mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-03-22 07:06:47 +00:00
feat: add DAG progress WebSocket handlers and processing page table
Add DAG_STATUS and DAG_TASK_PROGRESS event handlers to useWebSockets hook with exported DagTask/DagTaskStatus types. Create DagProgressTable component with status icons, live elapsed timers, progress bars, and expandable error rows. Wire into processing page with REST fallback.
This commit is contained in:
@@ -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 (
|
||||
<Box as="span" title="Completed">
|
||||
<Icon color="green.500" as={FaCheck} />
|
||||
</Box>
|
||||
);
|
||||
case "running":
|
||||
return <Spinner size="sm" color="blue.500" />;
|
||||
case "failed":
|
||||
return (
|
||||
<Box as="span" title="Failed">
|
||||
<Icon color="red.500" as={FaXmark} />
|
||||
</Box>
|
||||
);
|
||||
case "queued":
|
||||
return (
|
||||
<Box as="span" title="Queued">
|
||||
<Icon color="gray.400" as={FaClock} />
|
||||
</Box>
|
||||
);
|
||||
case "cancelled":
|
||||
return (
|
||||
<Box as="span" title="Cancelled">
|
||||
<Icon color="gray.400" as={FaMinus} />
|
||||
</Box>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ElapsedTimer({ startedAt }: { startedAt: string }) {
|
||||
const [elapsed, setElapsed] = useState<number>(() => {
|
||||
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 <Text fontSize="sm">{formatDuration(elapsed)}</Text>;
|
||||
}
|
||||
|
||||
function DurationCell({ task }: { task: DagTask }) {
|
||||
if (task.status === "completed" && task.duration_seconds !== null) {
|
||||
return <Text fontSize="sm">{formatDuration(task.duration_seconds)}</Text>;
|
||||
}
|
||||
if (task.status === "running" && task.started_at) {
|
||||
return <ElapsedTimer startedAt={task.started_at} />;
|
||||
}
|
||||
return (
|
||||
<Text fontSize="sm" color="gray.400">
|
||||
--
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressCell({ task }: { task: DagTask }) {
|
||||
if (task.progress_pct === null && task.children_total === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{task.progress_pct !== null && (
|
||||
<Box
|
||||
w="100%"
|
||||
h="6px"
|
||||
bg="gray.200"
|
||||
borderRadius="full"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box
|
||||
h="100%"
|
||||
w={`${Math.min(100, Math.max(0, task.progress_pct))}%`}
|
||||
bg={task.status === "failed" ? "red.400" : "blue.400"}
|
||||
borderRadius="full"
|
||||
transition="width 0.3s ease"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{task.children_total !== null && (
|
||||
<Badge
|
||||
size="sm"
|
||||
colorPalette="gray"
|
||||
mt={task.progress_pct !== null ? 1 : 0}
|
||||
>
|
||||
{task.children_completed ?? 0}/{task.children_total}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({ task }: { task: DagTask }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasFailed = task.status === "failed" && task.error;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.Row
|
||||
cursor={hasFailed ? "pointer" : "default"}
|
||||
onClick={hasFailed ? () => setExpanded((prev) => !prev) : undefined}
|
||||
_hover={hasFailed ? { bg: "gray.50" } : undefined}
|
||||
>
|
||||
<Table.Cell>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{humanizeTaskName(task.name)}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<StatusIcon status={task.status} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DurationCell task={task} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<ProgressCell task={task} />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{hasFailed && expanded && (
|
||||
<Table.Row>
|
||||
<Table.Cell colSpan={4}>
|
||||
<Box bg="red.50" p={3} borderRadius="md">
|
||||
<Text fontSize="xs" color="red.700" whiteSpace="pre-wrap">
|
||||
{task.error}
|
||||
</Text>
|
||||
</Box>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DagProgressTable({ tasks }: { tasks: DagTask[] }) {
|
||||
return (
|
||||
<Box w="100%" overflowX="auto">
|
||||
<Table.Root size="sm">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader fontWeight="600">Task</Table.ColumnHeader>
|
||||
<Table.ColumnHeader fontWeight="600" width="80px">
|
||||
Status
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader fontWeight="600" width="100px">
|
||||
Duration
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader fontWeight="600" width="140px">
|
||||
Progress
|
||||
</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{tasks.map((task) => (
|
||||
<TaskRow key={task.name} task={task} />
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>)?.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" }}
|
||||
>
|
||||
<Center h={"full"} w="full">
|
||||
<VStack gap={10} bg="gray.100" p={10} borderRadius="md" maxW="500px">
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
<Heading size={"md"} textAlign="center">
|
||||
Processing recording
|
||||
</Heading>
|
||||
<VStack
|
||||
gap={10}
|
||||
bg="gray.100"
|
||||
p={10}
|
||||
borderRadius="md"
|
||||
maxW="600px"
|
||||
w="full"
|
||||
>
|
||||
{dagStatus ? (
|
||||
<>
|
||||
<Heading size={"md"} textAlign="center">
|
||||
Processing recording
|
||||
</Heading>
|
||||
<DagProgressTable tasks={dagStatus} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
<Heading size={"md"} textAlign="center">
|
||||
Processing recording
|
||||
</Heading>
|
||||
</>
|
||||
)}
|
||||
<Text color="gray.600" textAlign="center">
|
||||
You can safely return to the library while your recording is being
|
||||
processed.
|
||||
|
||||
@@ -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<Status | null>(null);
|
||||
const [dagStatus, setDagStatus] = useState<DagTask[] | null>(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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user