Sanitized mirror from private repository - 2026-04-20 01:32:01 UTC
This commit is contained in:
198
dashboard/ui/components/activity-feed.tsx
Normal file
198
dashboard/ui/components/activity-feed.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const feedCategoryColors: Record<string, string> = {
|
||||
receipts: "text-amber-400",
|
||||
newsletters: "text-blue-400",
|
||||
accounts: "text-violet-400",
|
||||
spam: "text-red-400",
|
||||
personal: "text-green-400",
|
||||
finance: "text-emerald-400",
|
||||
work: "text-cyan-400",
|
||||
promotions: "text-amber-400",
|
||||
social: "text-purple-400",
|
||||
};
|
||||
|
||||
function getCategoryColor(cat: string): string {
|
||||
const lower = cat.toLowerCase();
|
||||
for (const [key, cls] of Object.entries(feedCategoryColors)) {
|
||||
if (lower.includes(key)) return cls;
|
||||
}
|
||||
return "text-foreground";
|
||||
}
|
||||
|
||||
function EventMessage({ event }: { event: ActivityEvent }): React.ReactElement {
|
||||
switch (event.type) {
|
||||
case "email_classified": {
|
||||
const cat = String(event.category ?? "?");
|
||||
return (
|
||||
<span>
|
||||
Classified as <span className={getCategoryColor(cat)}>{cat}</span>
|
||||
{event.label ? ` - ${String(event.label)}` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case "email_classifying":
|
||||
return <span>[{String(event.progress ?? "?")}] Classifying: {String(event.subject ?? "?")}</span>;
|
||||
case "email_cached": {
|
||||
const cat = String(event.category ?? "?");
|
||||
return (
|
||||
<span>
|
||||
Cached: {String(event.subject ?? "?")} → <span className={getCategoryColor(cat)}>{cat}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case "receipt_extracted":
|
||||
return (
|
||||
<span>
|
||||
Receipt: {String(event.vendor ?? "?")} <span className="text-green-400">${String(event.amount ?? "?")}</span>
|
||||
</span>
|
||||
);
|
||||
case "container_restarted":
|
||||
return (
|
||||
<span>
|
||||
Restarted <span className="text-cyan-400">{String(event.container)}</span> on {String(event.endpoint)}
|
||||
</span>
|
||||
);
|
||||
case "container_unhealthy":
|
||||
return event.container ? (
|
||||
<span>
|
||||
Unhealthy: <span className="text-cyan-400">{String(event.container)}</span> on {String(event.endpoint)}
|
||||
</span>
|
||||
) : (
|
||||
<span>{String(event.raw ?? "Unhealthy container detected")}</span>
|
||||
);
|
||||
case "backup_result":
|
||||
return <span>Backup: {String(event.status ?? "?")}</span>;
|
||||
case "drift_clean":
|
||||
return <span>Config drift check: <span className="text-green-400">all clean</span></span>;
|
||||
case "drift_found":
|
||||
return <span>Config drift: {String(event.drifts ?? "?")} drifts in {String(event.services ?? "?")} services</span>;
|
||||
case "cron_complete":
|
||||
return <span>Automation run completed</span>;
|
||||
case "stack_healthy":
|
||||
return <span>All containers <span className="text-green-400">healthy</span></span>;
|
||||
case "disk_warning":
|
||||
return <span>Disk warning: {String(event.days)} days remaining</span>;
|
||||
case "disk_scan_complete":
|
||||
return <span>Disk scan: {String(event.count)} filesystems checked</span>;
|
||||
case "pr_reviewed":
|
||||
return <span>AI reviewed PR <span className="text-violet-400">#{String(event.pr)}</span></span>;
|
||||
case "changelog_generated":
|
||||
return <span>Changelog: {String(event.commits)} commits</span>;
|
||||
case "changelog_commits":
|
||||
return <span>{String(event.count)} new commits found</span>;
|
||||
case "restart_analysis":
|
||||
return (
|
||||
<span>
|
||||
LLM says {String(event.decision)} for <span className="text-cyan-400">{String(event.container)}</span>
|
||||
</span>
|
||||
);
|
||||
default: {
|
||||
if (typeof event.raw === "string") {
|
||||
const cleaned = event.raw.replace(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},?\d*\s+\S+\s+/, "");
|
||||
return <span>{cleaned}</span>;
|
||||
}
|
||||
return <span>{event.type} from {event.source}</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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={event} />
|
||||
</p>
|
||||
<p className="text-muted-foreground/70">
|
||||
{formatTime(event.timestamp)} · {event.source}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user