feat(web): add queue notification feed
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

This commit is contained in:
2026-03-08 18:18:07 -05:00
parent 6e9def3c5a
commit 72d295edd6
6 changed files with 700 additions and 3 deletions

View File

@@ -0,0 +1,332 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { BellOff, ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
import { useToast } from "@mosaic/ui";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet, apiPost } from "@/lib/api/client";
export interface QueueNotificationFeedProps {
className?: string;
}
interface QueueNotification {
id: string;
agent: string;
filename: string;
payload: unknown;
createdAt: string;
}
interface QueuePayloadRecord {
taskId?: unknown;
type?: unknown;
eventType?: unknown;
event?: unknown;
}
interface NotificationGroup {
agent: string;
notifications: QueueNotification[];
}
const NOTIFICATIONS_ENDPOINT = "/api/orchestrator/api/queue/notifications";
const NOTIFICATIONS_STREAM_ENDPOINT = "/api/orchestrator/queue/notifications/stream";
function joinClasses(...classes: (string | undefined)[]): string {
return classes.filter((value) => typeof value === "string" && value.length > 0).join(" ");
}
function asPayloadRecord(payload: unknown): QueuePayloadRecord | null {
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
return payload as QueuePayloadRecord;
}
function getNotificationTaskId(notification: QueueNotification): string {
const payload = asPayloadRecord(notification.payload);
return typeof payload?.taskId === "string" && payload.taskId.trim().length > 0
? payload.taskId
: notification.id;
}
function getNotificationEventType(notification: QueueNotification): string {
const payload = asPayloadRecord(notification.payload);
const candidates = [payload?.eventType, payload?.type, payload?.event];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim().length > 0) {
return candidate;
}
}
return "notification";
}
function formatNotificationAge(createdAt: string): string {
const parsedDate = new Date(createdAt);
if (Number.isNaN(parsedDate.getTime())) {
return "just now";
}
return formatDistanceToNow(parsedDate, { addSuffix: true });
}
function groupNotificationsByAgent(notifications: QueueNotification[]): NotificationGroup[] {
const grouped = new Map<string, QueueNotification[]>();
for (const notification of notifications) {
const current = grouped.get(notification.agent) ?? [];
current.push(notification);
grouped.set(notification.agent, current);
}
return Array.from(grouped.entries())
.sort(([leftAgent], [rightAgent]) => leftAgent.localeCompare(rightAgent))
.map(([agent, items]) => ({
agent,
notifications: [...items].sort(
(left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()
),
}));
}
export function QueueNotificationFeed({
className,
}: QueueNotificationFeedProps): React.JSX.Element {
const { showToast } = useToast();
const [notifications, setNotifications] = useState<QueueNotification[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [acknowledgingIds, setAcknowledgingIds] = useState<Record<string, boolean>>({});
const [isCollapsed, setIsCollapsed] = useState(false);
const [now, setNow] = useState(Date.now());
const refreshNotifications = useCallback(async (): Promise<void> => {
try {
const payload = await apiGet<QueueNotification[]>(NOTIFICATIONS_ENDPOINT);
setNotifications(payload);
setErrorMessage(null);
} catch (error) {
const message =
error instanceof Error && error.message.trim().length > 0
? error.message
: "Failed to load queue notifications.";
setErrorMessage(message);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void refreshNotifications();
}, [refreshNotifications]);
useEffect(() => {
if (typeof EventSource === "undefined") {
return undefined;
}
const source = new EventSource(NOTIFICATIONS_STREAM_ENDPOINT);
source.onmessage = (event: MessageEvent<string>): void => {
try {
const payload = JSON.parse(event.data) as QueueNotification[];
setNotifications(payload);
setErrorMessage(null);
setIsLoading(false);
} catch {
setErrorMessage("Received an invalid notification stream payload.");
}
};
source.onerror = (): void => {
setErrorMessage((current) => current ?? "Queue notification stream disconnected.");
};
return (): void => {
source.close();
};
}, []);
useEffect(() => {
const intervalId = window.setInterval(() => {
setNow(Date.now());
}, 60_000);
return (): void => {
window.clearInterval(intervalId);
};
}, []);
const groupedNotifications = useMemo(
() => groupNotificationsByAgent(notifications),
[notifications]
);
const pendingCount = notifications.length;
const handleAck = useCallback(
async (notificationId: string): Promise<void> => {
setAcknowledgingIds((current) => ({
...current,
[notificationId]: true,
}));
try {
await apiPost<{ success: true; id: string }>(
`/api/orchestrator/api/queue/notifications/${encodeURIComponent(notificationId)}/ack`
);
setNotifications((current) => current.filter((item) => item.id !== notificationId));
} catch (error) {
const message =
error instanceof Error && error.message.trim().length > 0
? error.message
: "Failed to acknowledge notification.";
showToast(message, "error");
} finally {
setAcknowledgingIds((current) => {
const { [notificationId]: _omitted, ...remaining } = current;
return remaining;
});
}
},
[showToast]
);
return (
<Card className={joinClasses("flex h-full min-h-0 flex-col", className)}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<CardTitle className="text-base">Queue Notifications</CardTitle>
<Badge variant={pendingCount > 0 ? "status-info" : "secondary"}>{pendingCount}</Badge>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setIsCollapsed((current) => !current);
}}
aria-label={isCollapsed ? "Expand queue notifications" : "Collapse queue notifications"}
title={isCollapsed ? "Expand queue notifications" : "Collapse queue notifications"}
>
{isCollapsed ? (
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
) : (
<ChevronRight className="h-4 w-4" aria-hidden="true" />
)}
</Button>
</div>
</CardHeader>
<Collapsible open={!isCollapsed} className="min-h-0 flex-1">
{!isCollapsed ? (
<CardContent className="min-h-0 flex-1 px-3 pb-3">
{isLoading ? (
<ScrollArea className="h-full">
<div className="space-y-2 pr-1">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={`queue-notification-skeleton-${String(index)}`}
className="h-14 w-full"
/>
))}
</div>
</ScrollArea>
) : errorMessage && notifications.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-red-500">
{errorMessage}
</div>
) : groupedNotifications.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
<BellOff className="h-5 w-5" aria-hidden="true" />
<span>No pending notifications</span>
</div>
) : (
<ScrollArea className="h-full">
<div className="space-y-4 pr-1">
{groupedNotifications.map((group) => (
<section key={group.agent} className="space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">{group.agent}</h3>
<span className="text-xs text-muted-foreground">
{group.notifications.length}
</span>
</div>
<div className="space-y-2">
{group.notifications.map((notification) => {
const taskId = getNotificationTaskId(notification);
const eventType = getNotificationEventType(notification);
const isAcknowledging = acknowledgingIds[notification.id] ?? false;
return (
<article
key={notification.id}
className="rounded-lg border border-border/70 bg-card px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-foreground">
{taskId}
</span>
<Badge variant="secondary">{eventType}</Badge>
</div>
<time
className="block text-xs text-muted-foreground"
dateTime={notification.createdAt}
>
{formatNotificationAge(notification.createdAt)}
</time>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={isAcknowledging}
onClick={() => {
void handleAck(notification.id);
}}
aria-label={`ACK notification ${taskId}`}
>
{isAcknowledging ? (
<span className="flex items-center gap-2">
<Loader2
className="h-4 w-4 animate-spin"
aria-hidden="true"
/>
ACK
</span>
) : (
"ACK"
)}
</Button>
</div>
</article>
);
})}
</div>
</section>
))}
</div>
</ScrollArea>
)}
</CardContent>
) : null}
</Collapsible>
<span className="sr-only" aria-live="polite">
{pendingCount} pending notifications, refreshed at {new Date(now).toISOString()}
</span>
</Card>
);
}