diff --git a/app/routes/$orgSlug/settings/data-management/+components/job-history.tsx b/app/routes/$orgSlug/settings/data-management/+components/job-history.tsx new file mode 100644 index 00000000..0f6b37dc --- /dev/null +++ b/app/routes/$orgSlug/settings/data-management/+components/job-history.tsx @@ -0,0 +1,206 @@ +import type { RunStatus, TypedClientRun } from '@coji/durably-react' +import { + Alert, + AlertDescription, + Badge, + Button, + Stack, +} from '~/app/components/ui' +import { Progress } from '~/app/components/ui/progress' +import dayjs from '~/app/libs/dayjs' + +type Run = TypedClientRun< + Record, + Record | undefined +> + +export function isRunActive(status: RunStatus): boolean { + return status === 'pending' || status === 'leased' +} + +const jobNameColors: Record = { + crawl: 'bg-blue-100 text-blue-800', + recalculate: 'bg-purple-100 text-purple-800', + classify: 'bg-amber-100 text-amber-800', + backfill: 'bg-emerald-100 text-emerald-800', +} + +function StatusBadge({ status }: { status: RunStatus }) { + const variant = + { + pending: 'outline' as const, + leased: 'secondary' as const, + completed: 'default' as const, + failed: 'destructive' as const, + cancelled: 'outline' as const, + }[status] ?? ('outline' as const) + + return {status} +} + +function RunItem({ + run, + onCancel, + onRetrigger, + isActing, +}: { + run: Run + onCancel: (runId: string) => void + onRetrigger: (runId: string) => void + isActing: boolean +}) { + const isRunning = isRunActive(run.status) + const canRetrigger = run.status === 'failed' || run.status === 'cancelled' + + return ( +
+
+
+ + {run.jobName} + + +
+
+ + {dayjs.utc(run.createdAt).fromNow()} + + {isRunning && ( + + )} + {canRetrigger && ( + + )} +
+
+ + {isRunning && run.progress && ( +
+

+ {run.progress.message ?? 'Processing...'} +

+ {run.progress.current != null && + run.progress.total != null && + run.progress.total > 0 && ( + + )} +
+ )} + + {run.status === 'completed' && run.output && ( +

+ {(() => { + const pullCount = (run.output as { pullCount?: number }).pullCount + return pullCount != null ? `${pullCount} PRs updated` : 'Done' + })()} +

+ )} + + {run.status === 'failed' && run.error && ( +

{run.error}

+ )} +
+ ) +} + +export function JobHistory({ + runs, + page, + hasMore, + isLoading, + onNextPage, + onPrevPage, + onCancel, + onRetrigger, + isActing, + actionError, +}: { + runs: Run[] + page: number + hasMore: boolean + isLoading: boolean + onNextPage: () => void + onPrevPage: () => void + onCancel: (runId: string) => void + onRetrigger: (runId: string) => void + isActing: boolean + actionError: string | null +}) { + return ( + +
+

Job History

+

+ Recent job executions for this organization. +

+
+ + {actionError && ( + + {actionError} + + )} + + {isLoading && runs.length === 0 ? ( +

Loading...

+ ) : runs.length === 0 ? ( +

No job history yet.

+ ) : ( + + {runs.map((run) => ( + + ))} + + )} + + {(page > 0 || hasMore) && ( +
+ + Page {page + 1} + +
+ )} +
+ ) +} diff --git a/app/routes/$orgSlug/settings/data-management/index.tsx b/app/routes/$orgSlug/settings/data-management/index.tsx index d7c89100..56c76373 100644 --- a/app/routes/$orgSlug/settings/data-management/index.tsx +++ b/app/routes/$orgSlug/settings/data-management/index.tsx @@ -1,21 +1,20 @@ import { useState } from 'react' -import { data, href, useFetcher } from 'react-router' +import { data, href, useFetcher, useLoaderData } from 'react-router' import { match } from 'ts-pattern' import { Alert, AlertDescription, - Badge, Button, Checkbox, Label, Stack, } from '~/app/components/ui' -import { Progress } from '~/app/components/ui/progress' import { orgContext } from '~/app/middleware/context' import { durably } from '~/app/services/durably' import { durably as serverDurably } from '~/app/services/durably.server' import type { JobSteps } from '~/app/services/jobs/shared-steps.server' import ContentSection from '../+components/content-section' +import { JobHistory, isRunActive } from './+components/job-history' import type { Route } from './+types/index' export const handle = { @@ -25,6 +24,11 @@ export const handle = { }), } +export const loader = ({ context }: Route.LoaderArgs) => { + const { organization } = context.get(orgContext) + return data({ organizationId: organization.id }) +} + export const action = async ({ request, context }: Route.ActionArgs) => { const { organization: org } = context.get(orgContext) const formData = await request.formData() @@ -33,14 +37,14 @@ export const action = async ({ request, context }: Route.ActionArgs) => { return match(intent) .with('refresh', async () => { try { - const run = await serverDurably.jobs.crawl.trigger( + await serverDurably.jobs.crawl.trigger( { organizationId: org.id, refresh: true }, { concurrencyKey: `crawl:${org.id}`, labels: { organizationId: org.id }, }, ) - return data({ intent: 'refresh' as const, ok: true, runId: run.id }) + return data({ intent: 'refresh' as const, ok: true }) } catch { return data( { intent: 'refresh' as const, error: 'Failed to start refresh' }, @@ -66,18 +70,14 @@ export const action = async ({ request, context }: Route.ActionArgs) => { } try { - const run = await serverDurably.jobs.recalculate.trigger( + await serverDurably.jobs.recalculate.trigger( { organizationId: org.id, steps }, { concurrencyKey: `recalculate:${org.id}`, labels: { organizationId: org.id }, }, ) - return data({ - intent: 'recalculate' as const, - ok: true, - runId: run.id, - }) + return data({ intent: 'recalculate' as const, ok: true }) } catch { return data( { @@ -91,119 +91,19 @@ export const action = async ({ request, context }: Route.ActionArgs) => { .otherwise(() => data({ error: 'Invalid intent' }, { status: 400 })) } -// --- Shared Run Status Alerts --- - -function RunStatusAlerts({ - label, - progress, - output, - runError, - triggerError, - isRunning, - isCompleted, - isFailed, -}: { - label: string - progress: { message?: string; current?: number; total?: number } | null - output: { pullCount?: number } | null - runError: string | null - triggerError: string | null - isRunning: boolean - isCompleted: boolean - isFailed: boolean -}) { - if (isRunning && progress) { - return ( - - -
-

{progress.message ?? 'Processing...'}

- {progress.current != null && - progress.total != null && - progress.total > 0 && ( - - )} -
-
-
- ) - } - - if (isRunning) { - return ( - - Starting {label}... - - ) - } - - const capitalizedLabel = label.charAt(0).toUpperCase() + label.slice(1) - - if (isCompleted) { - return ( - - - {capitalizedLabel} completed.{' '} - {output?.pullCount != null && `${output.pullCount} PRs updated.`} - - - ) - } - - if (isFailed) { - return ( - - - {capitalizedLabel} failed. {runError} - - - ) - } - - if (triggerError) { - return ( - - {triggerError} - - ) - } - - return null -} - // --- Refresh Section --- -function RefreshSection() { +function RefreshSection({ isRunning }: { isRunning: boolean }) { const fetcher = useFetcher() const isSubmitting = fetcher.state !== 'idle' - - const runId = - fetcher.data?.intent === 'refresh' && fetcher.data?.ok - ? fetcher.data.runId - : null - const { - progress, - output, - error: runError, - isPending, - isLeased, - isCompleted, - isFailed, - } = durably.crawl.useRun(runId) - - const isRunning = isPending || isLeased + const triggerError = + fetcher.data?.intent === 'refresh' ? fetcher.data?.error : null return (
-

- Full Refresh - {isRunning && Running} -

+

Full Refresh

Re-fetch all PR data from GitHub immediately.

@@ -216,46 +116,25 @@ function RefreshSection() {
- + {triggerError && ( + + {triggerError} + + )} ) } // --- Recalculate Section --- -function RecalculateSection() { +function RecalculateSection({ isRunning }: { isRunning: boolean }) { const fetcher = useFetcher() const [upsert, setUpsert] = useState(true) const [exportData, setExportData] = useState(false) const noneSelected = !upsert && !exportData - - const runId = - fetcher.data?.intent === 'recalculate' && fetcher.data?.ok - ? fetcher.data.runId - : null - const { - progress, - output, - error: runError, - isPending, - isLeased, - isCompleted, - isFailed, - } = durably.recalculate.useRun(runId) - - const isRunning = isPending || isLeased const isSubmitting = fetcher.state !== 'idle' + const triggerError = + fetcher.data?.intent === 'recalculate' ? fetcher.data?.error : null return ( @@ -308,18 +187,11 @@ function RecalculateSection() { - + {triggerError && ( + + {triggerError} + + )} ) } @@ -371,15 +243,49 @@ function ExportDataSection({ orgSlug }: { orgSlug: string }) { export default function DataManagementPage({ params: { orgSlug }, }: Route.ComponentProps) { + const { organizationId } = useLoaderData() + + const { runs, page, hasMore, isLoading, nextPage, prevPage } = + durably.useRuns({ + labels: { organizationId }, + pageSize: 10, + }) + + const { + cancel, + retrigger, + isLoading: isActing, + error: actionError, + } = durably.useRunActions() + + const isCrawlRunning = runs.some( + (r) => r.jobName === 'crawl' && isRunActive(r.status), + ) + const isRecalculateRunning = runs.some( + (r) => r.jobName === 'recalculate' && isRunActive(r.status), + ) + return ( - - + + + )