Submatter/apps/web/components/fleet/fleet-activity-feed.tsx

102 lines
3.4 KiB
TypeScript
Raw Normal View History

"use client";
import * as React from "react";
type FleetEvent = {
id: string;
workspaceId: string;
convoyId: string;
briefId: string | null;
role: string;
actor: string | null;
eventType: string;
harnessAlias: string | null;
occurredAt: string;
metadata: Record<string, unknown> | null;
classification?: string | null;
durationS?: number | null;
outcome?: string | null;
};
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export function FleetActivityFeed() {
const [events, setEvents] = React.useState<FleetEvent[]>([]);
const [connected, setConnected] = React.useState(false);
const seenRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
const es = new EventSource("/api/fleet/events");
es.onopen = () => setConnected(true);
es.onerror = () => setConnected(false);
es.onmessage = (e) => {
try {
const parsed = JSON.parse(e.data) as FleetEvent | FleetEvent[];
const incoming = Array.isArray(parsed) ? parsed : [parsed];
// Filter duplicates by id
const fresh = incoming.filter((ev) => {
if (!ev?.id || seenRef.current.has(ev.id)) return false;
seenRef.current.add(ev.id);
return true;
});
if (fresh.length === 0) return;
setEvents((prev) => {
const merged = [...fresh, ...prev];
// keep sort by occurredAt desc, dedup already handled, limit 50
merged.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
return merged.slice(0, 100);
});
} catch {
// ignore malformed
}
};
return () => {
es.close();
};
}, []);
return (
<div className="rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">Activity feed</h3>
<span className="text-[10px] text-muted-foreground">{connected ? "live" : "reconnecting…"}</span>
</div>
<div className="max-h-[360px] overflow-y-auto">
{events.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">No recent convoy events.</p>
) : (
<ul className="divide-y divide-border">
{events.map((ev) => (
<li key={ev.id} className="px-4 py-2.5 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{ev.eventType}</span>
<span className="text-xs text-muted-foreground">{formatRelative(ev.occurredAt)}</span>
</div>
<p className="text-xs text-muted-foreground">
{ev.role}
{ev.actor && ev.actor !== ev.role ? ` · ${ev.actor}` : ""}
{ev.harnessAlias ? ` · ${ev.harnessAlias}` : ""}
{ev.outcome ? ` · ${ev.outcome}` : ""}
</p>
<p className="text-[10px] text-muted-foreground">convoy {ev.convoyId.slice(0, 8)}</p>
</li>
))}
</ul>
)}
</div>
</div>
);
}