Sanitized mirror from private repository - 2026-04-18 11:13:17 UTC
This commit is contained in:
402
dashboard/ui/app/automations/page.tsx
Normal file
402
dashboard/ui/app/automations/page.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
"use client";
|
||||
|
||||
import { usePoll } from "@/lib/use-poll";
|
||||
import type { EmailStats, AutomationTimelineEntry } from "@/lib/types";
|
||||
import { StatCard } from "@/components/stat-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { CardSkeleton } from "@/components/skeleton";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
|
||||
interface BackupResult {
|
||||
host: string;
|
||||
status: string;
|
||||
last_run: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
interface DriftResult {
|
||||
stack: string;
|
||||
drifted: boolean;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
interface StackRestart {
|
||||
stack: string;
|
||||
status: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
receipts: "bg-amber-500/20 border-amber-500/30 text-amber-400",
|
||||
newsletters: "bg-blue-500/20 border-blue-500/30 text-blue-400",
|
||||
accounts: "bg-violet-500/20 border-violet-500/30 text-violet-400",
|
||||
spam: "bg-red-500/20 border-red-500/30 text-red-400",
|
||||
personal: "bg-green-500/20 border-green-500/30 text-green-400",
|
||||
finance: "bg-emerald-500/20 border-emerald-500/30 text-emerald-400",
|
||||
work: "bg-cyan-500/20 border-cyan-500/30 text-cyan-400",
|
||||
promotions: "bg-amber-500/15 border-amber-500/25 text-amber-300",
|
||||
social: "bg-purple-500/20 border-purple-500/30 text-purple-400",
|
||||
travel: "bg-cyan-500/15 border-cyan-500/25 text-cyan-300",
|
||||
orders: "bg-orange-500/20 border-orange-500/30 text-orange-400",
|
||||
updates: "bg-teal-500/20 border-teal-500/30 text-teal-400",
|
||||
};
|
||||
|
||||
function getAccountColor(name: string): string {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes("gmail") || lower.includes("lzbellina")) return "text-blue-400";
|
||||
if (lower.includes("dvish")) return "text-amber-400";
|
||||
if (lower.includes("proton") || lower.includes("admin")) return "text-violet-400";
|
||||
return "text-foreground";
|
||||
}
|
||||
|
||||
function getAccountGradient(name: string): string {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes("gmail") || lower.includes("lzbellina")) return "bg-gradient-to-b from-blue-300 to-blue-500 bg-clip-text text-transparent";
|
||||
if (lower.includes("dvish")) return "bg-gradient-to-b from-amber-300 to-amber-500 bg-clip-text text-transparent";
|
||||
if (lower.includes("proton") || lower.includes("admin")) return "bg-gradient-to-b from-violet-300 to-violet-500 bg-clip-text text-transparent";
|
||||
return "text-foreground";
|
||||
}
|
||||
|
||||
function getCategoryClass(cat: string): string {
|
||||
const lower = cat.toLowerCase();
|
||||
for (const [key, cls] of Object.entries(categoryColors)) {
|
||||
if (lower.includes(key)) return cls;
|
||||
}
|
||||
return "bg-white/[0.06] border-white/[0.08] text-muted-foreground";
|
||||
}
|
||||
|
||||
// Order matters — more specific keys must come BEFORE generic ones
|
||||
// ("digest" before "email" so "Email Digest" matches digest, not email)
|
||||
const expectedIntervals: [string, number][] = [
|
||||
["digest", 36 * 60 * 60 * 1000], // 36 hours (daily)
|
||||
["changelog", 8 * 24 * 60 * 60 * 1000], // 8 days (weekly)
|
||||
["predictor", 8 * 24 * 60 * 60 * 1000], // 8 days (weekly)
|
||||
["validator", 36 * 60 * 60 * 1000], // 36 hours (daily)
|
||||
["receipt", 36 * 60 * 60 * 1000], // 36 hours (daily)
|
||||
["drift", 36 * 60 * 60 * 1000], // 36 hours (daily)
|
||||
["backup", 36 * 60 * 60 * 1000], // 36 hours (daily)
|
||||
["restart", 30 * 60 * 1000], // 30 min
|
||||
["stack", 30 * 60 * 1000], // 30 min
|
||||
["email", 2 * 60 * 60 * 1000], // 2 hours
|
||||
["organizer", 2 * 60 * 60 * 1000], // 2 hours
|
||||
];
|
||||
|
||||
function getExpectedInterval(name: string): number {
|
||||
const lower = name.toLowerCase();
|
||||
for (const [key, interval] of expectedIntervals) {
|
||||
if (lower.includes(key)) return interval;
|
||||
}
|
||||
return 60 * 60 * 1000; // default 1 hour
|
||||
}
|
||||
|
||||
function isOnSchedule(entry: AutomationTimelineEntry): boolean {
|
||||
if (!entry.last_run || !entry.exists) return false;
|
||||
const lastRun = new Date(entry.last_run).getTime();
|
||||
const now = Date.now();
|
||||
const expected = getExpectedInterval(entry.name);
|
||||
// Allow 2x the expected interval as grace
|
||||
return (now - lastRun) < expected * 2;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const now = Date.now();
|
||||
const diff = now - d.getTime();
|
||||
if (diff < 60000) return "just now";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return `${Math.floor(diff / 86400000)}d ago`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeOnly(dateStr: string): string {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AutomationsPage() {
|
||||
const { data: emails } = usePoll<EmailStats>("/api/automations/email", 60000);
|
||||
const { data: backups } = usePoll<Record<string, unknown>>("/api/automations/backup", 120000);
|
||||
const { data: drift } = usePoll<Record<string, unknown>>("/api/automations/drift", 120000);
|
||||
const { data: restartsData } = usePoll<{ entries: StackRestart[] }>("/api/automations/restarts", 60000);
|
||||
const { data: timeline } = usePoll<AutomationTimelineEntry[]>("/api/automation-timeline", 60000);
|
||||
const restarts = restartsData?.entries ?? [];
|
||||
|
||||
// Compute stats for top row
|
||||
const totalEmailsToday = emails?.accounts
|
||||
? emails.accounts.reduce((sum, acct) => {
|
||||
const today = Number((acct as Record<string, unknown>).today ?? (acct as Record<string, unknown>).today_total ?? 0);
|
||||
return sum + today;
|
||||
}, 0)
|
||||
: 0;
|
||||
|
||||
const backupOk = String(backups?.status ?? "unknown") === "ok";
|
||||
const driftStatus = String(drift?.status ?? "unknown");
|
||||
const driftClean = driftStatus === "clean" || driftStatus === "no_log";
|
||||
const restartCount = restarts.length;
|
||||
|
||||
// Sort timeline by most recent first
|
||||
const sortedTimeline = timeline
|
||||
? [...timeline].sort((a, b) => {
|
||||
if (!a.last_run) return 1;
|
||||
if (!b.last_run) return -1;
|
||||
return new Date(b.last_run).getTime() - new Date(a.last_run).getTime();
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-lg font-semibold">Automations</h1>
|
||||
|
||||
{/* Top: Big stats row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Emails Today"
|
||||
value={emails ? totalEmailsToday : "--"}
|
||||
color="blue"
|
||||
sub="classified"
|
||||
/>
|
||||
<StatCard
|
||||
label="Backup Status"
|
||||
value={backups ? (backupOk ? "OK" : "FAIL") : "--"}
|
||||
color={backupOk || !backups ? "green" : "amber"}
|
||||
sub={backups?.has_errors ? "errors detected" : "all good"}
|
||||
/>
|
||||
<StatCard
|
||||
label="Config Drift"
|
||||
value={drift ? (driftClean ? "0" : driftStatus) : "--"}
|
||||
color={driftClean || !drift ? "emerald" : "amber"}
|
||||
sub={drift ? String(drift.last_result ?? "no scan yet") : "loading"}
|
||||
/>
|
||||
<StatCard
|
||||
label="Restarts"
|
||||
value={restartsData ? restartCount : "--"}
|
||||
color={restartCount === 0 || !restartsData ? "violet" : "amber"}
|
||||
sub="container restarts"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color Legend */}
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs text-muted-foreground/70 px-1">
|
||||
<span className="font-medium text-foreground/60">Legend:</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-green-500" style={{boxShadow:"0 0 6px rgba(34,197,94,0.4)"}} /> On schedule</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-amber-500" style={{boxShadow:"0 0 6px rgba(245,158,11,0.4)"}} /> Overdue</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-gray-500" /> No log</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-blue-500" style={{boxShadow:"0 0 6px rgba(59,130,246,0.4)"}} /> Email</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-violet-500" style={{boxShadow:"0 0 6px rgba(139,92,246,0.4)"}} /> Accounts</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-amber-400" style={{boxShadow:"0 0 6px rgba(251,191,36,0.4)"}} /> Receipts</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-red-500" style={{boxShadow:"0 0 6px rgba(239,68,68,0.4)"}} /> Spam</span>
|
||||
</div>
|
||||
|
||||
{/* Automation Timeline */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold">Automation Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!timeline ? (
|
||||
<CardSkeleton lines={6} />
|
||||
) : sortedTimeline.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground/60">No automation data</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sortedTimeline.map((entry, i) => {
|
||||
const onSchedule = isOnSchedule(entry);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between text-sm rounded-lg px-3 py-2 hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
|
||||
!entry.exists
|
||||
? "bg-gray-500"
|
||||
: onSchedule
|
||||
? "bg-green-500"
|
||||
: "bg-amber-500"
|
||||
}`}
|
||||
style={{
|
||||
boxShadow: !entry.exists
|
||||
? "none"
|
||||
: onSchedule
|
||||
? "0 0 8px rgba(34, 197, 94, 0.5)"
|
||||
: "0 0 8px rgba(245, 158, 11, 0.5)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-foreground font-medium truncate">{entry.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 shrink-0 ml-3">
|
||||
{entry.last_run ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{formatTimeOnly(entry.last_run)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40 min-w-[60px] text-right">
|
||||
{formatRelativeTime(entry.last_run)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/40">never</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Middle: Email Organizers */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold">Email Organizers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!emails ? (
|
||||
<CardSkeleton lines={4} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{emails.accounts.map((acct: Record<string, unknown>) => {
|
||||
const name = String(acct.account ?? acct.name ?? "?");
|
||||
const today = Number(acct.today ?? acct.today_total ?? 0);
|
||||
const cats = (acct.categories ?? acct.today_categories ?? {}) as Record<string, number>;
|
||||
return (
|
||||
<div key={name} className="flex items-center gap-6 rounded-xl px-4 py-3 bg-white/[0.02] border border-white/[0.04] hover:bg-white/[0.04] transition-colors">
|
||||
<div className="shrink-0 min-w-[140px]">
|
||||
<p className={`text-base font-medium ${getAccountColor(name)}`}>{name}</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-0.5">today</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<p className={`text-3xl font-bold ${getAccountGradient(name)}`}>{today}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 flex-1">
|
||||
{Object.entries(cats).map(([cat, count]) => (
|
||||
<Badge
|
||||
key={cat}
|
||||
variant="secondary"
|
||||
className={`text-xs px-2.5 py-0.5 border ${getCategoryClass(cat)}`}
|
||||
>
|
||||
{cat}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom: System Health -- 2 columns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Backup Details */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">Backup Details</CardTitle>
|
||||
{backups && (
|
||||
<StatusBadge
|
||||
color={backupOk ? "green" : "red"}
|
||||
label={String(backups.status ?? "unknown")}
|
||||
/>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!backups ? (
|
||||
<CardSkeleton lines={3} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{backups.has_errors ? (
|
||||
<p className="text-sm text-red-400 font-medium">Errors detected in backup</p>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground/70">Log entries today</span>
|
||||
<span className="text-foreground font-medium">{String(backups.entries ?? 0)}</span>
|
||||
</div>
|
||||
{backups.last_run ? (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground/70">Last run</span>
|
||||
<span className="text-foreground">{String(backups.last_run)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{backups.email_count != null ? (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground/70">Emails backed up</span>
|
||||
<span className="text-foreground font-medium">{String(backups.email_count)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Config Drift + Restarts */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">Config Drift & Restarts</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Drift */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground/70 font-medium">Config Drift</p>
|
||||
{drift && (
|
||||
<StatusBadge
|
||||
color={driftClean ? "green" : "amber"}
|
||||
label={driftStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{drift && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{String(drift.last_result ?? "No scan results yet")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restarts */}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground/70 font-medium mb-2">Recent Restarts</p>
|
||||
{restarts.length === 0 ? (
|
||||
<EmptyState icon={"OK"} title="All healthy" description="No containers needed restarting" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{restarts.map((r, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground font-medium">{r.stack}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge
|
||||
color={r.status === "success" ? "green" : "red"}
|
||||
label={r.status}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{new Date(r.timestamp).toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user