Sanitized mirror from private repository - 2026-04-20 01:32:01 UTC
This commit is contained in:
301
dashboard/ui/app/page.tsx
Normal file
301
dashboard/ui/app/page.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { usePoll } from "@/lib/use-poll";
|
||||
import { postAPI } from "@/lib/api";
|
||||
import type { OverviewStats, HealthScore, DiskUsageEntry } from "@/lib/types";
|
||||
import { StatCard } from "@/components/stat-card";
|
||||
import { ActivityFeed } from "@/components/activity-feed";
|
||||
import { JellyfinCard } from "@/components/jellyfin-card";
|
||||
import { OllamaCard } from "@/components/ollama-card";
|
||||
import { CalendarCard } from "@/components/calendar-card";
|
||||
import { HostRow } from "@/components/host-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CardSkeleton, TableSkeleton } from "@/components/skeleton";
|
||||
|
||||
/* --- Quick Action Button --- */
|
||||
interface ActionButtonProps {
|
||||
label: string;
|
||||
endpoint: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
function ActionButton({ label, endpoint, icon }: ActionButtonProps) {
|
||||
const [state, setState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
|
||||
const run = useCallback(async () => {
|
||||
setState("loading");
|
||||
try {
|
||||
await postAPI(endpoint);
|
||||
setState("success");
|
||||
} catch {
|
||||
setState("error");
|
||||
}
|
||||
setTimeout(() => setState("idle"), 2000);
|
||||
}, [endpoint]);
|
||||
|
||||
const bg =
|
||||
state === "loading"
|
||||
? "bg-white/[0.08] cursor-wait"
|
||||
: state === "success"
|
||||
? "bg-green-500/15 border-green-500/30"
|
||||
: state === "error"
|
||||
? "bg-red-500/15 border-red-500/30"
|
||||
: "bg-white/[0.04] hover:bg-white/[0.08] border-white/[0.08]";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={state === "loading"}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-all ${bg}`}
|
||||
>
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-white/[0.08] text-muted-foreground">{icon}</span>
|
||||
<span>
|
||||
{state === "loading"
|
||||
? "Running..."
|
||||
: state === "success"
|
||||
? "Done!"
|
||||
: state === "error"
|
||||
? "Failed"
|
||||
: label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Organizer Toggle --- */
|
||||
function OrganizerToggle() {
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [state, setState] = useState<"idle" | "loading">("idle");
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
setState("loading");
|
||||
try {
|
||||
if (paused) {
|
||||
await postAPI("/api/actions/resume-organizers");
|
||||
setPaused(false);
|
||||
} else {
|
||||
await postAPI("/api/actions/pause-organizers");
|
||||
setPaused(true);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setState("idle");
|
||||
}, [paused]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={state === "loading"}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-all ${
|
||||
paused
|
||||
? "bg-amber-500/15 border-amber-500/30 hover:bg-amber-500/25"
|
||||
: "bg-white/[0.04] hover:bg-white/[0.08] border-white/[0.08]"
|
||||
}`}
|
||||
>
|
||||
<span>{paused ? ">" : "||"}</span>
|
||||
<span>
|
||||
{state === "loading" ? "..." : paused ? "Resume Organizers" : "Pause Organizers"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Disk Usage Bar --- */
|
||||
function DiskBar({ entry }: { entry: DiskUsageEntry }) {
|
||||
const color =
|
||||
entry.used_pct >= 85
|
||||
? "from-red-500 to-red-400"
|
||||
: entry.used_pct >= 70
|
||||
? "from-amber-500 to-amber-400"
|
||||
: "from-green-500 to-emerald-400";
|
||||
|
||||
const hostColors: Record<string, string> = {
|
||||
atlantis: "text-blue-400",
|
||||
calypso: "text-violet-400",
|
||||
olares: "text-emerald-400",
|
||||
nuc: "text-amber-400",
|
||||
rpi5: "text-cyan-400",
|
||||
homelab: "text-green-400",
|
||||
guava: "text-orange-400",
|
||||
seattle: "text-teal-400",
|
||||
};
|
||||
const hostCls =
|
||||
hostColors[entry.host.toLowerCase()] ?? "text-foreground";
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`font-medium ${hostCls}`}>{entry.host}</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-xs truncate">
|
||||
{entry.mount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{entry.total_gb >= 1000
|
||||
? `${(entry.total_gb / 1000).toFixed(1)} TB`
|
||||
: `${Math.round(entry.total_gb)} GB`}
|
||||
</span>
|
||||
<span className="text-xs font-medium tabular-nums-transition">
|
||||
{entry.used_pct}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-bar-track h-2">
|
||||
<div
|
||||
className={`h-full glass-bar-fill bg-gradient-to-r ${color} transition-all duration-700`}
|
||||
style={{ width: `${entry.used_pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data } = usePoll<OverviewStats>("/api/stats/overview", 60000);
|
||||
const { data: health } = usePoll<HealthScore>("/api/health-score", 60000);
|
||||
const { data: disks } = usePoll<DiskUsageEntry[]>("/api/disk-usage", 300000);
|
||||
|
||||
// Handle both API field name variants
|
||||
const endpoints = data?.containers?.endpoints || data?.containers?.by_endpoint || {};
|
||||
const rawEmail = data?.emails_today ?? data?.email_today ?? 0;
|
||||
const emailCount = typeof rawEmail === "object" && rawEmail !== null ? (rawEmail as Record<string, number>).total ?? 0 : rawEmail;
|
||||
const alertCount = data?.alerts ?? data?.unhealthy_count ?? 0;
|
||||
const running = data?.containers?.running ?? Object.values(endpoints).reduce((s, e) => s + (e.running || 0), 0);
|
||||
const hostsOnline = data?.hosts_online ?? Object.values(endpoints).filter(e => !e.error).length;
|
||||
const gpuPct = data?.gpu?.utilization_pct;
|
||||
const totalHosts = Object.keys(endpoints).length;
|
||||
|
||||
// Top 5 most-used disks
|
||||
const topDisks = disks
|
||||
? [...disks].sort((a, b) => b.used_pct - a.used_pct).slice(0, 5)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Row 1: Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<StatCard
|
||||
label="Health"
|
||||
value={health ? `${health.score}` : "—"}
|
||||
color={
|
||||
health
|
||||
? health.score >= 80
|
||||
? "green"
|
||||
: health.score >= 60
|
||||
? "amber"
|
||||
: "amber"
|
||||
: "blue"
|
||||
}
|
||||
gauge={health ? health.score : undefined}
|
||||
sub={health ? `Grade: ${health.grade}` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Containers"
|
||||
value={data ? `${running}/${data.containers.total}` : "—"}
|
||||
color="blue"
|
||||
sub={data ? "running / total" : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Hosts Online"
|
||||
value={data ? hostsOnline : "—"}
|
||||
color="green"
|
||||
sub="endpoints"
|
||||
/>
|
||||
<StatCard
|
||||
label="GPU — RTX 5090"
|
||||
value={
|
||||
data?.gpu?.available
|
||||
? `${gpuPct ?? 0}%`
|
||||
: "—"
|
||||
}
|
||||
color="violet"
|
||||
gauge={data?.gpu?.available ? gpuPct : undefined}
|
||||
sub={data?.gpu?.available ? `${data.gpu.temp_c ?? "—"}°C · ${data.gpu.power_w ?? data.gpu.power_draw_w ?? "—"}W` : "unavailable"}
|
||||
/>
|
||||
<StatCard
|
||||
label="Emails Today"
|
||||
value={data ? emailCount : "—"}
|
||||
color="amber"
|
||||
sub="processed"
|
||||
/>
|
||||
<StatCard
|
||||
label="Alerts"
|
||||
value={data ? alertCount : "—"}
|
||||
color="emerald"
|
||||
sub="active"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 1.5: Quick Actions */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground/70 font-medium mr-1">
|
||||
Quick Actions
|
||||
</span>
|
||||
<ActionButton label="Restart Jellyfin" endpoint="/api/actions/restart-jellyfin" icon="JF" />
|
||||
<ActionButton label="Restart Ollama" endpoint="/api/actions/restart-ollama" icon="AI" />
|
||||
<OrganizerToggle />
|
||||
<ActionButton label="Run Backup" endpoint="/api/actions/run-backup" icon="BK" />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Calendar + Activity Feed */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<CalendarCard />
|
||||
<ActivityFeed />
|
||||
</div>
|
||||
|
||||
{/* Row 3: Jellyfin + GPU + Hosts */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<JellyfinCard />
|
||||
<OllamaCard />
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
Hosts {data ? `(${hostsOnline}/${totalHosts} online)` : ""}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data ? (
|
||||
<div className="divide-y divide-white/[0.06]">
|
||||
{Object.entries(endpoints).map(([name, info]) => (
|
||||
<HostRow
|
||||
key={name}
|
||||
name={name}
|
||||
running={info.running}
|
||||
total={info.total}
|
||||
error={info.error}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<CardSkeleton lines={5} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Storage / Disk Usage */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">Storage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!disks ? (
|
||||
<CardSkeleton lines={5} />
|
||||
) : topDisks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground/60">No disk data</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{topDisks.map((d, i) => (
|
||||
<DiskBar key={`${d.host}-${d.mount}-${i}`} entry={d} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user