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,
};
};