mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-03-25 08:26:48 +00:00
feat: add DAG progress dots to browse page via WebSocket events
- Add TRANSCRIPT_DAG_STATUS handler to UserEventsProvider with DagStatusContext and useDagStatusMap hook for live DAG task updates - Clean up dagStatusMap entries when TRANSCRIPT_STATUS transitions away from "processing" - Create DagProgressDots component rendering color-coded dots per DAG task (green=completed, blue pulsing=running, hollow=queued, red=failed, gray=cancelled) with humanized tooltip names - Wire dagStatusMap through browse page -> TranscriptCards -> TranscriptStatusIcon, falling back to REST dag_status field
This commit is contained in:
61
www/app/(app)/browse/_components/DagProgressDots.tsx
Normal file
61
www/app/(app)/browse/_components/DagProgressDots.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { Box, Flex } from "@chakra-ui/react";
|
||||
import type { DagTask } from "../../../lib/UserEventsProvider";
|
||||
|
||||
const pulseKeyframes = `
|
||||
@keyframes dagDotPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
`;
|
||||
|
||||
function humanizeTaskName(name: string): string {
|
||||
return name
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function dotProps(status: DagTask["status"]): Record<string, unknown> {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return { bg: "green.500" };
|
||||
case "running":
|
||||
return {
|
||||
bg: "blue.500",
|
||||
style: { animation: "dagDotPulse 1.5s ease-in-out infinite" },
|
||||
};
|
||||
case "failed":
|
||||
return { bg: "red.500" };
|
||||
case "cancelled":
|
||||
return { bg: "gray.400" };
|
||||
case "queued":
|
||||
default:
|
||||
return {
|
||||
bg: "transparent",
|
||||
border: "1px solid",
|
||||
borderColor: "gray.400",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function DagProgressDots({ tasks }: { tasks: DagTask[] }) {
|
||||
return (
|
||||
<>
|
||||
<style>{pulseKeyframes}</style>
|
||||
<Flex gap="2px" alignItems="center" flexWrap="wrap">
|
||||
{tasks.map((task) => (
|
||||
<Box
|
||||
key={task.name}
|
||||
w="4px"
|
||||
h="4px"
|
||||
borderRadius="full"
|
||||
flexShrink={0}
|
||||
title={humanizeTaskName(task.name)}
|
||||
{...dotProps(task.status)}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
generateTextFragment,
|
||||
} from "../../../lib/textHighlight";
|
||||
import type { components } from "../../../reflector-api";
|
||||
import type { DagTask } from "../../../lib/UserEventsProvider";
|
||||
|
||||
type SearchResult = components["schemas"]["SearchResult"];
|
||||
type SourceKind = components["schemas"]["SourceKind"];
|
||||
@@ -29,6 +30,7 @@ interface TranscriptCardsProps {
|
||||
isLoading?: boolean;
|
||||
onDelete: (transcriptId: string) => void;
|
||||
onReprocess: (transcriptId: string) => void;
|
||||
dagStatusMap?: Map<string, DagTask[]>;
|
||||
}
|
||||
|
||||
function highlightText(text: string, query: string): React.ReactNode {
|
||||
@@ -102,11 +104,13 @@ function TranscriptCard({
|
||||
query,
|
||||
onDelete,
|
||||
onReprocess,
|
||||
dagStatusMap,
|
||||
}: {
|
||||
result: SearchResult;
|
||||
query: string;
|
||||
onDelete: (transcriptId: string) => void;
|
||||
onReprocess: (transcriptId: string) => void;
|
||||
dagStatusMap?: Map<string, DagTask[]>;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
@@ -137,7 +141,18 @@ function TranscriptCard({
|
||||
<Box borderWidth={1} p={4} borderRadius="md" fontSize="sm">
|
||||
<Flex justify="space-between" alignItems="flex-start" gap="2">
|
||||
<Box>
|
||||
<TranscriptStatusIcon status={result.status} />
|
||||
<TranscriptStatusIcon
|
||||
status={result.status}
|
||||
dagStatus={
|
||||
dagStatusMap?.get(result.id) ??
|
||||
(
|
||||
(result as Record<string, unknown>).dag_status as
|
||||
| { tasks?: DagTask[] }
|
||||
| undefined
|
||||
)?.tasks ??
|
||||
null
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex="1">
|
||||
{/* Title with highlighting and text fragment for deep linking */}
|
||||
@@ -284,6 +299,7 @@ export default function TranscriptCards({
|
||||
isLoading,
|
||||
onDelete,
|
||||
onReprocess,
|
||||
dagStatusMap,
|
||||
}: TranscriptCardsProps) {
|
||||
return (
|
||||
<Box position="relative">
|
||||
@@ -315,6 +331,7 @@ export default function TranscriptCards({
|
||||
query={query}
|
||||
onDelete={onDelete}
|
||||
onReprocess={onReprocess}
|
||||
dagStatusMap={dagStatusMap}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -8,13 +8,17 @@ import {
|
||||
FaGear,
|
||||
} from "react-icons/fa6";
|
||||
import { TranscriptStatus } from "../../../lib/transcript";
|
||||
import type { DagTask } from "../../../lib/UserEventsProvider";
|
||||
import DagProgressDots from "./DagProgressDots";
|
||||
|
||||
interface TranscriptStatusIconProps {
|
||||
status: TranscriptStatus;
|
||||
dagStatus?: DagTask[] | null;
|
||||
}
|
||||
|
||||
export default function TranscriptStatusIcon({
|
||||
status,
|
||||
dagStatus,
|
||||
}: TranscriptStatusIconProps) {
|
||||
switch (status) {
|
||||
case "ended":
|
||||
@@ -36,6 +40,9 @@ export default function TranscriptStatusIcon({
|
||||
</Box>
|
||||
);
|
||||
case "processing":
|
||||
if (dagStatus && dagStatus.length > 0) {
|
||||
return <DagProgressDots tasks={dagStatus} />;
|
||||
}
|
||||
return (
|
||||
<Box as="span" title="Processing in progress">
|
||||
<Icon color="gray.500" as={FaGear} />
|
||||
|
||||
@@ -43,6 +43,7 @@ import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog";
|
||||
import { formatLocalDate } from "../../lib/time";
|
||||
import { RECORD_A_MEETING_URL } from "../../api/urls";
|
||||
import { useUserName } from "../../lib/useUserName";
|
||||
import { useDagStatusMap } from "../../lib/UserEventsProvider";
|
||||
|
||||
const SEARCH_FORM_QUERY_INPUT_NAME = "query" as const;
|
||||
|
||||
@@ -273,6 +274,7 @@ export default function TranscriptBrowser() {
|
||||
}, [JSON.stringify(searchFilters)]);
|
||||
|
||||
const userName = useUserName();
|
||||
const dagStatusMap = useDagStatusMap();
|
||||
const [deletionLoading, setDeletionLoading] = useState(false);
|
||||
const cancelRef = React.useRef(null);
|
||||
const [transcriptToDeleteId, setTranscriptToDeleteId] =
|
||||
@@ -408,6 +410,7 @@ export default function TranscriptBrowser() {
|
||||
isLoading={searchLoading}
|
||||
onDelete={setTranscriptToDeleteId}
|
||||
onReprocess={handleProcessTranscript}
|
||||
dagStatusMap={dagStatusMap}
|
||||
/>
|
||||
|
||||
{!searchLoading && results.length === 0 && (
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { WEBSOCKET_URL } from "./apiClient";
|
||||
import { useAuth } from "./AuthProvider";
|
||||
import { z } from "zod";
|
||||
import { invalidateTranscriptLists, TRANSCRIPT_SEARCH_URL } from "./apiHooks";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const DagStatusContext = React.createContext<Map<string, DagTask[]>>(new Map());
|
||||
|
||||
export function useDagStatusMap() {
|
||||
return React.useContext(DagStatusContext);
|
||||
}
|
||||
|
||||
const UserEvent = z.object({
|
||||
event: z.string(),
|
||||
});
|
||||
@@ -95,6 +121,9 @@ export function UserEventsProvider({
|
||||
const queryClient = useQueryClient();
|
||||
const tokenRef = useRef<string | null>(null);
|
||||
const detachRef = useRef<(() => void) | null>(null);
|
||||
const [dagStatusMap, setDagStatusMap] = useState<Map<string, DagTask[]>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Only tear down when the user is truly unauthenticated
|
||||
@@ -133,20 +162,46 @@ export function UserEventsProvider({
|
||||
if (!detachRef.current) {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const msg = UserEvent.parse(JSON.parse(event.data));
|
||||
const fullMsg = JSON.parse(event.data);
|
||||
const msg = UserEvent.parse(fullMsg);
|
||||
const eventName = msg.event;
|
||||
|
||||
const invalidateList = () => invalidateTranscriptLists(queryClient);
|
||||
|
||||
switch (eventName) {
|
||||
case "TRANSCRIPT_CREATED":
|
||||
case "TRANSCRIPT_DELETED":
|
||||
case "TRANSCRIPT_STATUS":
|
||||
case "TRANSCRIPT_FINAL_TITLE":
|
||||
case "TRANSCRIPT_DURATION":
|
||||
invalidateList().then(() => {});
|
||||
break;
|
||||
|
||||
case "TRANSCRIPT_STATUS": {
|
||||
invalidateList().then(() => {});
|
||||
const transcriptId = fullMsg.data?.id as string | undefined;
|
||||
const status = fullMsg.data?.value as string | undefined;
|
||||
if (transcriptId && status && status !== "processing") {
|
||||
setDagStatusMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(transcriptId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "TRANSCRIPT_DAG_STATUS": {
|
||||
const transcriptId = fullMsg.data?.id as string | undefined;
|
||||
const tasks = fullMsg.data?.tasks as DagTask[] | undefined;
|
||||
if (transcriptId && tasks) {
|
||||
setDagStatusMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(transcriptId, tasks);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// Ignore other content events for list updates
|
||||
break;
|
||||
@@ -176,5 +231,9 @@ export function UserEventsProvider({
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<DagStatusContext.Provider value={dagStatusMap}>
|
||||
{children}
|
||||
</DagStatusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user