mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-21 04:35:19 +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,
|
generateTextFragment,
|
||||||
} from "../../../lib/textHighlight";
|
} from "../../../lib/textHighlight";
|
||||||
import type { components } from "../../../reflector-api";
|
import type { components } from "../../../reflector-api";
|
||||||
|
import type { DagTask } from "../../../lib/UserEventsProvider";
|
||||||
|
|
||||||
type SearchResult = components["schemas"]["SearchResult"];
|
type SearchResult = components["schemas"]["SearchResult"];
|
||||||
type SourceKind = components["schemas"]["SourceKind"];
|
type SourceKind = components["schemas"]["SourceKind"];
|
||||||
@@ -29,6 +30,7 @@ interface TranscriptCardsProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onDelete: (transcriptId: string) => void;
|
onDelete: (transcriptId: string) => void;
|
||||||
onReprocess: (transcriptId: string) => void;
|
onReprocess: (transcriptId: string) => void;
|
||||||
|
dagStatusMap?: Map<string, DagTask[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightText(text: string, query: string): React.ReactNode {
|
function highlightText(text: string, query: string): React.ReactNode {
|
||||||
@@ -102,11 +104,13 @@ function TranscriptCard({
|
|||||||
query,
|
query,
|
||||||
onDelete,
|
onDelete,
|
||||||
onReprocess,
|
onReprocess,
|
||||||
|
dagStatusMap,
|
||||||
}: {
|
}: {
|
||||||
result: SearchResult;
|
result: SearchResult;
|
||||||
query: string;
|
query: string;
|
||||||
onDelete: (transcriptId: string) => void;
|
onDelete: (transcriptId: string) => void;
|
||||||
onReprocess: (transcriptId: string) => void;
|
onReprocess: (transcriptId: string) => void;
|
||||||
|
dagStatusMap?: Map<string, DagTask[]>;
|
||||||
}) {
|
}) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
@@ -137,7 +141,18 @@ function TranscriptCard({
|
|||||||
<Box borderWidth={1} p={4} borderRadius="md" fontSize="sm">
|
<Box borderWidth={1} p={4} borderRadius="md" fontSize="sm">
|
||||||
<Flex justify="space-between" alignItems="flex-start" gap="2">
|
<Flex justify="space-between" alignItems="flex-start" gap="2">
|
||||||
<Box>
|
<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>
|
||||||
<Box flex="1">
|
<Box flex="1">
|
||||||
{/* Title with highlighting and text fragment for deep linking */}
|
{/* Title with highlighting and text fragment for deep linking */}
|
||||||
@@ -284,6 +299,7 @@ export default function TranscriptCards({
|
|||||||
isLoading,
|
isLoading,
|
||||||
onDelete,
|
onDelete,
|
||||||
onReprocess,
|
onReprocess,
|
||||||
|
dagStatusMap,
|
||||||
}: TranscriptCardsProps) {
|
}: TranscriptCardsProps) {
|
||||||
return (
|
return (
|
||||||
<Box position="relative">
|
<Box position="relative">
|
||||||
@@ -315,6 +331,7 @@ export default function TranscriptCards({
|
|||||||
query={query}
|
query={query}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onReprocess={onReprocess}
|
onReprocess={onReprocess}
|
||||||
|
dagStatusMap={dagStatusMap}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -8,13 +8,17 @@ import {
|
|||||||
FaGear,
|
FaGear,
|
||||||
} from "react-icons/fa6";
|
} from "react-icons/fa6";
|
||||||
import { TranscriptStatus } from "../../../lib/transcript";
|
import { TranscriptStatus } from "../../../lib/transcript";
|
||||||
|
import type { DagTask } from "../../../lib/UserEventsProvider";
|
||||||
|
import DagProgressDots from "./DagProgressDots";
|
||||||
|
|
||||||
interface TranscriptStatusIconProps {
|
interface TranscriptStatusIconProps {
|
||||||
status: TranscriptStatus;
|
status: TranscriptStatus;
|
||||||
|
dagStatus?: DagTask[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TranscriptStatusIcon({
|
export default function TranscriptStatusIcon({
|
||||||
status,
|
status,
|
||||||
|
dagStatus,
|
||||||
}: TranscriptStatusIconProps) {
|
}: TranscriptStatusIconProps) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "ended":
|
case "ended":
|
||||||
@@ -36,6 +40,9 @@ export default function TranscriptStatusIcon({
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
case "processing":
|
case "processing":
|
||||||
|
if (dagStatus && dagStatus.length > 0) {
|
||||||
|
return <DagProgressDots tasks={dagStatus} />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Box as="span" title="Processing in progress">
|
<Box as="span" title="Processing in progress">
|
||||||
<Icon color="gray.500" as={FaGear} />
|
<Icon color="gray.500" as={FaGear} />
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog";
|
|||||||
import { formatLocalDate } from "../../lib/time";
|
import { formatLocalDate } from "../../lib/time";
|
||||||
import { RECORD_A_MEETING_URL } from "../../api/urls";
|
import { RECORD_A_MEETING_URL } from "../../api/urls";
|
||||||
import { useUserName } from "../../lib/useUserName";
|
import { useUserName } from "../../lib/useUserName";
|
||||||
|
import { useDagStatusMap } from "../../lib/UserEventsProvider";
|
||||||
|
|
||||||
const SEARCH_FORM_QUERY_INPUT_NAME = "query" as const;
|
const SEARCH_FORM_QUERY_INPUT_NAME = "query" as const;
|
||||||
|
|
||||||
@@ -273,6 +274,7 @@ export default function TranscriptBrowser() {
|
|||||||
}, [JSON.stringify(searchFilters)]);
|
}, [JSON.stringify(searchFilters)]);
|
||||||
|
|
||||||
const userName = useUserName();
|
const userName = useUserName();
|
||||||
|
const dagStatusMap = useDagStatusMap();
|
||||||
const [deletionLoading, setDeletionLoading] = useState(false);
|
const [deletionLoading, setDeletionLoading] = useState(false);
|
||||||
const cancelRef = React.useRef(null);
|
const cancelRef = React.useRef(null);
|
||||||
const [transcriptToDeleteId, setTranscriptToDeleteId] =
|
const [transcriptToDeleteId, setTranscriptToDeleteId] =
|
||||||
@@ -408,6 +410,7 @@ export default function TranscriptBrowser() {
|
|||||||
isLoading={searchLoading}
|
isLoading={searchLoading}
|
||||||
onDelete={setTranscriptToDeleteId}
|
onDelete={setTranscriptToDeleteId}
|
||||||
onReprocess={handleProcessTranscript}
|
onReprocess={handleProcessTranscript}
|
||||||
|
dagStatusMap={dagStatusMap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!searchLoading && results.length === 0 && (
|
{!searchLoading && results.length === 0 && (
|
||||||
|
|||||||
@@ -1,12 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { WEBSOCKET_URL } from "./apiClient";
|
import { WEBSOCKET_URL } from "./apiClient";
|
||||||
import { useAuth } from "./AuthProvider";
|
import { useAuth } from "./AuthProvider";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { invalidateTranscriptLists, TRANSCRIPT_SEARCH_URL } from "./apiHooks";
|
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({
|
const UserEvent = z.object({
|
||||||
event: z.string(),
|
event: z.string(),
|
||||||
});
|
});
|
||||||
@@ -95,6 +121,9 @@ export function UserEventsProvider({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const tokenRef = useRef<string | null>(null);
|
const tokenRef = useRef<string | null>(null);
|
||||||
const detachRef = useRef<(() => void) | null>(null);
|
const detachRef = useRef<(() => void) | null>(null);
|
||||||
|
const [dagStatusMap, setDagStatusMap] = useState<Map<string, DagTask[]>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only tear down when the user is truly unauthenticated
|
// Only tear down when the user is truly unauthenticated
|
||||||
@@ -133,20 +162,46 @@ export function UserEventsProvider({
|
|||||||
if (!detachRef.current) {
|
if (!detachRef.current) {
|
||||||
const onMessage = (event: MessageEvent) => {
|
const onMessage = (event: MessageEvent) => {
|
||||||
try {
|
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 eventName = msg.event;
|
||||||
|
|
||||||
const invalidateList = () => invalidateTranscriptLists(queryClient);
|
const invalidateList = () => invalidateTranscriptLists(queryClient);
|
||||||
|
|
||||||
switch (eventName) {
|
switch (eventName) {
|
||||||
case "TRANSCRIPT_CREATED":
|
case "TRANSCRIPT_CREATED":
|
||||||
case "TRANSCRIPT_DELETED":
|
case "TRANSCRIPT_DELETED":
|
||||||
case "TRANSCRIPT_STATUS":
|
|
||||||
case "TRANSCRIPT_FINAL_TITLE":
|
case "TRANSCRIPT_FINAL_TITLE":
|
||||||
case "TRANSCRIPT_DURATION":
|
case "TRANSCRIPT_DURATION":
|
||||||
invalidateList().then(() => {});
|
invalidateList().then(() => {});
|
||||||
break;
|
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:
|
default:
|
||||||
// Ignore other content events for list updates
|
// Ignore other content events for list updates
|
||||||
break;
|
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