WorkflowLogs.tsx209 lines · main
1import { groupBy } from 'lodash'
2import { ArrowLeft, ArrowRight } from 'lucide-react'
3import { useState } from 'react'
4import {
5 Button,
6 cn,
7 Dialog,
8 DialogContent,
9 DialogDescription,
10 DialogHeader,
11 DialogSection,
12 DialogSectionSeparator,
13 DialogTitle,
14 DialogTrigger,
15 StatusIcon,
16} from 'ui'
17import { GenericSkeletonLoader, TimestampInfo } from 'ui-patterns'
18
19import { ActionStatusBadge, ActionStatusBadgeCondensed, STATUS_TO_LABEL } from './ActionStatusBadge'
20import BranchStatusBadge from './BranchStatusBadge'
21import AlertError from '@/components/ui/AlertError'
22import { ActionRunData } from '@/data/actions/action-detail-query'
23import { useActionRunLogsQuery } from '@/data/actions/action-logs-query'
24import {
25 useActionsQuery,
26 type ActionRunStep,
27 type ActionStatus,
28} from '@/data/actions/action-runs-query'
29import type { Branch } from '@/data/branches/branches-query'
30
31interface WorkflowLogsProps {
32 branch: Branch
33}
34
35type StatusType = Branch['status']
36
37const HEALTHY_STATUSES: StatusType[] = ['FUNCTIONS_DEPLOYED', 'MIGRATIONS_PASSED']
38const UNHEALTHY_STATUSES: StatusType[] = ['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED']
39
40export const WorkflowLogs = ({ branch }: WorkflowLogsProps) => {
41 const { project_ref: projectRef, status, name } = branch
42 const [isOpen, setIsOpen] = useState(false)
43
44 const {
45 data: workflowRuns,
46 isSuccess: isWorkflowRunsSuccess,
47 isPending: isWorkflowRunsLoading,
48 isError: isWorkflowRunsError,
49 error: workflowRunsError,
50 } = useActionsQuery({ ref: projectRef }, { enabled: isOpen })
51
52 const [selectedWorkflowRun, setSelectedWorkflowRun] = useState<ActionRunData>()
53
54 const {
55 data: workflowRunLogs,
56 isSuccess: isWorkflowRunLogsSuccess,
57 isPending: isWorkflowRunLogsLoading,
58 isError: isWorkflowRunLogsError,
59 error: workflowRunLogsError,
60 } = useActionRunLogsQuery(
61 { projectRef, runId: selectedWorkflowRun?.id },
62 { enabled: isOpen && Boolean(selectedWorkflowRun) }
63 )
64
65 const showStatusIcon = !HEALTHY_STATUSES.includes(status)
66 const isUnhealthy = UNHEALTHY_STATUSES.includes(status)
67
68 return (
69 <Dialog open={isOpen} onOpenChange={setIsOpen}>
70 <DialogTrigger asChild>
71 <Button
72 type="default"
73 icon={
74 showStatusIcon ? (
75 <StatusIcon variant={isUnhealthy ? 'destructive' : 'default'} hideBackground />
76 ) : undefined
77 }
78 onClick={(e) => e.stopPropagation()}
79 >
80 View Logs
81 </Button>
82 </DialogTrigger>
83
84 <DialogContent size="xlarge">
85 <DialogHeader>
86 <DialogTitle>Workflow logs for {name}</DialogTitle>
87 <DialogDescription>
88 {!selectedWorkflowRun ? (
89 'Select a workflow run to view logs'
90 ) : (
91 <>
92 Run created at{' '}
93 <TimestampInfo className="text-sm" utcTimestamp={selectedWorkflowRun.created_at} />
94 </>
95 )}
96 </DialogDescription>
97 </DialogHeader>
98
99 <DialogSectionSeparator />
100
101 <DialogSection className={cn('px-0!', isWorkflowRunLogsSuccess ? 'py-0 pt-2' : 'py-0!')}>
102 {!selectedWorkflowRun ? (
103 <>
104 {isWorkflowRunsLoading && <GenericSkeletonLoader className="py-4" />}
105 {isWorkflowRunsError && (
106 <div className="py-4">
107 <AlertError error={workflowRunsError} />
108 </div>
109 )}
110 {isWorkflowRunsSuccess &&
111 (workflowRuns.length > 0 ? (
112 <ul className="divide-y">
113 {workflowRuns.map((workflowRun) => (
114 <li key={workflowRun.id} className="px-4 py-3">
115 <button
116 type="button"
117 disabled={workflowRun.id === projectRef}
118 onClick={() => setSelectedWorkflowRun(workflowRun)}
119 className="flex items-center gap-2 w-full justify-between"
120 >
121 <div className="flex items-center gap-4">
122 {workflowRun.run_steps.length > 0 ? (
123 <RunSteps steps={workflowRun.run_steps} />
124 ) : (
125 <BranchStatusBadge status={status} />
126 )}
127
128 <TimestampInfo
129 className="text-sm"
130 utcTimestamp={workflowRun.created_at}
131 />
132 </div>
133 {workflowRun.id !== projectRef && <ArrowRight size={16} />}
134 </button>
135 </li>
136 ))}
137 </ul>
138 ) : (
139 <p className="text-center text-sm text-foreground-light py-4">
140 No workflow runs found.
141 </p>
142 ))}
143 </>
144 ) : (
145 <div className="px-4 flex flex-col gap-2 py-2">
146 <Button
147 onClick={() => setSelectedWorkflowRun(undefined)}
148 type="text"
149 icon={<ArrowLeft />}
150 className="self-start"
151 >
152 Back to workflow runs
153 </Button>
154
155 {isWorkflowRunLogsLoading && <GenericSkeletonLoader className="py-2" />}
156 {isWorkflowRunLogsError && (
157 <div className="py-2">
158 <AlertError
159 className="rounded-none"
160 subject="Failed to retrieve workflow logs"
161 error={workflowRunLogsError}
162 />
163 </div>
164 )}
165 {isWorkflowRunLogsSuccess && (
166 <pre className="whitespace-pre max-h-[500px] overflow-scroll pb-5 text-sm">
167 {workflowRunLogs}
168 </pre>
169 )}
170 </div>
171 )}
172 </DialogSection>
173 </DialogContent>
174 </Dialog>
175 )
176}
177
178function RunSteps({ steps }: { steps: Array<ActionRunStep> }) {
179 const stepsByStatus = groupBy(steps, 'status') as Record<ActionStatus, Array<ActionRunStep>>
180 const firstFailedStep = stepsByStatus.DEAD?.[0]
181 const numberFailedSteps = stepsByStatus.DEAD?.length ?? 0
182
183 return (
184 <>
185 {firstFailedStep && (
186 <ActionStatusBadge name={firstFailedStep.name} status={firstFailedStep.status} />
187 )}
188 {numberFailedSteps > 1 && (
189 <ActionStatusBadgeCondensed status={'DEAD'} details={stepsByStatus.DEAD.slice(1)}>
190 {numberFailedSteps - 1} more
191 </ActionStatusBadgeCondensed>
192 )}
193
194 <div className="flex items-center gap-x-2">
195 {(Object.keys(stepsByStatus) as Array<ActionStatus>)
196 .filter((status) => status !== 'DEAD')
197 .map((status) => (
198 <ActionStatusBadgeCondensed
199 key={status}
200 status={status}
201 details={stepsByStatus[status]}
202 >
203 {stepsByStatus[status].length} {STATUS_TO_LABEL[status]}
204 </ActionStatusBadgeCondensed>
205 ))}
206 </div>
207 </>
208 )
209}