148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
"use client";
|
|
|
|
import { useSSE } from "@/lib/use-sse";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import type { ActivityEvent } from "@/lib/types";
|
|
|
|
const green = { bg: "#22c55e", shadow: "0 0 8px rgba(34, 197, 94, 0.4)" };
|
|
const blue = { bg: "#3b82f6", shadow: "0 0 8px rgba(59, 130, 246, 0.4)" };
|
|
const amber = { bg: "#f59e0b", shadow: "0 0 8px rgba(245, 158, 11, 0.4)" };
|
|
const red = { bg: "#ef4444", shadow: "0 0 8px rgba(239, 68, 68, 0.4)" };
|
|
const purple = { bg: "#a855f7", shadow: "0 0 8px rgba(168, 85, 247, 0.4)" };
|
|
|
|
const typeColors: Record<string, { bg: string; shadow: string }> = {
|
|
stack_healthy: green,
|
|
backup_result: green,
|
|
drift_clean: green,
|
|
cron_complete: green,
|
|
disk_scan_complete: green,
|
|
email_classified: blue,
|
|
email_classifying: blue,
|
|
email_cached: blue,
|
|
start: blue,
|
|
receipt_extracted: amber,
|
|
container_restarted: amber,
|
|
restart_analysis: amber,
|
|
container_unhealthy: red,
|
|
drift_found: red,
|
|
disk_warning: red,
|
|
error: red,
|
|
changelog_generated: purple,
|
|
changelog_commits: purple,
|
|
pr_reviewed: purple,
|
|
};
|
|
const defaultDot = { bg: "#6b7280", shadow: "none" };
|
|
|
|
function formatTime(ts: string) {
|
|
try {
|
|
return new Date(ts).toLocaleTimeString("en-US", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
} catch {
|
|
return ts;
|
|
}
|
|
}
|
|
|
|
function eventMessage(event: ActivityEvent): string {
|
|
switch (event.type) {
|
|
case "email_classified":
|
|
return `Classified as ${event.category ?? "?"} ${event.label ? `- ${event.label}` : ""}`.trim();
|
|
case "email_classifying":
|
|
return `[${event.progress ?? "?"}] Classifying: ${event.subject ?? "?"}`;
|
|
case "email_cached":
|
|
return `Cached: ${event.subject ?? "?"} -> ${event.category ?? "?"}`;
|
|
case "receipt_extracted":
|
|
return `Receipt: ${event.vendor ?? "?"} $${event.amount ?? "?"}`;
|
|
case "container_restarted":
|
|
return `Restarted ${event.container} on ${event.endpoint}`;
|
|
case "container_unhealthy":
|
|
return event.container
|
|
? `Unhealthy: ${event.container} on ${event.endpoint}`
|
|
: event.raw ?? "Unhealthy container detected";
|
|
case "backup_result":
|
|
return `Backup: ${event.status ?? "?"}`;
|
|
case "drift_clean":
|
|
return "Config drift check: all clean";
|
|
case "drift_found":
|
|
return `Config drift: ${event.drifts ?? "?"} drifts in ${event.services ?? "?"} services`;
|
|
case "cron_complete":
|
|
return "Automation run completed";
|
|
case "stack_healthy":
|
|
return "All containers healthy";
|
|
case "disk_warning":
|
|
return `Disk warning: ${event.days} days remaining`;
|
|
case "disk_scan_complete":
|
|
return `Disk scan: ${event.count} filesystems checked`;
|
|
case "pr_reviewed":
|
|
return `AI reviewed PR #${event.pr}`;
|
|
case "changelog_generated":
|
|
return `Changelog: ${event.commits} commits`;
|
|
case "changelog_commits":
|
|
return `${event.count} new commits found`;
|
|
case "restart_analysis":
|
|
return `LLM says ${event.decision} for ${event.container}`;
|
|
default:
|
|
// Strip timestamp prefix from raw log line for cleaner display
|
|
if (typeof event.raw === "string") {
|
|
return event.raw.replace(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},?\d*\s+\S+\s+/, "");
|
|
}
|
|
return `${event.type} from ${event.source}`;
|
|
}
|
|
}
|
|
|
|
export function ActivityFeed() {
|
|
const events = useSSE("/api/activity");
|
|
|
|
return (
|
|
<Card className="overflow-hidden relative">
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="text-base font-semibold">Activity Feed</CardTitle>
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] border-green-500/30 text-green-400 animate-live-pulse bg-green-500/5"
|
|
>
|
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5 glow-green" />
|
|
LIVE
|
|
</Badge>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ScrollArea className="h-[220px]">
|
|
{events.length === 0 && (
|
|
<p className="text-xs text-muted-foreground py-4 text-center">
|
|
Waiting for events...
|
|
</p>
|
|
)}
|
|
<div className="space-y-2">
|
|
{events.map((event, i) => (
|
|
<div
|
|
key={`${event.timestamp}-${i}`}
|
|
className="flex items-start gap-3 text-xs animate-slide-in rounded-lg px-2 py-1.5 transition-colors hover:bg-white/[0.03]"
|
|
style={{ animationDelay: `${i * 30}ms` }}
|
|
>
|
|
<span
|
|
className="w-2.5 h-2.5 rounded-full mt-0.5 shrink-0"
|
|
style={{
|
|
background: (typeColors[event.type] ?? defaultDot).bg,
|
|
boxShadow: (typeColors[event.type] ?? defaultDot).shadow,
|
|
}}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-foreground truncate">
|
|
{eventMessage(event)}
|
|
</p>
|
|
<p className="text-muted-foreground/70">
|
|
{formatTime(event.timestamp)} · {event.source}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|