362 lines
17 KiB
TypeScript
362 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
|
import { getStats, type Stats, type NetworkInterface } from "../../lib/getStats";
|
|
import AnalyticsPanel from "./AnalyticsPanel";
|
|
|
|
// ── Types / constants ─────────────────────────────────────────────────────────
|
|
|
|
const COST_PER_KWH = 0.24;
|
|
|
|
interface DeviceReading { name: string; watts: number; on: boolean; today_wh: number; month_wh: number; }
|
|
interface HistoryEntry { ts: string; devices: DeviceReading[]; }
|
|
|
|
const PRESETS = [
|
|
{ label: "1h", h: 1 },
|
|
{ label: "6h", h: 6 },
|
|
{ label: "24h", h: 24 },
|
|
{ label: "3d", h: 72 },
|
|
{ label: "7d", h: 168 },
|
|
{ label: "30d", h: 720 },
|
|
];
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function fmtBytes(mb: number): string {
|
|
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
|
|
return `${mb.toFixed(0)} MB`;
|
|
}
|
|
|
|
function fmtUptime(up: Stats["uptime"]): string {
|
|
const parts: string[] = [];
|
|
if (up.days > 0) parts.push(`${up.days}d`);
|
|
if (up.hours > 0 || up.days > 0) parts.push(`${up.hours}h`);
|
|
parts.push(`${up.minutes}m`);
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function fmtNetBytes(bytes: number): string {
|
|
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(2)} GB/s`;
|
|
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB/s`;
|
|
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(0)} KB/s`;
|
|
return `${bytes.toFixed(0)} B/s`;
|
|
}
|
|
|
|
// ── Sub-components ────────────────────────────────────────────────────────────
|
|
|
|
function StatCard({ label, value, sub, hot }: { label: string; value: string; sub?: string; hot?: boolean }) {
|
|
return (
|
|
<div style={{
|
|
border: "1px solid var(--color-secondary)",
|
|
borderRadius: 12,
|
|
padding: "16px 20px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 4,
|
|
background: "var(--color-primary)",
|
|
}}>
|
|
<p style={{ fontSize: "9pt", color: "var(--color-foreground-sec)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.07em", margin: 0 }}>
|
|
{label}
|
|
</p>
|
|
<p style={{ fontSize: "24pt", fontWeight: 700, color: hot ? "var(--color-blue)" : "var(--color-foreground)", lineHeight: 1, margin: 0 }}>
|
|
{value}
|
|
</p>
|
|
{sub && <p style={{ fontSize: "9pt", color: "var(--color-foreground-sec)", margin: 0 }}>{sub}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PowerSummaryCard({ label, value, sub, accent }: { label: string; value: string; sub?: string; accent?: string }) {
|
|
return (
|
|
<div style={{
|
|
border: "1px solid var(--color-secondary)",
|
|
borderRadius: 12,
|
|
padding: "14px 18px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 3,
|
|
}}>
|
|
<p style={{ fontSize: "9pt", color: "var(--color-foreground-sec)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.07em", margin: 0 }}>
|
|
{label}
|
|
</p>
|
|
<p style={{ fontSize: "20pt", fontWeight: 700, color: accent ?? "var(--color-foreground)", lineHeight: 1.1, margin: 0 }}>
|
|
{value}
|
|
</p>
|
|
{sub && <p style={{ fontSize: "9pt", color: "var(--color-foreground-sec)", margin: 0 }}>{sub}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BreakdownBar({ label, count, total, color }: { label: string; count: number; total: number; color?: string }) {
|
|
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "9.5pt" }}>
|
|
<span style={{ color: "var(--color-foreground)", fontWeight: 500 }}>{label}</span>
|
|
<span style={{ color: "var(--color-foreground-sec)" }}>{count} · {pct}%</span>
|
|
</div>
|
|
<div style={{ height: 4, borderRadius: 2, background: "var(--color-secondary)", overflow: "hidden" }}>
|
|
<div style={{ width: `${pct}%`, height: "100%", borderRadius: 2, background: color ?? "var(--color-blue)", transition: "width 0.4s ease" }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<p style={{ fontSize: "10pt", fontWeight: 600, color: "var(--color-foreground)", margin: "0 0 12px 0" }}>
|
|
{children}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
// ── DashboardPanel ────────────────────────────────────────────────────────────
|
|
|
|
export default function DashboardPanel({ isAuthed }: { isAuthed: boolean }) {
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [netSpeed, setNetSpeed] = useState<{ rx: number; tx: number } | null>(null);
|
|
const prevNetRef = useRef<Record<string, NetworkInterface> | null>(null);
|
|
const prevTimeRef = useRef<number>(0);
|
|
|
|
const [hours, setHours] = useState(24);
|
|
const [readings, setReadings] = useState<HistoryEntry[]>([]);
|
|
const [powerLoading, setPowerLoading] = useState(true);
|
|
|
|
// System stats poll
|
|
useEffect(() => {
|
|
const go = async () => {
|
|
try {
|
|
const now = Date.now();
|
|
const data = await getStats();
|
|
setStats(data);
|
|
const primary = Object.keys(data.network).find(
|
|
(k) => !k.startsWith("docker") && !k.startsWith("br-") && data.network[k].rx > 0,
|
|
);
|
|
if (primary && prevNetRef.current?.[primary] && prevTimeRef.current > 0) {
|
|
const elapsed = (now - prevTimeRef.current) / 1000;
|
|
const prev = prevNetRef.current[primary];
|
|
setNetSpeed({
|
|
rx: Math.max(0, (data.network[primary].rx - prev.rx) / elapsed),
|
|
tx: Math.max(0, (data.network[primary].tx - prev.tx) / elapsed),
|
|
});
|
|
}
|
|
prevNetRef.current = data.network;
|
|
prevTimeRef.current = now;
|
|
} catch {}
|
|
};
|
|
go();
|
|
const id = setInterval(go, 4000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
// Power history fetch
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setPowerLoading(true);
|
|
fetch(`/api/power/history?hours=${hours}`)
|
|
.then(r => r.ok ? r.json() : { readings: [] })
|
|
.then(d => { if (!cancelled) { setReadings(d.readings ?? []); setPowerLoading(false); } })
|
|
.catch(() => { if (!cancelled) setPowerLoading(false); });
|
|
return () => { cancelled = true; };
|
|
}, [hours]);
|
|
|
|
// Power summary stats
|
|
const powerStats = useMemo(() => {
|
|
if (readings.length === 0) return null;
|
|
const deviceNames = [...new Set(readings.flatMap(r => r.devices.map(d => d.name)))];
|
|
let totalWh = 0;
|
|
let totalAvgW = 0;
|
|
for (const name of deviceNames) {
|
|
const pts = readings
|
|
.map(r => { const d = r.devices.find(x => x.name === name); return d ? { ts: new Date(r.ts).getTime(), w: d.watts } : null; })
|
|
.filter(Boolean).sort((a, b) => a!.ts - b!.ts) as { ts: number; w: number }[];
|
|
let wh = 0;
|
|
for (let i = 1; i < pts.length; i++) {
|
|
wh += (pts[i].w + pts[i - 1].w) / 2 * (pts[i].ts - pts[i - 1].ts) / 3_600_000;
|
|
}
|
|
totalWh += wh;
|
|
totalAvgW += pts.length ? pts.reduce((s, p) => s + p.w, 0) / pts.length : 0;
|
|
}
|
|
const cost = totalWh / 1000 * COST_PER_KWH;
|
|
const latest = readings[readings.length - 1];
|
|
const activeDevices = latest ? latest.devices.filter(d => d.on).length : 0;
|
|
const totalDevices = latest ? latest.devices.length : 0;
|
|
return { totalWh, totalAvgW, cost, activeDevices, totalDevices };
|
|
}, [readings]);
|
|
|
|
const s = stats;
|
|
|
|
const svcEntries = isAuthed && s ? Object.entries(s.services) : [];
|
|
const svcRunning = svcEntries.filter(([, v]) => v === "running" || v === "active").length;
|
|
const svcStopped = svcEntries.filter(([, v]) => v === "inactive" || v === "stopped" || v === "dead").length;
|
|
const svcFailed = svcEntries.filter(([, v]) => v === "failed").length;
|
|
const svcTotal = svcEntries.length;
|
|
|
|
return (
|
|
<div style={{ padding: "20px", display: "flex", flexDirection: "column", gap: 20 }}>
|
|
|
|
{/* System stat cards */}
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))", gap: 12 }}>
|
|
<StatCard label="CPU" value={s ? `${s.cpu.percent.toFixed(1)}%` : "—"} sub={s?.cpu.model.replace(/\(R\)/g, "").replace(/\(TM\)/g, "").trim().split(" ").slice(0, 4).join(" ")} hot={s != null && s.cpu.percent > 80} />
|
|
<StatCard label="Memory" value={s ? `${s.memory.percent.toFixed(0)}%` : "—"} sub={s ? `${fmtBytes(s.memory.used)} / ${fmtBytes(s.memory.total)}` : ""} hot={s != null && s.memory.percent > 85} />
|
|
<StatCard label="Disk" value={s ? `${s.disk.percent}%` : "—"} sub={s ? `${(s.disk.used / 1024).toFixed(1)} GB / ${(s.disk.total / 1024).toFixed(0)} GB` : ""} hot={s != null && s.disk.percent > 90} />
|
|
<StatCard label="Temp" value={s?.temperature != null ? `${s.temperature}°` : "—"} sub={s?.temperature != null ? (s.temperature > 80 ? "Running hot" : s.temperature > 60 ? "Warm" : "Cool") : ""} hot={s?.temperature != null && s.temperature > 75} />
|
|
</div>
|
|
|
|
{/* Power analytics section */}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
|
|
{/* Header row: title + time range pills */}
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
|
<p style={{ fontSize: "10pt", fontWeight: 600, color: "var(--color-foreground)", margin: 0 }}>
|
|
Power Analytics
|
|
</p>
|
|
<div style={{ display: "flex", gap: 4 }}>
|
|
{PRESETS.map(p => (
|
|
<button key={p.h} onClick={() => setHours(p.h)} style={{
|
|
padding: "4px 10px", borderRadius: 7, fontSize: "10pt", fontWeight: 500, cursor: "pointer",
|
|
border: `1px solid ${hours === p.h ? "var(--color-blue)" : "var(--color-secondary)"}`,
|
|
background: hours === p.h ? "color-mix(in srgb, var(--color-blue) 14%, transparent)" : "transparent",
|
|
color: hours === p.h ? "var(--color-blue)" : "var(--color-foreground-sec)",
|
|
transition: "all 120ms",
|
|
}}>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary cards */}
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 10 }}>
|
|
<PowerSummaryCard
|
|
label="Avg Power"
|
|
value={powerStats ? `${powerStats.totalAvgW.toFixed(1)} W` : "—"}
|
|
sub="combined average"
|
|
accent="var(--color-blue)"
|
|
/>
|
|
<PowerSummaryCard
|
|
label="Total Energy"
|
|
value={powerStats ? (powerStats.totalWh >= 1000 ? `${(powerStats.totalWh / 1000).toFixed(2)} kWh` : `${powerStats.totalWh.toFixed(1)} Wh`) : "—"}
|
|
sub={`over ${PRESETS.find(p => p.h === hours)?.label ?? `${hours}h`}`}
|
|
/>
|
|
<PowerSummaryCard
|
|
label="Est. Cost"
|
|
value={powerStats ? `$${powerStats.cost.toFixed(3)}` : "—"}
|
|
sub={`@ $${COST_PER_KWH}/kWh`}
|
|
accent="#5dd776"
|
|
/>
|
|
<PowerSummaryCard
|
|
label="Active Devices"
|
|
value={powerStats ? `${powerStats.activeDevices}` : "—"}
|
|
sub={powerStats ? `of ${powerStats.totalDevices} total` : ""}
|
|
/>
|
|
</div>
|
|
|
|
{/* Mini charts */}
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 12 }}>
|
|
{(["line", "bar", "candle"] as const).map((type) => (
|
|
<div key={type} style={{ border: "1px solid var(--color-secondary)", borderRadius: 12, overflow: "hidden", height: 220 }}>
|
|
<div style={{ padding: "10px 14px 6px", borderBottom: "1px solid color-mix(in srgb, var(--color-secondary) 60%, transparent)" }}>
|
|
<p style={{ fontSize: "9pt", fontWeight: 600, color: "var(--color-foreground-sec)", margin: 0 }}>
|
|
{type === "line" ? "Line" : type === "bar" ? "Bar" : "Candlestick"}
|
|
</p>
|
|
</div>
|
|
<div style={{ height: "calc(100% - 37px)" }}>
|
|
<AnalyticsPanel key={hours} chartType={type} readOnly defaultHours={hours} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Authenticated: services + system info */}
|
|
{isAuthed && (
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12 }}>
|
|
{svcTotal > 0 && (
|
|
<div style={{ border: "1px solid var(--color-secondary)", borderRadius: 12, padding: "16px 20px" }}>
|
|
<SectionTitle>Services ({svcTotal})</SectionTitle>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
<BreakdownBar label="Running" count={svcRunning} total={svcTotal} color="#5dd776" />
|
|
<BreakdownBar label="Stopped" count={svcStopped} total={svcTotal} color="var(--color-foreground-sec)" />
|
|
{svcFailed > 0 && <BreakdownBar label="Failed" count={svcFailed} total={svcTotal} color="#ef4444" />}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{s?.loadAvg && (
|
|
<div style={{ border: "1px solid var(--color-secondary)", borderRadius: 12, padding: "16px 20px" }}>
|
|
<SectionTitle>Load Average</SectionTitle>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
{([["1 min", s.loadAvg["1m"]], ["5 min", s.loadAvg["5m"]], ["15 min", s.loadAvg["15m"]]] as [string, number][]).map(([label, val]) => (
|
|
<div key={label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
<span style={{ fontSize: "9.5pt", color: "var(--color-foreground)", fontWeight: 500 }}>{label}</span>
|
|
<span style={{ fontSize: "9.5pt", color: "var(--color-foreground-sec)" }}>{val.toFixed(2)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{s?.uptime && (
|
|
<div style={{ border: "1px solid var(--color-secondary)", borderRadius: 12, padding: "16px 20px" }}>
|
|
<SectionTitle>Uptime</SectionTitle>
|
|
<p style={{ fontSize: "22pt", fontWeight: 700, color: "var(--color-foreground)", lineHeight: 1, margin: "0 0 8px 0" }}>
|
|
{fmtUptime(s.uptime)}
|
|
</p>
|
|
<p style={{ fontSize: "9pt", color: "var(--color-foreground-sec)", margin: 0 }}>
|
|
{s.uptime.days > 0
|
|
? `${s.uptime.days} days, ${s.uptime.hours} hours, ${s.uptime.minutes} min`
|
|
: `${s.uptime.hours} hours, ${s.uptime.minutes} min`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{netSpeed && (
|
|
<div style={{ border: "1px solid var(--color-secondary)", borderRadius: 12, padding: "16px 20px" }}>
|
|
<SectionTitle>Network</SectionTitle>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
<span style={{ fontSize: "9.5pt", color: "var(--color-foreground)", fontWeight: 500 }}>↓ Download</span>
|
|
<span style={{ fontSize: "9.5pt", color: "#5dd776", fontWeight: 600 }}>{fmtNetBytes(netSpeed.rx)}</span>
|
|
</div>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
<span style={{ fontSize: "9.5pt", color: "var(--color-foreground)", fontWeight: 500 }}>↑ Upload</span>
|
|
<span style={{ fontSize: "9.5pt", color: "var(--color-blue)", fontWeight: 600 }}>{fmtNetBytes(netSpeed.tx)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Service status table (authenticated only) */}
|
|
{isAuthed && svcEntries.length > 0 && (
|
|
<div style={{ border: "1px solid var(--color-secondary)", borderRadius: 12, overflow: "hidden" }}>
|
|
<div style={{ padding: "12px 20px", borderBottom: "1px solid var(--color-secondary)" }}>
|
|
<SectionTitle>Service Status</SectionTitle>
|
|
</div>
|
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
<thead>
|
|
<tr style={{ background: "color-mix(in srgb, var(--color-secondary) 30%, transparent)" }}>
|
|
<th style={{ padding: "8px 20px", textAlign: "left", fontSize: "9pt", fontWeight: 600, color: "var(--color-foreground-sec)", textTransform: "uppercase", letterSpacing: "0.05em" }}>Service</th>
|
|
<th style={{ padding: "8px 20px", textAlign: "right", fontSize: "9pt", fontWeight: 600, color: "var(--color-foreground-sec)", textTransform: "uppercase", letterSpacing: "0.05em" }}>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{svcEntries.map(([name, status], i) => {
|
|
const isRunning = status === "running" || status === "active";
|
|
const isFailed = status === "failed";
|
|
const color = isFailed ? "#ef4444" : isRunning ? "#5dd776" : "var(--color-foreground-sec)";
|
|
return (
|
|
<tr key={name} style={{ background: i % 2 !== 0 ? "color-mix(in srgb, var(--color-secondary) 20%, transparent)" : "transparent" }}>
|
|
<td style={{ padding: "8px 20px", fontSize: "10pt", fontWeight: 500, color: "var(--color-foreground)" }}>{name}</td>
|
|
<td style={{ padding: "8px 20px", textAlign: "right" }}>
|
|
<span style={{ fontSize: "9pt", fontWeight: 600, color, textTransform: "capitalize" }}>{status}</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|