web/app/dashboard/page.tsxblame
View source
4a006da1"use client";
4a006da2
27902ea3import { useEffect, useState, useCallback } from "react";
4a006da4import { useRouter } from "next/navigation";
4a006da5import { useAuth } from "@/lib/auth";
bf5fc336import { Skeleton } from "@/app/components/skeleton";
27902ea7
27902ea8/* ── Types ── */
27902ea9
27902ea10interface ServiceCheck {
27902ea11 name: string;
27902ea12 status: "operational" | "degraded" | "down";
27902ea13 latency: number | null;
27902ea14 detail?: string;
27902ea15}
27902ea16
27902ea17interface ContainerStats {
27902ea18 name: string;
27902ea19 status: string;
27902ea20 cpu_percent: number;
27902ea21 mem_usage_mb: number;
27902ea22 mem_limit_mb: number;
27902ea23 mem_percent: number;
27902ea24 net_rx_mb: number;
27902ea25 net_tx_mb: number;
27902ea26 uptime: string;
27902ea27}
27902ea28
27902ea29interface SystemMetrics {
27902ea30 cpu_percent: number;
27902ea31 mem_total_mb: number;
27902ea32 mem_used_mb: number;
27902ea33 mem_percent: number;
27902ea34 disk_total_gb: number;
27902ea35 disk_used_gb: number;
27902ea36 disk_percent: number;
27902ea37 load_avg: number[];
27902ea38 uptime: string;
27902ea39}
27902ea40
27902ea41interface StatusResponse {
27902ea42 status: string;
27902ea43 services: ServiceCheck[];
27902ea44 containers: ContainerStats[];
27902ea45 system: SystemMetrics | null;
27902ea46 checked_at: string;
27902ea47}
27902ea48
27902ea49/* ── Page ── */
4a006da50
4a006da51export default function DashboardPage() {
27902ea52 const { user, loading: authLoading } = useAuth();
4a006da53 const router = useRouter();
4a006da54
27902ea55 const [data, setData] = useState<StatusResponse | null>(null);
27902ea56 const [loaded, setLoaded] = useState(false);
79efd4157
1da987458 useEffect(() => {
1da987459 document.title = "Dashboard";
1da987460 }, []);
1da987461
4a006da62 useEffect(() => {
4a006da63 if (!authLoading && !user) {
6dd74de64 router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
4a006da65 }
4a006da66 }, [authLoading, user, router]);
4a006da67
27902ea68 const refresh = useCallback(async () => {
27902ea69 if (!user) return;
27902ea70 try {
27902ea71 const res = await fetch("/api/status", { cache: "no-store" });
27902ea72 const json: StatusResponse = await res.json();
27902ea73 setData(json);
27902ea74 } catch {}
27902ea75 setLoaded(true);
27902ea76 }, [user]);
27902ea77
4a006da78 useEffect(() => {
4a006da79 if (!user) return;
27902ea80 refresh();
27902ea81 const iv = setInterval(refresh, 30000);
27902ea82 return () => clearInterval(iv);
27902ea83 }, [user, refresh]);
0fdef1484
27902ea85 if (authLoading || !user) return null;
0fdef1486
27902ea87 const sys = data?.system;
27902ea88 const containers = data?.containers ?? [];
27902ea89 const services = data?.services ?? [];
0fdef1490
27902ea91 return (
27902ea92 <div style={{ maxWidth: 1100, margin: "0 auto", padding: "1.25rem 1rem 2rem" }}>
27902ea93 {/* ── Header ── */}
27902ea94 <div className="flex items-center justify-between" style={{ marginBottom: "1rem" }}>
27902ea95 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Dashboard</span>
27902ea96 <div className="flex items-center gap-3">
27902ea97 {data?.checked_at && (
27902ea98 <span className="text-xs" style={{ color: "var(--text-faint)" }}>
27902ea99 {new Date(data.checked_at).toLocaleTimeString()}
27902ea100 </span>
27902ea101 )}
27902ea102 <button onClick={refresh} className="btn-reset text-xs" style={{ color: "var(--text-muted)" }}>
27902ea103 Refresh
27902ea104 </button>
27902ea105 </div>
27902ea106 </div>
4a006da107
27902ea108 {/* ── System gauges ── */}
27902ea109 <div
27902ea110 style={{
27902ea111 display: "grid",
27902ea112 gridTemplateColumns: "repeat(4, 1fr)",
27902ea113 gap: "0.625rem",
27902ea114 marginBottom: "0.875rem",
27902ea115 }}
27902ea116 >
27902ea117 <GaugeCard
27902ea118 label="CPU"
27902ea119 value={sys?.cpu_percent ?? null}
27902ea120 detail={sys?.load_avg ? `Load ${sys.load_avg.map((l) => l.toFixed(1)).join(" ")}` : undefined}
27902ea121 loaded={loaded}
27902ea122 />
27902ea123 <GaugeCard
27902ea124 label="Memory"
27902ea125 value={sys?.mem_percent ?? null}
27902ea126 detail={sys ? `${sys.mem_used_mb} / ${sys.mem_total_mb} MB` : undefined}
27902ea127 loaded={loaded}
27902ea128 />
27902ea129 <GaugeCard
27902ea130 label="Disk"
27902ea131 value={sys?.disk_percent ?? null}
27902ea132 detail={sys ? `${sys.disk_used_gb} / ${sys.disk_total_gb} GB` : undefined}
27902ea133 loaded={loaded}
27902ea134 />
27902ea135 <GaugeCard
27902ea136 label="Uptime"
27902ea137 value={null}
27902ea138 displayValue={sys?.uptime || null}
27902ea139 loaded={loaded}
27902ea140 />
27902ea141 </div>
0b1c50f142
27902ea143 {/* ── Services + Containers ── */}
27902ea144 <div
27902ea145 style={{
27902ea146 display: "grid",
27902ea147 gridTemplateColumns: "1fr 1fr",
27902ea148 gap: "0.625rem",
27902ea149 }}
27902ea150 >
27902ea151 <Panel title="Services" count={services.length}>
27902ea152 {!loaded ? (
27902ea153 <SkeletonRows n={6} />
27902ea154 ) : services.length === 0 ? (
27902ea155 <EmptyRow>No service data</EmptyRow>
27902ea156 ) : (
27902ea157 <div className="flex flex-col">
27902ea158 {services.map((svc) => (
27902ea159 <div
27902ea160 key={svc.name}
27902ea161 className="flex items-center justify-between py-1.5 px-2.5"
27902ea162 style={{ borderBottom: "1px solid var(--divide)" }}
27902ea163 >
27902ea164 <div className="flex items-center gap-2">
27902ea165 <StatusDot status={svc.status} />
27902ea166 <span className="text-sm" style={{ color: "var(--text-primary)" }}>{svc.name}</span>
27902ea167 </div>
27902ea168 <span className="text-xs font-mono" style={{ color: "var(--text-faint)" }}>
27902ea169 {svc.latency !== null ? `${svc.latency}ms` : svc.detail ?? "—"}
27902ea170 </span>
27902ea171 </div>
27902ea172 ))}
27902ea173 </div>
27902ea174 )}
27902ea175 </Panel>
4dfd09b176
27902ea177 <Panel title="Containers" count={containers.length}>
27902ea178 {!loaded ? (
27902ea179 <SkeletonRows n={6} />
27902ea180 ) : containers.length === 0 ? (
27902ea181 <EmptyRow>No container data</EmptyRow>
27902ea182 ) : (
27902ea183 <div className="flex flex-col">
27902ea184 {containers.map((c) => (
27902ea185 <div
27902ea186 key={c.name}
27902ea187 className="flex items-center justify-between py-1.5 px-2.5"
27902ea188 style={{ borderBottom: "1px solid var(--divide)" }}
27902ea189 >
27902ea190 <div className="flex items-center gap-2 min-w-0">
27902ea191 <StatusDot status={c.status === "running" ? "operational" : "down"} />
27902ea192 <span className="text-sm truncate" style={{ color: "var(--text-primary)" }}>{c.name}</span>
27902ea193 <span className="text-xs shrink-0" style={{ color: "var(--text-faint)" }}>{c.uptime}</span>
27902ea194 </div>
27902ea195 <div className="flex items-center gap-3 shrink-0">
27902ea196 <MiniBar value={c.cpu_percent} max={100} label={`${c.cpu_percent.toFixed(1)}%`} color="var(--accent)" />
27902ea197 <MiniBar value={c.mem_usage_mb} max={c.mem_limit_mb || 1} label={`${c.mem_usage_mb}M`} color="var(--status-merged-text)" />
27902ea198 </div>
27902ea199 </div>
27902ea200 ))}
27902ea201 </div>
27902ea202 )}
27902ea203 </Panel>
27902ea204 </div>
27902ea205 </div>
27902ea206 );
27902ea207}
27902ea208
27902ea209/* ── Gauge card ── */
4a006da210
27902ea211function GaugeCard({
27902ea212 label,
27902ea213 value,
27902ea214 displayValue,
27902ea215 detail,
27902ea216 loaded,
27902ea217}: {
27902ea218 label: string;
27902ea219 value: number | null;
27902ea220 displayValue?: string | null;
27902ea221 detail?: string;
27902ea222 loaded: boolean;
27902ea223}) {
27902ea224 const pct = value !== null ? Math.min(100, Math.max(0, value)) : null;
27902ea225 const color =
27902ea226 pct === null
27902ea227 ? "var(--text-faint)"
27902ea228 : pct > 85
27902ea229 ? "var(--status-closed-text)"
27902ea230 : pct > 60
27902ea231 ? "var(--status-merged-text)"
27902ea232 : "var(--status-open-text)";
4a006da233
4a006da234 return (
27902ea235 <div
27902ea236 style={{
27902ea237 backgroundColor: "var(--bg-card)",
27902ea238 border: "1px solid var(--border-subtle)",
27902ea239 padding: "0.75rem 0.875rem",
27902ea240 position: "relative",
27902ea241 overflow: "hidden",
27902ea242 }}
27902ea243 >
27902ea244 {pct !== null && (
4dfd09b245 <div
4dfd09b246 style={{
27902ea247 position: "absolute",
27902ea248 left: 0,
27902ea249 bottom: 0,
27902ea250 width: `${pct}%`,
27902ea251 height: 3,
27902ea252 backgroundColor: color,
27902ea253 transition: "width 0.6s ease, background-color 0.3s",
4dfd09b254 }}
27902ea255 />
27902ea256 )}
27902ea257 <div className="text-xs font-mono" style={{ color: "var(--text-faint)", marginBottom: 3 }}>
27902ea258 {label}
27902ea259 </div>
27902ea260 {!loaded ? (
27902ea261 <Skeleton width="3rem" height="1.25rem" />
27902ea262 ) : (
27902ea263 <>
27902ea264 <div
27902ea265 className="font-mono"
27902ea266 style={{ fontSize: "1.375rem", fontWeight: 500, color: displayValue ? "var(--text-primary)" : color, lineHeight: 1.1 }}
27902ea267 >
27902ea268 {displayValue ?? (pct !== null ? `${pct}%` : "—")}
bdf6540269 </div>
27902ea270 {detail && (
27902ea271 <div className="text-xs" style={{ color: "var(--text-faint)", marginTop: 2 }}>{detail}</div>
bdf6540272 )}
27902ea273 </>
4a006da274 )}
27902ea275 </div>
27902ea276 );
27902ea277}
4a006da278
27902ea279/* ── Mini bar ── */
4dfd09b280
27902ea281function MiniBar({ value, max, label, color }: { value: number; max: number; label: string; color: string }) {
27902ea282 const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
27902ea283 return (
27902ea284 <div className="flex items-center gap-1.5" style={{ minWidth: 70 }}>
27902ea285 <div style={{ width: 36, height: 6, backgroundColor: "var(--bg-inset)", borderRadius: 3, overflow: "hidden" }}>
27902ea286 <div style={{ width: `${pct}%`, height: "100%", backgroundColor: color, borderRadius: 3, transition: "width 0.4s ease" }} />
4a006da287 </div>
27902ea288 <span className="text-xs font-mono" style={{ color: "var(--text-faint)" }}>{label}</span>
27902ea289 </div>
27902ea290 );
27902ea291}
4a006da292
27902ea293/* ── Panel ── */
79efd41294
27902ea295function Panel({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) {
27902ea296 return (
27902ea297 <div
27902ea298 style={{
27902ea299 backgroundColor: "var(--bg-card)",
27902ea300 border: "1px solid var(--border-subtle)",
27902ea301 display: "flex",
27902ea302 flexDirection: "column",
27902ea303 }}
27902ea304 >
27902ea305 <div className="flex items-center gap-2 px-2.5 py-1.5" style={{ borderBottom: "1px solid var(--divide)" }}>
27902ea306 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{title}</span>
27902ea307 {count !== undefined && (
27902ea308 <span className="text-xs" style={{ color: "var(--text-faint)" }}>{count}</span>
79efd41309 )}
79efd41310 </div>
27902ea311 <div style={{ flex: 1 }}>{children}</div>
27902ea312 </div>
27902ea313 );
27902ea314}
79efd41315
4a006da316
27902ea317/* ── Helpers ── */
cf89d3c318
27902ea319function StatusDot({ status }: { status: "operational" | "degraded" | "down" }) {
27902ea320 const color =
27902ea321 status === "operational" ? "var(--status-open-text)" : status === "degraded" ? "var(--status-merged-text)" : "var(--status-closed-text)";
27902ea322 return (
27902ea323 <span style={{ width: 7, height: 7, borderRadius: "50%", backgroundColor: color, display: "inline-block", flexShrink: 0 }} />
27902ea324 );
27902ea325}
27902ea326
27902ea327function SkeletonRows({ n }: { n: number }) {
27902ea328 return (
27902ea329 <div className="p-2 space-y-1">
27902ea330 {Array.from({ length: n }, (_, i) => (
27902ea331 <Skeleton key={i} width="100%" height="1.75rem" />
27902ea332 ))}
4a006da333 </div>
4a006da334 );
4a006da335}
27902ea336
27902ea337function EmptyRow({ children }: { children: React.ReactNode }) {
27902ea338 return (
27902ea339 <div className="py-4 text-center text-xs" style={{ color: "var(--text-faint)" }}>{children}</div>
27902ea340 );
27902ea341}