10.3 KB342 lines
Blame
1"use client";
2
3import { useEffect, useState, useCallback } from "react";
4import { useRouter } from "next/navigation";
5import { useAuth } from "@/lib/auth";
6import { Skeleton } from "@/app/components/skeleton";
7
8/* ── Types ── */
9
10interface ServiceCheck {
11 name: string;
12 status: "operational" | "degraded" | "down";
13 latency: number | null;
14 detail?: string;
15}
16
17interface ContainerStats {
18 name: string;
19 status: string;
20 cpu_percent: number;
21 mem_usage_mb: number;
22 mem_limit_mb: number;
23 mem_percent: number;
24 net_rx_mb: number;
25 net_tx_mb: number;
26 uptime: string;
27}
28
29interface SystemMetrics {
30 cpu_percent: number;
31 mem_total_mb: number;
32 mem_used_mb: number;
33 mem_percent: number;
34 disk_total_gb: number;
35 disk_used_gb: number;
36 disk_percent: number;
37 load_avg: number[];
38 uptime: string;
39}
40
41interface StatusResponse {
42 status: string;
43 services: ServiceCheck[];
44 containers: ContainerStats[];
45 system: SystemMetrics | null;
46 checked_at: string;
47}
48
49/* ── Page ── */
50
51export default function DashboardPage() {
52 const { user, loading: authLoading } = useAuth();
53 const router = useRouter();
54
55 const [data, setData] = useState<StatusResponse | null>(null);
56 const [loaded, setLoaded] = useState(false);
57
58 useEffect(() => {
59 document.title = "Dashboard";
60 }, []);
61
62 useEffect(() => {
63 if (!authLoading && !user) {
64 router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
65 }
66 }, [authLoading, user, router]);
67
68 const refresh = useCallback(async () => {
69 if (!user) return;
70 try {
71 const res = await fetch("/api/status", { cache: "no-store" });
72 const json: StatusResponse = await res.json();
73 setData(json);
74 } catch {}
75 setLoaded(true);
76 }, [user]);
77
78 useEffect(() => {
79 if (!user) return;
80 refresh();
81 const iv = setInterval(refresh, 30000);
82 return () => clearInterval(iv);
83 }, [user, refresh]);
84
85 if (authLoading || !user) return null;
86
87 const sys = data?.system;
88 const containers = data?.containers ?? [];
89 const services = data?.services ?? [];
90
91 return (
92 <div style={{ maxWidth: 1100, margin: "0 auto", padding: "1.25rem 1rem 2rem" }}>
93 {/* ── Header ── */}
94 <div className="flex items-center justify-between" style={{ marginBottom: "1rem" }}>
95 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Dashboard</span>
96 <div className="flex items-center gap-3">
97 {data?.checked_at && (
98 <span className="text-xs" style={{ color: "var(--text-faint)" }}>
99 {new Date(data.checked_at).toLocaleTimeString()}
100 </span>
101 )}
102 <button onClick={refresh} className="btn-reset text-xs" style={{ color: "var(--text-muted)" }}>
103 Refresh
104 </button>
105 </div>
106 </div>
107
108 {/* ── System gauges ── */}
109 <div
110 style={{
111 display: "grid",
112 gridTemplateColumns: "repeat(4, 1fr)",
113 gap: "0.625rem",
114 marginBottom: "0.875rem",
115 }}
116 >
117 <GaugeCard
118 label="CPU"
119 value={sys?.cpu_percent ?? null}
120 detail={sys?.load_avg ? `Load ${sys.load_avg.map((l) => l.toFixed(1)).join(" ")}` : undefined}
121 loaded={loaded}
122 />
123 <GaugeCard
124 label="Memory"
125 value={sys?.mem_percent ?? null}
126 detail={sys ? `${sys.mem_used_mb} / ${sys.mem_total_mb} MB` : undefined}
127 loaded={loaded}
128 />
129 <GaugeCard
130 label="Disk"
131 value={sys?.disk_percent ?? null}
132 detail={sys ? `${sys.disk_used_gb} / ${sys.disk_total_gb} GB` : undefined}
133 loaded={loaded}
134 />
135 <GaugeCard
136 label="Uptime"
137 value={null}
138 displayValue={sys?.uptime || null}
139 loaded={loaded}
140 />
141 </div>
142
143 {/* ── Services + Containers ── */}
144 <div
145 style={{
146 display: "grid",
147 gridTemplateColumns: "1fr 1fr",
148 gap: "0.625rem",
149 }}
150 >
151 <Panel title="Services" count={services.length}>
152 {!loaded ? (
153 <SkeletonRows n={6} />
154 ) : services.length === 0 ? (
155 <EmptyRow>No service data</EmptyRow>
156 ) : (
157 <div className="flex flex-col">
158 {services.map((svc) => (
159 <div
160 key={svc.name}
161 className="flex items-center justify-between py-1.5 px-2.5"
162 style={{ borderBottom: "1px solid var(--divide)" }}
163 >
164 <div className="flex items-center gap-2">
165 <StatusDot status={svc.status} />
166 <span className="text-sm" style={{ color: "var(--text-primary)" }}>{svc.name}</span>
167 </div>
168 <span className="text-xs font-mono" style={{ color: "var(--text-faint)" }}>
169 {svc.latency !== null ? `${svc.latency}ms` : svc.detail ?? "—"}
170 </span>
171 </div>
172 ))}
173 </div>
174 )}
175 </Panel>
176
177 <Panel title="Containers" count={containers.length}>
178 {!loaded ? (
179 <SkeletonRows n={6} />
180 ) : containers.length === 0 ? (
181 <EmptyRow>No container data</EmptyRow>
182 ) : (
183 <div className="flex flex-col">
184 {containers.map((c) => (
185 <div
186 key={c.name}
187 className="flex items-center justify-between py-1.5 px-2.5"
188 style={{ borderBottom: "1px solid var(--divide)" }}
189 >
190 <div className="flex items-center gap-2 min-w-0">
191 <StatusDot status={c.status === "running" ? "operational" : "down"} />
192 <span className="text-sm truncate" style={{ color: "var(--text-primary)" }}>{c.name}</span>
193 <span className="text-xs shrink-0" style={{ color: "var(--text-faint)" }}>{c.uptime}</span>
194 </div>
195 <div className="flex items-center gap-3 shrink-0">
196 <MiniBar value={c.cpu_percent} max={100} label={`${c.cpu_percent.toFixed(1)}%`} color="var(--accent)" />
197 <MiniBar value={c.mem_usage_mb} max={c.mem_limit_mb || 1} label={`${c.mem_usage_mb}M`} color="var(--status-merged-text)" />
198 </div>
199 </div>
200 ))}
201 </div>
202 )}
203 </Panel>
204 </div>
205 </div>
206 );
207}
208
209/* ── Gauge card ── */
210
211function GaugeCard({
212 label,
213 value,
214 displayValue,
215 detail,
216 loaded,
217}: {
218 label: string;
219 value: number | null;
220 displayValue?: string | null;
221 detail?: string;
222 loaded: boolean;
223}) {
224 const pct = value !== null ? Math.min(100, Math.max(0, value)) : null;
225 const color =
226 pct === null
227 ? "var(--text-faint)"
228 : pct > 85
229 ? "var(--status-closed-text)"
230 : pct > 60
231 ? "var(--status-merged-text)"
232 : "var(--status-open-text)";
233
234 return (
235 <div
236 style={{
237 backgroundColor: "var(--bg-card)",
238 border: "1px solid var(--border-subtle)",
239 padding: "0.75rem 0.875rem",
240 position: "relative",
241 overflow: "hidden",
242 }}
243 >
244 {pct !== null && (
245 <div
246 style={{
247 position: "absolute",
248 left: 0,
249 bottom: 0,
250 width: `${pct}%`,
251 height: 3,
252 backgroundColor: color,
253 transition: "width 0.6s ease, background-color 0.3s",
254 }}
255 />
256 )}
257 <div className="text-xs font-mono" style={{ color: "var(--text-faint)", marginBottom: 3 }}>
258 {label}
259 </div>
260 {!loaded ? (
261 <Skeleton width="3rem" height="1.25rem" />
262 ) : (
263 <>
264 <div
265 className="font-mono"
266 style={{ fontSize: "1.375rem", fontWeight: 500, color: displayValue ? "var(--text-primary)" : color, lineHeight: 1.1 }}
267 >
268 {displayValue ?? (pct !== null ? `${pct}%` : "—")}
269 </div>
270 {detail && (
271 <div className="text-xs" style={{ color: "var(--text-faint)", marginTop: 2 }}>{detail}</div>
272 )}
273 </>
274 )}
275 </div>
276 );
277}
278
279/* ── Mini bar ── */
280
281function MiniBar({ value, max, label, color }: { value: number; max: number; label: string; color: string }) {
282 const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
283 return (
284 <div className="flex items-center gap-1.5" style={{ minWidth: 70 }}>
285 <div style={{ width: 36, height: 6, backgroundColor: "var(--bg-inset)", borderRadius: 3, overflow: "hidden" }}>
286 <div style={{ width: `${pct}%`, height: "100%", backgroundColor: color, borderRadius: 3, transition: "width 0.4s ease" }} />
287 </div>
288 <span className="text-xs font-mono" style={{ color: "var(--text-faint)" }}>{label}</span>
289 </div>
290 );
291}
292
293/* ── Panel ── */
294
295function Panel({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) {
296 return (
297 <div
298 style={{
299 backgroundColor: "var(--bg-card)",
300 border: "1px solid var(--border-subtle)",
301 display: "flex",
302 flexDirection: "column",
303 }}
304 >
305 <div className="flex items-center gap-2 px-2.5 py-1.5" style={{ borderBottom: "1px solid var(--divide)" }}>
306 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{title}</span>
307 {count !== undefined && (
308 <span className="text-xs" style={{ color: "var(--text-faint)" }}>{count}</span>
309 )}
310 </div>
311 <div style={{ flex: 1 }}>{children}</div>
312 </div>
313 );
314}
315
316
317/* ── Helpers ── */
318
319function StatusDot({ status }: { status: "operational" | "degraded" | "down" }) {
320 const color =
321 status === "operational" ? "var(--status-open-text)" : status === "degraded" ? "var(--status-merged-text)" : "var(--status-closed-text)";
322 return (
323 <span style={{ width: 7, height: 7, borderRadius: "50%", backgroundColor: color, display: "inline-block", flexShrink: 0 }} />
324 );
325}
326
327function SkeletonRows({ n }: { n: number }) {
328 return (
329 <div className="p-2 space-y-1">
330 {Array.from({ length: n }, (_, i) => (
331 <Skeleton key={i} width="100%" height="1.75rem" />
332 ))}
333 </div>
334 );
335}
336
337function EmptyRow({ children }: { children: React.ReactNode }) {
338 return (
339 <div className="py-4 text-center text-xs" style={{ color: "var(--text-faint)" }}>{children}</div>
340 );
341}
342