WorkflowLogs.tsx 7.1 KB

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