diff --git a/apps/studio/components/interfaces/BranchManagement/ActionStatusBadge.tsx b/apps/studio/components/interfaces/BranchManagement/ActionStatusBadge.tsx new file mode 100644 index 00000000000..36cfe1cf8ad --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/ActionStatusBadge.tsx @@ -0,0 +1,89 @@ +import type { PropsWithChildren } from 'react' + +import { ActionName, ActionStatus, type ActionRunStep } from 'data/actions/action-runs-query' +import { Badge, StatusIcon, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +export interface ActionStatusBadgeProps { + name: ActionName + status: ActionStatus +} + +const UNHEALTHY_STATUES: ActionStatus[] = ['DEAD', 'REMOVING'] +const WAITING_STATUSES: ActionStatus[] = ['CREATED', 'RESTARTING', 'RUNNING'] + +export const STATUS_TO_LABEL: Record = { + CREATED: 'pending', + DEAD: 'failed', + EXITED: 'succeeded', + PAUSED: 'skipped', + REMOVING: 'failed', + RESTARTING: 'restarting', + RUNNING: 'running', +} + +const NAME_TO_LABEL: Record = { + clone: 'Cloning repo', + pull: 'Pulling data', + health: 'Health check', + configure: 'Configurations', + migrate: 'Migrations', + seed: 'Data seeding', + deploy: 'Functions deployment', +} + +export const ActionStatusBadgeCondensed = ({ + children, + status, + details, +}: PropsWithChildren<{ + status: ActionStatus + details: Array +}>) => { + if (status === 'EXITED') { + return null + } + + const isUnhealthy = UNHEALTHY_STATUES.includes(status) + const isWaiting = WAITING_STATUSES.includes(status) + + return ( + + + + {(isUnhealthy || isWaiting) && ( + + )} + {children} + + + + Additional {STATUS_TO_LABEL[status]} steps: +
    + {details.map((step) => ( +
  • + {NAME_TO_LABEL[step.name]} +
  • + ))} +
+
+
+ ) +} + +export const ActionStatusBadge = ({ name, status }: ActionStatusBadgeProps) => { + if (status === 'EXITED') { + return null + } + + const isUnhealthy = UNHEALTHY_STATUES.includes(status) + const isWaiting = WAITING_STATUSES.includes(status) + + return ( + + {(isUnhealthy || isWaiting) && ( + + )} + {NAME_TO_LABEL[name]}: {STATUS_TO_LABEL[status]} + + ) +} diff --git a/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx b/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx index fddc2017c14..29253f09d9b 100644 --- a/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx +++ b/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx @@ -1,12 +1,17 @@ -import type { Branch } from 'data/branches/branches-query' import dayjs from 'dayjs' +import { groupBy } from 'lodash' import { ArrowLeft, ArrowRight } from 'lucide-react' import { useState } from 'react' -import { StatusIcon } from 'ui' import AlertError from 'components/ui/AlertError' -import { useWorkflowRunLogsQuery } from 'data/workflow-runs/workflow-run-logs-query' -import { useWorkflowRunsQuery } from 'data/workflow-runs/workflow-runs-query' +import { ActionRunData } from 'data/actions/action-detail-query' +import { useActionRunLogsQuery } from 'data/actions/action-logs-query' +import { + useActionsQuery, + type ActionRunStep, + type ActionStatus, +} from 'data/actions/action-runs-query' +import type { Branch } from 'data/branches/branches-query' import { Button, cn, @@ -18,24 +23,21 @@ import { DialogSectionSeparator, DialogTitle, DialogTrigger, + StatusIcon, } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns' +import { ActionStatusBadge, ActionStatusBadgeCondensed, STATUS_TO_LABEL } from './ActionStatusBadge' import BranchStatusBadge from './BranchStatusBadge' interface WorkflowLogsProps { projectRef: string - status?: Branch['status'] | string + status: Branch['status'] } -type StatusType = Branch['status'] | string +type StatusType = Branch['status'] -const UNHEALTHY_STATUSES: StatusType[] = [ - 'ACTIVE_UNHEALTHY', - 'INIT_FAILED', - 'UNKNOWN', - 'MIGRATIONS_FAILED', - 'FUNCTIONS_FAILED', -] +const HEALTHY_STATUSES: StatusType[] = ['FUNCTIONS_DEPLOYED', 'MIGRATIONS_PASSED'] +const UNHEALTHY_STATUSES: StatusType[] = ['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'] export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => { const [isOpen, setIsOpen] = useState(false) @@ -46,9 +48,11 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => { isLoading: isWorkflowRunsLoading, isError: isWorkflowRunsError, error: workflowRunsError, - } = useWorkflowRunsQuery({ projectRef }, { enabled: isOpen }) + } = useActionsQuery({ ref: projectRef }, { enabled: isOpen }) - const [selectedWorkflowRunId, setSelectedWorkflowRunId] = useState(undefined) + const [selectedWorkflowRun, setSelectedWorkflowRun] = useState( + undefined + ) const { data: workflowRunLogs, @@ -56,17 +60,13 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => { isLoading: isWorkflowRunLogsLoading, isError: isWorkflowRunLogsError, error: workflowRunLogsError, - } = useWorkflowRunLogsQuery( - { workflowRunId: selectedWorkflowRunId }, - { enabled: isOpen && selectedWorkflowRunId !== undefined } + } = useActionRunLogsQuery( + { ref: projectRef, run_id: selectedWorkflowRun?.id ?? '' }, + { enabled: isOpen && Boolean(selectedWorkflowRun) } ) - const showStatusIcon = - status !== undefined && - status !== 'ACTIVE_HEALTHY' && - status !== 'FUNCTIONS_DEPLOYED' && - status !== 'MIGRATIONS_PASSED' - const isUnhealthy = status !== undefined && UNHEALTHY_STATUSES.includes(status) + const showStatusIcon = !HEALTHY_STATUSES.includes(status) + const isUnhealthy = UNHEALTHY_STATUSES.includes(status) return ( @@ -93,7 +93,7 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => { - {selectedWorkflowRunId === undefined ? ( + {!selectedWorkflowRun ? ( <> {isWorkflowRunsLoading && } {isWorkflowRunsError && ( @@ -108,11 +108,15 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
  • ) } + +function RunSteps({ steps }: { steps: Array }) { + const stepsByStatus = groupBy(steps, 'status') as Record> + const firstFailedStep = stepsByStatus.DEAD?.[0] + const numberFailedSteps = stepsByStatus.DEAD?.length ?? 0 + + return ( + <> + {firstFailedStep && ( + + )} + {numberFailedSteps > 1 && ( + + {numberFailedSteps - 1} more + + )} + {(Object.keys(stepsByStatus) as Array) + .filter((status) => status !== 'DEAD') + .map((status) => ( + + {stepsByStatus[status].length} {STATUS_TO_LABEL[status]} + + ))} + + ) +} diff --git a/apps/studio/data/actions/action-detail-query.ts b/apps/studio/data/actions/action-detail-query.ts new file mode 100644 index 00000000000..4e34849c78f --- /dev/null +++ b/apps/studio/data/actions/action-detail-query.ts @@ -0,0 +1,30 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import type { operations } from 'data/api' +import { get, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { actionKeys } from './keys' + +export type ActionRunVariables = operations['v1-get-action-run']['parameters']['path'] + +export async function getActionRun(params: ActionRunVariables, signal?: AbortSignal) { + const { data, error } = await get('/v1/projects/{ref}/actions/{run_id}', { + params: { path: params }, + signal, + }) + if (error) handleError(error) + return data +} + +export type ActionRunData = Awaited> +export type ActionRunError = ResponseError + +export const useActionRunQuery = ( + { ref, run_id }: ActionRunVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + actionKeys.detail(ref, run_id), + ({ signal }) => getActionRun({ ref, run_id }, signal), + { enabled: enabled && Boolean(ref) && Boolean(run_id), staleTime: 0, ...options } + ) diff --git a/apps/studio/data/actions/action-logs-query.ts b/apps/studio/data/actions/action-logs-query.ts new file mode 100644 index 00000000000..218eed45a72 --- /dev/null +++ b/apps/studio/data/actions/action-logs-query.ts @@ -0,0 +1,36 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { operations } from 'data/api' +import { get, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { actionKeys } from './keys' + +export type ActionLogsVariables = operations['v1-get-action-run-logs']['parameters']['path'] + +export async function getActionRunLogs(params: ActionLogsVariables, signal?: AbortSignal) { + const { data, error } = await get(`/v1/projects/{ref}/actions/{run_id}/logs`, { + params: { path: params }, + parseAs: 'text', + headers: { Accept: 'text/plain' }, + signal, + }) + if (error) handleError(error) + return data + .split('\n') + .flatMap((line) => line.split('\r')) + .join('\n') + .trim() +} + +export type ActionLogsData = Awaited> +export type WorkflowRunLogsError = ResponseError + +export const useActionRunLogsQuery = ( + { ref, run_id }: ActionLogsVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + actionKeys.detail(ref, run_id), + ({ signal }) => getActionRunLogs({ ref, run_id }, signal), + { enabled: enabled && Boolean(ref) && Boolean(run_id), staleTime: 0, ...options } + ) diff --git a/apps/studio/data/actions/action-runs-query.ts b/apps/studio/data/actions/action-runs-query.ts new file mode 100644 index 00000000000..d987fd3494d --- /dev/null +++ b/apps/studio/data/actions/action-runs-query.ts @@ -0,0 +1,34 @@ +import { useQuery, type UseQueryOptions } from '@tanstack/react-query' + +import type { operations } from 'data/api' +import { get, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { actionKeys } from './keys' + +export type ActionsVariables = operations['v1-list-action-runs']['parameters']['path'] + +export async function listActionRuns(params: ActionsVariables, signal?: AbortSignal) { + const { data, error } = await get(`/v1/projects/{ref}/actions`, { + params: { path: params }, + signal, + }) + if (error) handleError(error) + return data +} + +export type ActionsData = Awaited> +export type ActionsError = ResponseError + +export type ActionRunStep = ActionsData[number]['run_steps'][number] +export type ActionName = ActionRunStep['name'] +export type ActionStatus = ActionRunStep['status'] + +export const useActionsQuery = ( + { ref }: ActionsVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + actionKeys.list(ref), + ({ signal }) => listActionRuns({ ref }, signal), + { enabled: enabled && Boolean(ref), staleTime: 0, ...options } + ) diff --git a/apps/studio/data/actions/keys.ts b/apps/studio/data/actions/keys.ts new file mode 100644 index 00000000000..e2e3df04f90 --- /dev/null +++ b/apps/studio/data/actions/keys.ts @@ -0,0 +1,5 @@ +export const actionKeys = { + list: (projectRef: string | undefined) => ['projects', projectRef, 'actions'] as const, + detail: (projectRef: string | undefined, runId: string | undefined) => + ['projects', projectRef, 'actions', runId] as const, +} diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 737101c8742..e15025b4759 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -2882,7 +2882,7 @@ export interface components { * @description Resource indicator for MCP (Model Context Protocol) clients * @enum {string} */ - resource?: 'http://localhost:8080/mcp' | 'http://localhost:8080/mcp' + resource?: 'https://api.supabase.green/mcp' | 'https://mcp.supabase.green/mcp' } OAuthTokenResponse: { access_token: string @@ -4280,7 +4280,7 @@ export interface operations { organization_slug?: string redirect_uri: string /** @description Resource indicator for MCP (Model Context Protocol) clients */ - resource?: 'http://localhost:8080/mcp' | 'http://localhost:8080/mcp' + resource?: 'https://api.supabase.green/mcp' | 'https://mcp.supabase.green/mcp' response_mode?: string response_type: 'code' | 'token' | 'id_token token' scope?: string