diff --git a/www/app/(app)/transcripts/[transcriptId]/processing/DagProgressTable.tsx b/www/app/(app)/transcripts/[transcriptId]/processing/DagProgressTable.tsx new file mode 100644 index 00000000..20bf77b8 --- /dev/null +++ b/www/app/(app)/transcripts/[transcriptId]/processing/DagProgressTable.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Table, Box, Icon, Spinner, Text, Badge } from "@chakra-ui/react"; +import { FaCheck, FaXmark, FaClock, FaMinus } from "react-icons/fa6"; +import type { DagTask, DagTaskStatus } from "../../useWebSockets"; + +function humanizeTaskName(name: string): string { + return name + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function formatDuration(seconds: number): string { + if (seconds < 60) { + return `${Math.round(seconds)}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + return `${minutes}m ${remainingSeconds}s`; +} + +function StatusIcon({ status }: { status: DagTaskStatus }) { + switch (status) { + case "completed": + return ( + + + + ); + case "running": + return ; + case "failed": + return ( + + + + ); + case "queued": + return ( + + + + ); + case "cancelled": + return ( + + + + ); + default: + return null; + } +} + +function ElapsedTimer({ startedAt }: { startedAt: string }) { + const [elapsed, setElapsed] = useState(() => { + return (Date.now() - new Date(startedAt).getTime()) / 1000; + }); + + useEffect(() => { + const interval = setInterval(() => { + setElapsed((Date.now() - new Date(startedAt).getTime()) / 1000); + }, 1000); + return () => clearInterval(interval); + }, [startedAt]); + + return {formatDuration(elapsed)}; +} + +function DurationCell({ task }: { task: DagTask }) { + if (task.status === "completed" && task.duration_seconds !== null) { + return {formatDuration(task.duration_seconds)}; + } + if (task.status === "running" && task.started_at) { + return ; + } + return ( + + -- + + ); +} + +function ProgressCell({ task }: { task: DagTask }) { + if (task.progress_pct === null && task.children_total === null) { + return null; + } + + return ( + + {task.progress_pct !== null && ( + + + + )} + {task.children_total !== null && ( + + {task.children_completed ?? 0}/{task.children_total} + + )} + + ); +} + +function TaskRow({ task }: { task: DagTask }) { + const [expanded, setExpanded] = useState(false); + const hasFailed = task.status === "failed" && task.error; + + return ( + <> + setExpanded((prev) => !prev) : undefined} + _hover={hasFailed ? { bg: "gray.50" } : undefined} + > + + + {humanizeTaskName(task.name)} + + + + + + + + + + + + + {hasFailed && expanded && ( + + + + + {task.error} + + + + + )} + + ); +} + +export default function DagProgressTable({ tasks }: { tasks: DagTask[] }) { + return ( + + + + + Task + + Status + + + Duration + + + Progress + + + + + {tasks.map((task) => ( + + ))} + + + + ); +} diff --git a/www/app/(app)/transcripts/[transcriptId]/processing/page.tsx b/www/app/(app)/transcripts/[transcriptId]/processing/page.tsx index 0b7affaf..ad5f6e11 100644 --- a/www/app/(app)/transcripts/[transcriptId]/processing/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/processing/page.tsx @@ -11,6 +11,9 @@ import { import { useRouter } from "next/navigation"; import { useTranscriptGet } from "../../../../lib/apiHooks"; import { parseNonEmptyString } from "../../../../lib/utils"; +import { useWebSockets } from "../../useWebSockets"; +import type { DagTask } from "../../useWebSockets"; +import DagProgressTable from "./DagProgressTable"; type TranscriptProcessing = { params: Promise<{ @@ -24,9 +27,18 @@ export default function TranscriptProcessing(details: TranscriptProcessing) { const router = useRouter(); const transcript = useTranscriptGet(transcriptId); + const { status: wsStatus, dagStatus: wsDagStatus } = + useWebSockets(transcriptId); + + const restDagStatus: DagTask[] | null = + ((transcript.data as Record)?.dag_status as + | DagTask[] + | null) ?? null; + + const dagStatus = wsDagStatus ?? restDagStatus; useEffect(() => { - const status = transcript.data?.status; + const status = wsStatus?.value ?? transcript.data?.status; if (!status) return; if (status === "ended" || status === "error") { @@ -41,6 +53,7 @@ export default function TranscriptProcessing(details: TranscriptProcessing) { router.replace(dest); } }, [ + wsStatus?.value, transcript.data?.status, transcript.data?.source_kind, router, @@ -74,11 +87,29 @@ export default function TranscriptProcessing(details: TranscriptProcessing) { w={{ base: "full", md: "container.xl" }} >
- - - - Processing recording - + + {dagStatus ? ( + <> + + Processing recording + + + + ) : ( + <> + + + Processing recording + + + )} You can safely return to the library while your recording is being processed. diff --git a/www/app/(app)/transcripts/useWebSockets.ts b/www/app/(app)/transcripts/useWebSockets.ts index 47c036b8..ab05f59d 100644 --- a/www/app/(app)/transcripts/useWebSockets.ts +++ b/www/app/(app)/transcripts/useWebSockets.ts @@ -14,6 +14,26 @@ import { } from "../../lib/apiHooks"; import { NonEmptyString } from "../../lib/utils"; +export type DagTaskStatus = + | "queued" + | "running" + | "completed" + | "failed" + | "cancelled"; + +export type DagTask = { + name: string; + status: DagTaskStatus; + started_at: string | null; + finished_at: string | null; + duration_seconds: number | null; + parents: string[]; + error: string | null; + children_total: number | null; + children_completed: number | null; + progress_pct: number | null; +}; + export type UseWebSockets = { transcriptTextLive: string; translateText: string; @@ -24,6 +44,7 @@ export type UseWebSockets = { status: Status | null; waveform: AudioWaveform | null; duration: number | null; + dagStatus: DagTask[] | null; }; export const useWebSockets = (transcriptId: string | null): UseWebSockets => { @@ -40,6 +61,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { summary: "", }); const [status, setStatus] = useState(null); + const [dagStatus, setDagStatus] = useState(null); const { setError } = useError(); const queryClient = useQueryClient(); @@ -436,6 +458,25 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { } break; + case "DAG_STATUS": + if (message.data?.tasks) { + setDagStatus(message.data.tasks); + } + break; + + case "DAG_TASK_PROGRESS": + if (message.data) { + setDagStatus( + (prev) => + prev?.map((t) => + t.name === message.data.task_name + ? { ...t, progress_pct: message.data.progress_pct } + : t, + ) ?? null, + ); + } + break; + default: setError( new Error(`Received unknown WebSocket event: ${message.event}`), @@ -493,5 +534,6 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { status, waveform, duration, + dagStatus, }; };