Tile windowing system

This commit is contained in:
Jack Mechem 2026-05-22 02:19:57 -07:00
parent c6e6c5ca48
commit 43318fb8cd
35 changed files with 4659 additions and 360 deletions

603
app/analytics/page.tsx Normal file
View file

@ -0,0 +1,603 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import {
ResponsiveContainer, LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, CartesianGrid, Legend, Brush, ReferenceArea,
} from "recharts";
import { IconRefresh } from "@tabler/icons-react";
import SideNav from "../components/SideNav";
// ── Types ────────────────────────────────────────────────────────────────────
interface DeviceReading { name: string; watts: number; on: boolean; today_wh: number; month_wh: number; }
interface HistoryEntry { ts: string; devices: DeviceReading[]; }
interface Candle { open: number; close: number; high: number; low: number; }
interface CandleBucket { ts: number; label: string; [device: string]: Candle | number | string; }
type ChartType = "line" | "bar" | "candle";
const COST_PER_KWH = 0.24; // LADWP Northridge CA 2025
const DEVICE_COLORS: Record<string, string> = { server: "#428ce2", desktop: "#a78bfa" };
const CHART_MARGIN = { top: 4, right: 16, bottom: 4, left: 0 };
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmtTs(ts: string, spanH: number): string {
const d = new Date(ts);
if (spanH <= 24) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return d.toLocaleDateString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
}
function fmtDt(iso: string) {
// "2025-01-15T14:30" → "Jan 15, 2:30 PM"
try { return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); }
catch { return iso; }
}
function bucketCandles(readings: HistoryEntry[], spanH: number): CandleBucket[] {
const bucketMs = (spanH <= 6 ? 30 : spanH <= 24 ? 60 : spanH <= 168 ? 360 : 1440) * 60_000;
const map = new Map<number, Map<string, number[]>>();
for (const r of readings) {
const key = Math.floor(new Date(r.ts).getTime() / bucketMs) * bucketMs;
if (!map.has(key)) map.set(key, new Map());
for (const d of r.devices) {
if (!map.get(key)!.has(d.name)) map.get(key)!.set(d.name, []);
map.get(key)!.get(d.name)!.push(d.watts);
}
}
return Array.from(map.entries()).sort(([a], [b]) => a - b).map(([ts, devices]) => {
const label = fmtTs(new Date(ts).toISOString(), spanH);
const pt: CandleBucket = { ts, label };
for (const [name, watts] of devices) {
const s = [...watts].sort((a, b) => a - b);
pt[name] = { open: watts[0], close: watts[watts.length - 1], high: s[s.length - 1], low: s[0] };
}
return pt;
});
}
function computeSummary(readings: HistoryEntry[], deviceName: string) {
const pts = readings.map(r => {
const d = r.devices.find(x => x.name === deviceName);
return d ? { ts: new Date(r.ts).getTime(), watts: d.watts, on: d.on } : null;
}).filter(Boolean).sort((a, b) => a!.ts - b!.ts) as { ts: number; watts: number; on: boolean }[];
if (!pts.length) return null;
const avgW = pts.reduce((s, p) => s + p.watts, 0) / pts.length;
const peakW = Math.max(...pts.map(p => p.watts));
const uptimePct = pts.filter(p => p.on).length / pts.length * 100;
let kWh = pts.length >= 2
? pts.slice(1).reduce((s, p, i) => s + (p.watts + pts[i].watts) / 2 * (p.ts - pts[i].ts) / 3_600_000_000, 0)
: pts[0].watts / 1000 * (5 / 60);
return { avgW, peakW, kWh, cost: kWh * COST_PER_KWH, uptimePct };
}
function autoYDomain(data: Record<string, number | string>[], names: string[]): [number, number] | null {
const vals = data.flatMap(pt => names.map(n => pt[n]).filter(v => typeof v === "number")) as number[];
if (!vals.length) return null;
const mn = Math.min(...vals), mx = Math.max(...vals);
const pad = Math.max((mx - mn) * 0.08, 5);
return [Math.max(0, mn - pad), mx + pad];
}
// ── Candle chart ─────────────────────────────────────────────────────────────
function CandleChart({ data, activeDevices, yDomain, onYDomainChange, onBrush }: {
data: CandleBucket[];
activeDevices: string[];
yDomain: [number, number] | null;
onYDomainChange: (d: [number, number]) => void;
onBrush: (start: number, end: number) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(600);
const [tooltip, setTooltip] = useState<{ x: number; y: number; bucket: CandleBucket } | null>(null);
const [selecting, setSelecting] = useState<{ startPx: number; endPx: number } | null>(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(e => setWidth(e[0].contentRect.width));
ro.observe(el);
setWidth(el.getBoundingClientRect().width);
return () => ro.disconnect();
}, []);
const H = 300, margin = { top: 10, right: 20, bottom: 28, left: 54 };
const innerW = width - margin.left - margin.right;
const innerH = H - margin.top - margin.bottom;
// Y domain
let yMin = Infinity, yMax = -Infinity;
for (const pt of data) for (const n of activeDevices) {
const c = pt[n] as Candle | undefined;
if (c && typeof c === "object") { yMin = Math.min(yMin, c.low); yMax = Math.max(yMax, c.high); }
}
if (yMin === Infinity) { yMin = 0; yMax = 500; }
const pad = Math.max((yMax - yMin) * 0.08, 5);
const [effMin, effMax] = yDomain ?? [Math.max(0, yMin - pad), yMax + pad];
const yScale = (v: number) => margin.top + innerH - ((v - effMin) / (effMax - effMin)) * innerH;
const yFromPx = (py: number) => effMin + (1 - (py - margin.top) / innerH) * (effMax - effMin);
const slotW = innerW / Math.max(data.length, 1);
const candleW = Math.max(3, Math.min(18, slotW / activeDevices.length - 3));
const xScale = (i: number) => margin.left + (i + 0.5) * slotW;
const xToIdx = (px: number) => Math.max(0, Math.min(data.length - 1, Math.floor((px - margin.left) / slotW)));
const yTicks = Array.from({ length: 5 }, (_, i) => effMin + (effMax - effMin) * i / 4);
const xStep = Math.max(1, Math.floor(data.length / 6));
// Scroll = Y zoom
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15;
const py = e.clientY - el.getBoundingClientRect().top;
const center = yFromPx(py);
const half = (effMax - effMin) / 2 * factor;
onYDomainChange([Math.max(0, center - half), center + half]);
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, [effMin, effMax]);
// Mouse drag = X brush selection
const svgRef = useRef<SVGSVGElement>(null);
const onMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
const rect = svgRef.current!.getBoundingClientRect();
setSelecting({ startPx: e.clientX - rect.left, endPx: e.clientX - rect.left });
};
const onMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (!selecting) return;
const rect = svgRef.current!.getBoundingClientRect();
setSelecting(s => s ? { ...s, endPx: e.clientX - rect.left } : null);
setTooltip(null);
};
const onMouseUp = () => {
if (!selecting) return;
const s = xToIdx(selecting.startPx), e = xToIdx(selecting.endPx);
if (Math.abs(s - e) > 0) onBrush(Math.min(s, e), Math.max(s, e));
setSelecting(null);
};
const selX1 = selecting ? Math.min(selecting.startPx, selecting.endPx) : 0;
const selX2 = selecting ? Math.max(selecting.startPx, selecting.endPx) : 0;
return (
<div ref={containerRef} className="relative w-full select-none" style={{ height: H }}>
<svg ref={svgRef} width={width} height={H}
onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={onMouseUp}
onDoubleClick={() => { onYDomainChange(null as any); onBrush(0, data.length - 1); }}
>
{/* Grid */}
{yTicks.map((v, i) => (
<g key={i}>
<line x1={margin.left} y1={yScale(v)} x2={margin.left + innerW} y2={yScale(v)} stroke="currentColor" strokeOpacity={0.08} strokeDasharray="3 3" />
<text x={margin.left - 6} y={yScale(v)} textAnchor="end" dominantBaseline="middle" fontSize={11} fill="currentColor" fillOpacity={0.4}>{Math.round(v)}W</text>
</g>
))}
{data.map((pt, i) => i % xStep !== 0 && i !== data.length - 1 ? null : (
<text key={i} x={xScale(i)} y={margin.top + innerH + 18} textAnchor="middle" fontSize={11} fill="currentColor" fillOpacity={0.4}>{pt.label}</text>
))}
{/* Candles */}
{data.map((pt, i) => {
const cx = xScale(i);
return activeDevices.map((name, di) => {
const c = pt[name] as Candle | undefined;
if (!c || typeof c !== "object") return null;
const off = activeDevices.length > 1 ? (di - (activeDevices.length - 1) / 2) * (candleW + 3) : 0;
const x = cx + off;
const isUp = c.close >= c.open;
const color = isUp ? "#5dd776" : "#ef4444";
const bodyTop = Math.min(yScale(c.open), yScale(c.close));
const bodyH = Math.max(1, Math.abs(yScale(c.close) - yScale(c.open)));
return (
<g key={name}
onMouseEnter={e => {
const r = containerRef.current?.getBoundingClientRect();
if (r) setTooltip({ x: e.clientX - r.left, y: e.clientY - r.top, bucket: pt });
}}
onMouseLeave={() => setTooltip(null)}
>
<line x1={x} y1={yScale(c.high)} x2={x} y2={yScale(c.low)} stroke={color} strokeWidth={1.5} />
<rect x={x - candleW / 2} y={bodyTop} width={candleW} height={bodyH} fill={color} fillOpacity={0.85} />
<rect x={cx - slotW / 2} y={margin.top} width={slotW} height={innerH} fill="transparent" />
</g>
);
});
})}
{/* Drag selection overlay */}
{selecting && selX2 - selX1 > 2 && (
<rect x={selX1} y={margin.top} width={selX2 - selX1} height={innerH}
fill="#428ce2" fillOpacity={0.15} stroke="#428ce2" strokeOpacity={0.5} strokeWidth={1} />
)}
</svg>
{/* Hover tooltip */}
{tooltip && !selecting && (
<div className="pointer-events-none absolute z-10 bg-primary border border-secondary rounded-xl px-3 py-2 text-xs shadow-lg"
style={{ left: Math.min(tooltip.x + 12, width - 160), top: Math.max(0, tooltip.y - 10) }}>
<p className="text-foreground-sec mb-1.5">{tooltip.bucket.label}</p>
{activeDevices.map(name => {
const c = tooltip.bucket[name] as Candle | undefined;
if (!c || typeof c !== "object") return null;
return (
<div key={name} className="flex flex-col gap-0.5 mb-1" style={{ color: DEVICE_COLORS[name] }}>
<span className="font-medium capitalize">{name}</span>
<span className="text-foreground-sec font-mono text-[10px]">
O {c.open.toFixed(1)} H {c.high.toFixed(1)}<br />
L {c.low.toFixed(1)} C {c.close.toFixed(1)} W
</span>
</div>
);
})}
</div>
)}
</div>
);
}
// ── Shared tooltip ────────────────────────────────────────────────────────────
const CustomTooltip = ({ active, payload, label }: any) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-primary border border-secondary rounded-xl px-3 py-2 text-xs shadow-lg">
<p className="text-foreground-sec mb-1">{label}</p>
{payload.map((p: any) => (
<p key={p.name} style={{ color: p.color }} className="font-medium">
{String(p.name).charAt(0).toUpperCase() + String(p.name).slice(1)}: {Number(p.value).toFixed(1)} W
</p>
))}
</div>
);
};
function SummaryCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) {
return (
<div className="bg-primary border border-secondary rounded-2xl p-5 flex flex-col gap-1">
<span className="text-xs font-medium text-foreground-sec">{label}</span>
<span className="text-2xl font-medium tracking-tight text-foreground leading-none mt-1" style={color ? { color } : undefined}>{value}</span>
{sub && <span className="text-[0.7rem] text-foreground-sec mt-0.5">{sub}</span>}
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
const PRESETS = [{ label: "6h", h: 6 }, { label: "24h", h: 24 }, { label: "7d", h: 168 }, { label: "30d", h: 720 }];
export default function AnalyticsPage() {
// Time range
const [presetH, setPresetH] = useState(24);
const [showCustom, setShowCustom] = useState(false);
const [customFrom, setCustomFrom] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 1); return d.toISOString().slice(0, 16); });
const [customTo, setCustomTo] = useState(() => new Date().toISOString().slice(0, 16));
const [appliedRange, setAppliedRange] = useState<{ from: string; to: string } | null>(null);
// Data
const [readings, setReadings] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
// Chart
const [chartType, setChartType] = useState<ChartType>("line");
const [activeDevices, setActiveDevices] = useState<Set<string>>(new Set());
// Zoom
const [yDomain, setYDomain] = useState<[number, number] | null>(null);
const [brushIdx, setBrushIdx] = useState<[number, number] | null>(null); // [start, end] indices into flatData
const [candleBrushIdx, setCandleBrushIdx] = useState<[number, number] | null>(null);
const lineBarRef = useRef<HTMLDivElement>(null);
const yDomainRef = useRef<[number, number] | null>(null);
const visibleRef = useRef<Record<string, number | string>[]>([]);
const activeListRef = useRef<string[]>([]);
yDomainRef.current = yDomain;
const effectiveSpanH = appliedRange
? (new Date(appliedRange.to).getTime() - new Date(appliedRange.from).getTime()) / 3_600_000
: presetH;
const fetchHistory = useCallback(async () => {
setLoading(true);
try {
let hoursToFetch = presetH;
if (appliedRange) {
hoursToFetch = Math.ceil((Date.now() - new Date(appliedRange.from).getTime()) / 3_600_000) + 1;
}
const res = await fetch(`/api/power/history?hours=${Math.min(hoursToFetch, 24 * 60)}`);
if (!res.ok) return;
let r: HistoryEntry[] = (await res.json()).readings ?? [];
if (appliedRange) {
const from = new Date(appliedRange.from).getTime();
const to = new Date(appliedRange.to).getTime();
r = r.filter(e => { const t = new Date(e.ts).getTime(); return t >= from && t <= to; });
}
setReadings(r);
setBrushIdx(null);
setCandleBrushIdx(null);
setYDomain(null);
} finally {
setLoading(false);
}
}, [presetH, appliedRange]);
useEffect(() => { fetchHistory(); }, [fetchHistory]);
const deviceNames = Array.from(new Set(readings.flatMap(r => r.devices.map(d => d.name)))).sort();
useEffect(() => {
setActiveDevices(prev => prev.size > 0 ? prev : new Set(deviceNames));
}, [deviceNames.join(",")]);
const activeList = deviceNames.filter(n => activeDevices.has(n));
activeListRef.current = activeList;
// Flat data for line/bar
const flatData = readings.map(r => {
const pt: Record<string, number | string> = { ts: fmtTs(r.ts, effectiveSpanH) };
for (const d of r.devices) if (activeDevices.has(d.name)) pt[d.name] = Number(d.watts.toFixed(1));
return pt;
});
const visibleFlat = brushIdx ? flatData.slice(brushIdx[0], brushIdx[1] + 1) : flatData;
visibleRef.current = visibleFlat;
// Candle data
const candleData = bucketCandles(readings, effectiveSpanH);
const visibleCandle = candleBrushIdx ? candleData.slice(candleBrushIdx[0], candleBrushIdx[1] + 1) : candleData;
// Y domain: scroll-zoomed > auto from visible data > recharts auto
const effectiveYDomain = yDomain ?? autoYDomain(chartType === "candle" ? [] : visibleFlat, activeList);
// Scroll Y zoom for line/bar
useEffect(() => {
const el = lineBarRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
e.preventDefault();
const [curMin, curMax] = yDomainRef.current ?? autoYDomain(visibleRef.current, activeListRef.current) ?? [0, 500];
const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15;
const rect = el.getBoundingClientRect();
const pct = 1 - (e.clientY - rect.top) / rect.height;
const center = curMin + pct * (curMax - curMin);
const half = (curMax - curMin) / 2 * factor;
setYDomain([Math.max(0, center - half), center + half]);
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, []);
const isZoomed = !!(yDomain || brushIdx || candleBrushIdx);
const resetZoom = () => { setYDomain(null); setBrushIdx(null); setCandleBrushIdx(null); };
const toggleDevice = (name: string) => {
setActiveDevices(prev => {
const next = new Set(prev);
if (next.has(name)) { if (next.size > 1) next.delete(name); } else next.add(name);
return next;
});
};
const summaries = deviceNames.map(name => ({ name, summary: computeSummary(readings, name) }));
const totalKWh = summaries.reduce((s, { summary }) => s + (summary?.kWh ?? 0), 0);
const axisProps = { tick: { fontSize: 11, fill: "currentColor", fillOpacity: 0.4 } as any, tickLine: false, axisLine: false };
// Double-click resets all zoom
const onChartDblClick = () => resetZoom();
return (
<div className="w-full h-full bg-primary text-foreground overflow-hidden flex flex-row">
<SideNav online={false} devConsoleOpen={false} onToggleDevConsole={() => {}} />
<div className="flex-1 overflow-y-auto pt-[52px] lg:pt-0 lg:m-[10px_10px_10px_0px] lg:rounded-2xl lg:border lg:border-blue/20 min-w-0">
<div className="max-w-5xl mx-auto px-3 pb-20 pt-8">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Power Analytics</h1>
<p className="text-sm text-foreground-sec mt-1">
{readings.length} readings · LADWP ${COST_PER_KWH}/kWh
{appliedRange && <span> · {fmtDt(appliedRange.from)} {fmtDt(appliedRange.to)}</span>}
</p>
</div>
{/* Range picker */}
<div className="flex flex-col gap-2 items-end">
<div className="flex gap-1 bg-secondary/50 rounded-xl p-1">
{PRESETS.map(({ label, h }) => (
<button key={h} onClick={() => { setPresetH(h); setAppliedRange(null); setShowCustom(false); }}
className={"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer " +
(!appliedRange && presetH === h ? "bg-blue text-white" : "text-foreground-sec hover:text-foreground")}>
{label}
</button>
))}
<button onClick={() => setShowCustom(s => !s)}
className={"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer " +
(showCustom || appliedRange ? "bg-blue text-white" : "text-foreground-sec hover:text-foreground")}>
Custom
</button>
</div>
{/* Custom date range picker */}
{showCustom && (
<div className="flex flex-wrap items-center gap-2 bg-secondary/40 border border-secondary rounded-xl p-2.5 text-xs">
<div className="flex items-center gap-1.5">
<span className="text-foreground-sec">From</span>
<input type="datetime-local" value={customFrom} onChange={e => setCustomFrom(e.target.value)}
className="bg-secondary/60 border border-secondary rounded-lg px-2 py-1 text-foreground text-xs outline-none focus:border-blue/60" />
</div>
<div className="flex items-center gap-1.5">
<span className="text-foreground-sec">To</span>
<input type="datetime-local" value={customTo} onChange={e => setCustomTo(e.target.value)}
className="bg-secondary/60 border border-secondary rounded-lg px-2 py-1 text-foreground text-xs outline-none focus:border-blue/60" />
</div>
<button
onClick={() => { if (customFrom && customTo) { setAppliedRange({ from: customFrom, to: customTo }); setShowCustom(false); } }}
className="px-3 py-1 bg-blue/10 border border-blue/30 text-blue rounded-lg font-medium hover:bg-blue/20 transition-colors cursor-pointer">
Apply
</button>
</div>
)}
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3.5 mb-8">
<SummaryCard label="Total energy" value={`${totalKWh.toFixed(3)} kWh`} sub={`$${(totalKWh * COST_PER_KWH).toFixed(3)}`} />
{summaries.map(({ name, summary }) => (
<SummaryCard key={name} label={`${name[0].toUpperCase() + name.slice(1)} avg`}
value={summary ? `${summary.avgW.toFixed(1)} W` : "—"}
sub={summary ? `Peak ${summary.peakW.toFixed(1)} W` : undefined}
color={DEVICE_COLORS[name]} />
))}
{summaries.map(({ name, summary }) => (
<SummaryCard key={`${name}-u`} label={`${name[0].toUpperCase() + name.slice(1)} uptime`}
value={summary ? `${summary.uptimePct.toFixed(0)}%` : "—"}
sub={summary ? `${summary.kWh.toFixed(3)} kWh` : undefined} />
))}
</div>
{/* Chart card */}
<div className="bg-primary border border-secondary rounded-2xl p-5 mb-8">
{/* Controls row */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-3 flex-wrap">
<h2 className="text-sm font-medium text-foreground">Power over time</h2>
{deviceNames.map(name => {
const active = activeDevices.has(name);
const color = DEVICE_COLORS[name] ?? "#888";
return (
<button key={name} onClick={() => toggleDevice(name)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer border"
style={{ borderColor: active ? color + "55" : "transparent", background: active ? color + "18" : "transparent", color: active ? color : "var(--color-foreground-sec)" }}>
<span className="w-2 h-2 rounded-full" style={{ background: active ? color : "var(--color-secondary)" }} />
<span className="capitalize">{name}</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2">
{isZoomed && (
<button onClick={resetZoom}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-foreground-sec hover:text-foreground border border-secondary hover:border-secondary/80 transition-colors cursor-pointer">
<IconRefresh size={11} />
Reset zoom
</button>
)}
<div className="flex gap-0.5 bg-secondary/50 rounded-lg p-0.5">
{(["line", "bar", "candle"] as ChartType[]).map(type => (
<button key={type} onClick={() => { setChartType(type); resetZoom(); }}
className={"px-3 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer capitalize " +
(chartType === type ? "bg-primary text-foreground shadow-sm" : "text-foreground-sec hover:text-foreground")}>
{type}
</button>
))}
</div>
</div>
</div>
<p className="text-[11px] text-foreground-sec/50 mb-3">
{chartType === "candle" ? "Drag to zoom X · scroll to zoom Y · double-click to reset" : "Drag brush below chart to zoom X · scroll to zoom Y · double-click to reset"}
</p>
{/* Chart body */}
{loading ? (
<div className="skeleton h-[300px]" />
) : readings.length === 0 ? (
<div className="flex items-center justify-center h-[300px] text-foreground-sec text-sm">
No data yet readings are recorded every 5 minutes.
</div>
) : chartType === "candle" ? (
<CandleChart
data={visibleCandle}
activeDevices={activeList}
yDomain={yDomain}
onYDomainChange={d => setYDomain(d)}
onBrush={(s, e) => {
const base = candleBrushIdx ? candleBrushIdx[0] : 0;
setCandleBrushIdx([base + s, base + e]);
}}
/>
) : (
<div ref={lineBarRef} onDoubleClick={onChartDblClick}>
<ResponsiveContainer width="100%" height={300}>
{chartType === "bar" ? (
<BarChart data={flatData} margin={CHART_MARGIN}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" strokeOpacity={0.08} />
<XAxis dataKey="ts" {...axisProps} interval="preserveStartEnd" />
<YAxis {...axisProps} domain={effectiveYDomain ?? ["auto", "auto"]} tickFormatter={v => `${v}W`} width={50} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} formatter={v => String(v)[0].toUpperCase() + String(v).slice(1)} />
{activeList.map(name => <Bar key={name} dataKey={name} fill={DEVICE_COLORS[name] ?? "#888"} fillOpacity={0.8} radius={[2, 2, 0, 0]} maxBarSize={16} />)}
<Brush dataKey="ts" height={22} stroke="var(--color-secondary)" fill="var(--color-primary)" travellerWidth={6}
onChange={({ startIndex, endIndex }) => {
setBrushIdx([startIndex as number, endIndex as number]);
setYDomain(null);
}} />
</BarChart>
) : (
<LineChart data={flatData} margin={CHART_MARGIN}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" strokeOpacity={0.08} />
<XAxis dataKey="ts" {...axisProps} interval="preserveStartEnd" />
<YAxis {...axisProps} domain={effectiveYDomain ?? ["auto", "auto"]} tickFormatter={v => `${v}W`} width={50} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} formatter={v => String(v)[0].toUpperCase() + String(v).slice(1)} />
{activeList.map(name => (
<Line key={name} type="monotone" dataKey={name} stroke={DEVICE_COLORS[name] ?? "#888"} strokeWidth={1.5} dot={false} activeDot={{ r: 3 }} />
))}
<Brush dataKey="ts" height={22} stroke="var(--color-secondary)" fill="var(--color-primary)" travellerWidth={6}
onChange={({ startIndex, endIndex }) => {
setBrushIdx([startIndex as number, endIndex as number]);
setYDomain(null);
}} />
</LineChart>
)}
</ResponsiveContainer>
</div>
)}
</div>
{/* Device breakdown */}
{summaries.length > 0 && (
<>
<h2 className="text-lg font-medium tracking-tight text-foreground mb-5">Device breakdown</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5">
{summaries.map(({ name, summary }) => {
if (!summary) return null;
const color = DEVICE_COLORS[name] ?? "#888";
return (
<div key={name} className="bg-primary border border-secondary rounded-2xl p-5">
<div className="flex items-center gap-2 mb-4">
<span className="w-2.5 h-2.5 rounded-full" style={{ background: color }} />
<h3 className="text-sm font-medium text-foreground capitalize">{name}</h3>
</div>
<div className="grid grid-cols-3 gap-4">
{[
{ label: "Average", value: `${summary.avgW.toFixed(1)} W` },
{ label: "Peak", value: `${summary.peakW.toFixed(1)} W` },
{ label: "Uptime", value: `${summary.uptimePct.toFixed(1)}%` },
{ label: "Energy", value: `${summary.kWh.toFixed(3)} kWh` },
{ label: "Est. cost", value: `$${summary.cost.toFixed(3)}` },
{ label: "Readings", value: String(readings.filter(r => r.devices.some(d => d.name === name)).length) },
].map(({ label, value }) => (
<div key={label} className="flex flex-col gap-0.5">
<span className="text-xs text-foreground-sec">{label}</span>
<span className="text-sm font-medium text-foreground">{value}</span>
</div>
))}
</div>
</div>
);
})}
</div>
</>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const hours = req.nextUrl.searchParams.get("hours") ?? "24";
const res = await fetch(`http://localhost:3001/power/history?hours=${hours}`);
if (!res.ok) return NextResponse.json({ error: "Upstream error" }, { status: res.status });
return NextResponse.json(await res.json());
}

View file

@ -1,14 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
export async function GET(req: NextRequest) { export async function GET() {
const token = req.cookies.get("token")?.value; const res = await fetch("http://localhost:3001/power");
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const res = await fetch("http://localhost:3001/power", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) { if (!res.ok) {
return NextResponse.json({ error: "Upstream error" }, { status: res.status }); return NextResponse.json({ error: "Upstream error" }, { status: res.status });

View file

@ -1,17 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
export async function GET(req: NextRequest) { export async function GET() {
const token = req.cookies.get("token")?.value; const res = await fetch("http://localhost:3001/stats");
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const res = await fetch("http://localhost:3001/stats", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) { if (!res.ok) {
return NextResponse.json({ error: "Unauthorized" }, { status: res.status }); return NextResponse.json({ error: "Upstream error" }, { status: res.status });
} }
return NextResponse.json(await res.json()); return NextResponse.json(await res.json());

View file

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ username: string; credId: string }> },
) {
const token = req.cookies.get("token")?.value;
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { username, credId } = await params;
const res = await fetch(
`http://localhost:3001/users/${encodeURIComponent(username)}/credentials/${encodeURIComponent(credId)}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) return NextResponse.json({ error: "Upstream error" }, { status: res.status });
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ username: string }> },
) {
const token = req.cookies.get("token")?.value;
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
await params;
const body = await req.json();
const res = await fetch("http://localhost:3001/auth/register/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) return NextResponse.json({ error: "Registration failed" }, { status: 400 });
return NextResponse.json(await res.json());
}

View file

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ username: string }> },
) {
const token = req.cookies.get("token")?.value;
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { username } = await params;
const { password } = await req.json();
const res = await fetch("http://localhost:3001/auth/register/start", {
method: "POST",
headers: {
Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64"),
},
});
if (!res.ok) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
return NextResponse.json(await res.json());
}

13
app/api/users/route.ts Normal file
View file

@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const token = req.cookies.get("token")?.value;
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const res = await fetch("http://localhost:3001/users", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return NextResponse.json({ error: "Upstream error" }, { status: res.status });
return NextResponse.json(await res.json());
}

View file

@ -12,12 +12,12 @@ export default function NetworkCard({ iface, speed, delay = 0 }: NetworkCardProp
className="bg-primary border border-secondary rounded-2xl p-6 animate-fade-up" className="bg-primary border border-secondary rounded-2xl p-6 animate-fade-up"
style={{ animationDelay: `${delay}ms` }} style={{ animationDelay: `${delay}ms` }}
> >
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec mb-4"> <p className="text-xs font-medium text-foreground-sec mb-4">
Network Network
</p> </p>
{iface && speed ? ( {iface && speed ? (
<> <>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec mb-3"> <p className="text-xs font-medium text-foreground-sec mb-3">
{iface} {iface}
</p> </p>
<div className="flex gap-6"> <div className="flex gap-6">
@ -25,7 +25,7 @@ export default function NetworkCard({ iface, speed, delay = 0 }: NetworkCardProp
<span className="text-lg font-medium text-blue"> <span className="text-lg font-medium text-blue">
{formatBytes(speed.rx)}/s {formatBytes(speed.rx)}/s
</span> </span>
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec"> <span className="text-[0.7rem] text-foreground-sec">
Download Download
</span> </span>
</div> </div>
@ -33,7 +33,7 @@ export default function NetworkCard({ iface, speed, delay = 0 }: NetworkCardProp
<span className="text-lg font-medium text-blue/70"> <span className="text-lg font-medium text-blue/70">
{formatBytes(speed.tx)}/s {formatBytes(speed.tx)}/s
</span> </span>
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec"> <span className="text-[0.7rem] text-foreground-sec">
Upload Upload
</span> </span>
</div> </div>

View file

@ -27,13 +27,13 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
style={{ animationDelay: `${delay}ms` }} style={{ animationDelay: `${delay}ms` }}
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec"> <span className="text-xs font-medium text-foreground-sec">
{label} {label}
</span> </span>
{device ? ( {device ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`flex items-center gap-1.5 text-[0.62rem] font-medium uppercase tracking-widest ${ className={`flex items-center gap-1.5 text-[0.7rem] font-medium ${
device.on ? "text-green" : "text-foreground-sec" device.on ? "text-green" : "text-foreground-sec"
}`} }`}
> >
@ -48,7 +48,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
<button <button
onClick={() => onToggle(!device.on)} onClick={() => onToggle(!device.on)}
disabled={toggling} disabled={toggling}
className={`text-[0.6rem] font-medium uppercase tracking-widest px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${ className={`text-[0.7rem] font-medium px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
device.on device.on
? "border-red-500/20 text-red-400 hover:bg-red-500/10" ? "border-red-500/20 text-red-400 hover:bg-red-500/10"
: "border-green/20 text-green hover:bg-green/10" : "border-green/20 text-green hover:bg-green/10"
@ -89,7 +89,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
{(device.today_energy_wh / 1000).toFixed(3)} {(device.today_energy_wh / 1000).toFixed(3)}
<span className="text-foreground-sec text-xs ml-0.5">kWh</span> <span className="text-foreground-sec text-xs ml-0.5">kWh</span>
</span> </span>
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec"> <span className="text-[0.7rem] text-foreground-sec">
Today Today
</span> </span>
</div> </div>
@ -98,7 +98,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
{(device.month_energy_wh / 1000).toFixed(2)} {(device.month_energy_wh / 1000).toFixed(2)}
<span className="text-foreground-sec text-xs ml-0.5">kWh</span> <span className="text-foreground-sec text-xs ml-0.5">kWh</span>
</span> </span>
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec"> <span className="text-[0.7rem] text-foreground-sec">
Month Month
</span> </span>
</div> </div>
@ -106,7 +106,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
{runtimeHours}h {runtimeMins}m {runtimeHours}h {runtimeMins}m
</span> </span>
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec"> <span className="text-[0.7rem] text-foreground-sec">
Runtime Runtime
</span> </span>
</div> </div>

View file

@ -7,9 +7,10 @@ import PowerCard from "./PowerCard";
interface PowerGridProps { interface PowerGridProps {
power: PowerData | null; power: PowerData | null;
onRefresh: () => void; onRefresh: () => void;
showControls?: boolean;
} }
export default function PowerGrid({ power, onRefresh }: PowerGridProps) { export default function PowerGrid({ power, onRefresh, showControls = true }: PowerGridProps) {
const [toggling, setToggling] = useState<string | null>(null); const [toggling, setToggling] = useState<string | null>(null);
const server = power?.devices.find((d) => d.name === "server") ?? null; const server = power?.devices.find((d) => d.name === "server") ?? null;
@ -34,14 +35,14 @@ export default function PowerGrid({ power, onRefresh }: PowerGridProps) {
label="Server" label="Server"
delay={0} delay={0}
toggling={toggling === "server"} toggling={toggling === "server"}
onToggle={(on) => handleToggle("server", on)} onToggle={showControls ? (on) => handleToggle("server", on) : undefined}
/> />
<PowerCard <PowerCard
device={desktop} device={desktop}
label="Desktop" label="Desktop"
delay={60} delay={60}
toggling={toggling === "desktop"} toggling={toggling === "desktop"}
onToggle={(on) => handleToggle("desktop", on)} onToggle={showControls ? (on) => handleToggle("desktop", on) : undefined}
/> />
</div> </div>
); );

View file

@ -4,58 +4,186 @@ import { useState, useRef, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { import {
IconHome2, IconHome2, IconMoon, IconSun, IconChevronsLeft, IconChevronsRight,
IconMoon, IconMenu2, IconX, IconCode, IconKey, IconLogout, IconUsers, IconChartLine,
IconSun, IconChevronDown, IconBolt, IconChartBar, IconChartCandle,
IconChevronsLeft,
IconChevronsRight,
IconMenu2,
IconX,
IconCode,
IconKey,
IconLogout,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useSetTheme } from "@/stores/useThemeStore"; import { useSetTheme } from "@/stores/useThemeStore";
import { useFocusedWindowState, requestViewChange } from "@/stores/windowStore";
const LINKS = [ import { PANEL_SECTIONS, type PanelId } from "@/app/components/windows/types";
{ href: "/", label: "Dashboard", icon: IconHome2 }, import { SideNavWidgets } from "./SideNavWidgets";
{ href: "/auth", label: "Auth", icon: IconKey },
];
const COLLAPSED_W = 52; const COLLAPSED_W = 52;
const SECTION_ICONS: Record<string, React.ElementType> = {
"power-analytics": IconBolt,
};
const ANALYTICS_ICONS: Record<PanelId, React.ElementType> = {
dashboard: IconHome2,
"analytics-line": IconChartLine,
"analytics-bar": IconChartBar,
"analytics-candle": IconChartCandle,
};
interface SideNavProps { interface SideNavProps {
online: boolean; online: boolean;
devConsoleOpen: boolean; devConsoleOpen: boolean;
onToggleDevConsole: () => void; onToggleDevConsole: () => void;
isAuthed?: boolean;
} }
const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) => { // ── Window nav (only shown on /) ──────────────────────────────────────────────
function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedPanelId: PanelId | null }) {
const [openSections, setOpenSections] = useState<Set<string>>(
new Set(PANEL_SECTIONS.map((s) => s.id))
);
const toggleSection = (id: string) => {
setOpenSections((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
if (collapsed) {
return (
<div className="flex flex-col gap-[2px] px-[8px] mt-1">
{/* Dashboard icon */}
<button
onClick={() => requestViewChange("dashboard")}
title="Dashboard"
className={[
"w-full flex items-center justify-center py-[7px] rounded-[8px] transition-colors cursor-pointer",
focusedPanelId === "dashboard"
? "bg-blue/10 text-blue"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground",
].join(" ")}
>
<IconHome2 size={15} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
</button>
{/* Section items */}
{PANEL_SECTIONS.flatMap((s) =>
s.items.map(({ panelId, label }) => {
const Icon = ANALYTICS_ICONS[panelId];
const active = focusedPanelId === panelId;
return (
<button
key={panelId}
onClick={() => requestViewChange(panelId)}
title={label}
className={[
"w-full flex items-center justify-center py-[7px] rounded-[8px] transition-colors cursor-pointer",
active
? "bg-blue/10 text-blue"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground",
].join(" ")}
>
<Icon size={15} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
</button>
);
})
)}
</div>
);
}
return (
<div className="flex flex-col gap-[1px] px-[8px] mt-1">
{/* Dashboard standalone */}
<button
onClick={() => requestViewChange("dashboard")}
className={[
"w-full flex items-center gap-[8px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer font-medium",
focusedPanelId === "dashboard"
? "bg-blue/10 text-blue font-semibold"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground",
].join(" ")}
>
<IconHome2 size={14} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
Dashboard
</button>
{/* Power Analytics section */}
{PANEL_SECTIONS.map((section) => {
const SectionIcon = SECTION_ICONS[section.id] ?? IconBolt;
const isOpen = openSections.has(section.id);
return (
<div key={section.id}>
<button
onClick={() => toggleSection(section.id)}
className="w-full flex items-center gap-[8px] px-[10px] py-[5px] rounded-[8px] text-[11px] font-semibold text-foreground-sec hover:bg-secondary/40 hover:text-foreground transition-colors cursor-pointer select-none"
>
<SectionIcon size={13} className="shrink-0" />
<span className="flex-1 text-left">{section.label}</span>
<IconChevronDown
size={11}
className="shrink-0 transition-transform duration-200"
style={{ transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)" }}
/>
</button>
{isOpen && (
<div className="flex flex-col gap-[1px] pl-[6px]">
{section.items.map(({ panelId, label }) => {
const Icon = ANALYTICS_ICONS[panelId];
const active = focusedPanelId === panelId;
return (
<button
key={panelId}
onClick={() => requestViewChange(panelId)}
className={[
"w-full flex items-center gap-[8px] px-[10px] py-[6px] text-[12px] rounded-[8px] transition-colors cursor-pointer",
active
? "bg-blue/10 text-blue font-semibold"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium",
].join(" ")}
>
<Icon size={13} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
{label}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
);
}
// ── Desktop sidebar ───────────────────────────────────────────────────────────
const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideNavProps) => {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const setTheme = useSetTheme(); const setTheme = useSetTheme();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(168); const [sidebarWidth, setSidebarWidth] = useState(220);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [auth, setAuth] = useState(false); const [auth, setAuth] = useState<boolean | null>(isAuthed ?? null);
const isDragging = useRef(false); const isDragging = useRef(false);
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => { const { panelId: focusedPanelId } = useFocusedWindowState();
setMenuOpen(false); const isHome = pathname === "/";
}, [pathname]);
useEffect(() => { setMenuOpen(false); }, [pathname]);
useEffect(() => { useEffect(() => {
fetch("/api/auth/check") if (isAuthed !== undefined) return;
.then((r) => setAuth(r.ok)) fetch("/api/auth/check").then((r) => setAuth(r.ok)).catch(() => setAuth(false));
.catch(() => setAuth(false)); }, [isAuthed]);
}, []);
useEffect(() => { useEffect(() => {
const onMove = (e: MouseEvent) => { const onMove = (e: MouseEvent) => {
if (!isDragging.current || !wrapperRef.current) return; if (!isDragging.current || !wrapperRef.current) return;
const left = wrapperRef.current.getBoundingClientRect().left; const left = wrapperRef.current.getBoundingClientRect().left;
setSidebarWidth(Math.max(120, Math.min(320, e.clientX - left))); setSidebarWidth(Math.max(180, Math.min(400, e.clientX - left)));
}; };
const onUp = () => { isDragging.current = false; }; const onUp = () => { isDragging.current = false; };
window.addEventListener("mousemove", onMove); window.addEventListener("mousemove", onMove);
@ -71,100 +199,105 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) =
router.push("/auth"); router.push("/auth");
} }
const navItemClass = (active: boolean, col: boolean) =>
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer " +
(col
? "justify-center py-[7px] "
: "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") +
(active
? "bg-blue/10 text-blue font-semibold"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium");
return ( return (
<> <>
{/* Desktop sidebar + drag handle */} {/* Desktop sidebar */}
<div ref={wrapperRef} className="hidden lg:flex flex-row shrink-0 select-none"> <div ref={wrapperRef} className="hidden lg:flex flex-row shrink-0 select-none">
<div <div
style={{ width: collapsed ? COLLAPSED_W : sidebarWidth }} style={{ width: collapsed ? COLLAPSED_W : sidebarWidth, minWidth: collapsed ? COLLAPSED_W : 180 }}
className="flex flex-col py-[16px] overflow-hidden transition-[width] duration-200" className="flex flex-col py-[16px] overflow-hidden transition-[width] duration-200"
> >
{/* Logo */} {/* Logo */}
<div className={collapsed ? "flex justify-center mb-[16px] shrink-0" : "px-[16px] mb-[24px] shrink-0"}> <div className={collapsed ? "flex justify-center mb-[12px] shrink-0" : "px-[16px] mb-[16px] shrink-0"}>
<Link href="/"> <Link href="/">
<img <img src="/logo.svg" alt="logo" className={collapsed ? "max-h-[24px]" : "max-h-[36px]"} />
src="/logo.svg"
alt="logo"
className={collapsed ? "max-h-[24px]" : "max-h-[36px]"}
/>
</Link> </Link>
</div> </div>
{/* Nav links */} {/* Auth/users route links */}
<nav className="flex flex-col gap-[2px] px-[8px] flex-1"> <nav className="flex flex-col gap-[2px] px-[8px] shrink-0">
{LINKS.map(({ href, label, icon: Icon }) => { {auth === false && (() => {
const active = pathname === href || (href !== "/" && pathname?.startsWith(href)); const active = pathname === "/auth";
return ( return (
<Link <Link href="/auth" title={collapsed ? "Auth" : undefined} className={navItemClass(active, collapsed)}>
key={href} <IconKey size={16} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
href={href} {!collapsed && "Auth"}
title={collapsed ? label : undefined}
className={
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer " +
(collapsed
? "justify-center py-[7px] "
: "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") +
(active
? "bg-blue/10 text-blue font-semibold"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")
}
>
<Icon size={16} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
{!collapsed && label}
</Link> </Link>
); );
})} })()}
{auth && (() => {
const active = pathname === "/users";
return (
<Link href="/users" title={collapsed ? "User Management" : undefined} className={navItemClass(active, collapsed)}>
<IconUsers size={16} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
{!collapsed && "User Management"}
</Link>
);
})()}
</nav> </nav>
{/* Window sections — only on home route */}
{isHome && (
<>
{(auth !== null) && <div className="mx-[8px] my-[8px] border-t border-secondary shrink-0" />}
{!collapsed && (
<p className="px-[18px] mb-[4px] text-[10px] font-semibold text-foreground-sec/60 uppercase tracking-wider shrink-0">
Views
</p>
)}
<div className="flex-none overflow-y-auto">
<WindowNav collapsed={collapsed} focusedPanelId={focusedPanelId} />
</div>
</>
)}
{/* Widgets */}
{!collapsed && (
<>
<div className="mx-[8px] my-[8px] border-t border-secondary shrink-0" />
<SideNavWidgets />
</>
)}
{/* Online status */} {/* Online status */}
<div className="px-[8px] mb-[2px] shrink-0"> <div className="px-[8px] mb-[2px] shrink-0 mt-auto">
<div <div title={collapsed ? (online ? "Online" : "Connecting...") : undefined}
title={collapsed ? (online ? "Online" : "Connecting...") : undefined} className={"w-full flex items-center rounded-[8px] font-medium text-foreground-sec " +
className={ (collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
"w-full flex items-center rounded-[8px] font-medium text-foreground-sec " + <span className="w-[7px] h-[7px] rounded-full shrink-0"
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap") style={{ background: online ? "#5dd776" : "#7b899a", animation: online ? "pulse-dot 2s infinite" : "none" }} />
}
>
<span
className="w-[7px] h-[7px] rounded-full shrink-0"
style={{
background: online ? "#5dd776" : "#7b899a",
animation: online ? "pulse-dot 2s infinite" : "none",
}}
/>
{!collapsed && (online ? "Online" : "Connecting...")} {!collapsed && (online ? "Online" : "Connecting...")}
</div> </div>
</div> </div>
{/* Dev console toggle */} {/* Dev console */}
<div className="px-[8px] shrink-0"> {auth && (
<button <div className="px-[8px] shrink-0">
onClick={onToggleDevConsole} <button onClick={onToggleDevConsole} title={collapsed ? "Dev Console" : undefined}
title={collapsed ? "Dev Console" : undefined} className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium " +
className={ (collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") +
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium " + (devConsoleOpen ? "bg-blue/10 text-blue" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground")}>
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") + <IconCode size={16} className="shrink-0" />
(devConsoleOpen {!collapsed && "Dev Console"}
? "bg-blue/10 text-blue" </button>
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground") </div>
} )}
>
<IconCode size={16} className="shrink-0" />
{!collapsed && "Dev Console"}
</button>
</div>
{/* Logout */} {/* Logout */}
{auth && ( {auth && (
<div className="px-[8px] shrink-0"> <div className="px-[8px] shrink-0">
<button <button onClick={handleLogout} title={collapsed ? "Log out" : undefined}
onClick={handleLogout} className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium text-red-400 hover:bg-red-500/10 " +
title={collapsed ? "Log out" : undefined} (collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
className={
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium text-red-400 hover:bg-red-500/10 " +
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")
}
>
<IconLogout size={16} className="shrink-0" /> <IconLogout size={16} className="shrink-0" />
{!collapsed && "Log out"} {!collapsed && "Log out"}
</button> </button>
@ -173,36 +306,22 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) =
{/* Theme toggle */} {/* Theme toggle */}
<div className="px-[8px] mt-[4px] shrink-0"> <div className="px-[8px] mt-[4px] shrink-0">
<button <button onClick={setTheme} title={collapsed ? "Toggle theme" : undefined}
onClick={setTheme} className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " +
title={collapsed ? "Toggle theme" : undefined} (collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
className={
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " +
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")
}
>
<IconMoon size={16} className="shrink-0 dark-theme:hidden" /> <IconMoon size={16} className="shrink-0 dark-theme:hidden" />
<IconSun size={16} className="shrink-0 hidden dark-theme:block" /> <IconSun size={16} className="shrink-0 hidden dark-theme:block" />
{!collapsed && ( {!collapsed && <><span className="dark-theme:hidden">Dark mode</span><span className="hidden dark-theme:block">Light mode</span></>}
<>
<span className="dark-theme:hidden">Dark mode</span>
<span className="hidden dark-theme:block">Light mode</span>
</>
)}
</button> </button>
</div> </div>
{/* Divider + collapse toggle */} {/* Divider + collapse */}
<div className="mx-[8px] my-[8px] border-t border-secondary shrink-0" /> <div className="mx-[8px] my-[8px] border-t border-secondary shrink-0" />
<div className="px-[8px] shrink-0"> <div className="px-[8px] shrink-0">
<button <button onClick={() => setCollapsed((c) => !c)}
onClick={() => setCollapsed((c) => !c)}
title={collapsed ? "Expand sidebar" : "Collapse sidebar"} title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
className={ className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " +
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " + (collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")
}
>
{collapsed {collapsed
? <IconChevronsRight size={16} className="shrink-0" /> ? <IconChevronsRight size={16} className="shrink-0" />
: <IconChevronsLeft size={16} className="shrink-0" />} : <IconChevronsLeft size={16} className="shrink-0" />}
@ -213,10 +332,8 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) =
{/* Drag handle */} {/* Drag handle */}
{!collapsed && ( {!collapsed && (
<div <div onMouseDown={(e) => { isDragging.current = true; e.preventDefault(); }}
onMouseDown={(e) => { isDragging.current = true; e.preventDefault(); }} className="w-[10px] shrink-0 flex items-center justify-center cursor-col-resize group">
className="w-[10px] shrink-0 flex items-center justify-center cursor-col-resize group"
>
<div className="w-[3px] h-[40px] rounded-full bg-blue/20 transition-colors" /> <div className="w-[3px] h-[40px] rounded-full bg-blue/20 transition-colors" />
</div> </div>
)} )}
@ -227,63 +344,96 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) =
<Link href="/" onClick={() => setMenuOpen(false)}> <Link href="/" onClick={() => setMenuOpen(false)}>
<img src="/logo.svg" alt="logo" className="max-h-[22px]" /> <img src="/logo.svg" alt="logo" className="max-h-[22px]" />
</Link> </Link>
<button <button onClick={() => setMenuOpen((o) => !o)}
onClick={() => setMenuOpen((o) => !o)} className="ml-auto p-[7px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer">
className="ml-auto p-[7px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer"
>
{menuOpen ? <IconX size={18} /> : <IconMenu2 size={18} />} {menuOpen ? <IconX size={18} /> : <IconMenu2 size={18} />}
</button> </button>
</div> </div>
{/* Mobile dropdown menu */} {/* Mobile dropdown menu */}
{menuOpen && ( {menuOpen && (
<div className="lg:hidden fixed top-[52px] left-0 right-0 z-[997] bg-primary border-b border-secondary shadow-xl"> <div className="lg:hidden fixed top-[52px] left-0 right-0 z-[997] bg-primary border-b border-secondary shadow-xl max-h-[80vh] overflow-y-auto">
<nav className="flex flex-col gap-[2px] p-[8px]"> {auth === false && (
{LINKS.map(({ href, label, icon: Icon }) => { <nav className="flex flex-col gap-[2px] p-[8px]">
const active = pathname === href || (href !== "/" && pathname?.startsWith(href)); <Link href="/auth" onClick={() => setMenuOpen(false)}
return ( className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
<Link (pathname === "/auth" ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
key={href} <IconKey size={16} strokeWidth={pathname === "/auth" ? 2.5 : 2} className="shrink-0" />
href={href} Auth
onClick={() => setMenuOpen(false)} </Link>
className={ </nav>
"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " + )}
(active {auth && (
? "bg-blue/10 text-blue font-semibold" <nav className="flex flex-col gap-[2px] p-[8px]">
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium") <Link href="/users" onClick={() => setMenuOpen(false)}
} className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
> (pathname === "/users" ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
<Icon size={16} strokeWidth={active ? 2.5 : 2} className="shrink-0" /> <IconUsers size={16} strokeWidth={pathname === "/users" ? 2.5 : 2} className="shrink-0" />
{label} User Management
</Link> </Link>
); </nav>
})} )}
</nav>
{/* Mobile window sections */}
{isHome && (
<>
<div className="mx-[8px] border-t border-secondary" />
<div className="p-[8px]">
<p className="px-[10px] mb-[4px] text-[10px] font-semibold text-foreground-sec/60 uppercase tracking-wider">
Views
</p>
{/* Dashboard */}
<button
onClick={() => { requestViewChange("dashboard"); setMenuOpen(false); }}
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
(focusedPanelId === "dashboard" ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
<IconHome2 size={15} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
Dashboard
</button>
{/* Sections */}
{PANEL_SECTIONS.map((section) => (
<div key={section.id} className="mb-1 mt-2">
<p className="px-[10px] py-[4px] text-[10px] font-semibold text-foreground-sec uppercase tracking-wider">
{section.label}
</p>
{section.items.map(({ panelId, label }) => {
const Icon = ANALYTICS_ICONS[panelId];
const active = focusedPanelId === panelId;
return (
<button key={panelId}
onClick={() => { requestViewChange(panelId); setMenuOpen(false); }}
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
(active ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
<Icon size={15} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
{label}
</button>
);
})}
</div>
))}
</div>
</>
)}
<div className="mx-[8px] border-t border-secondary" /> <div className="mx-[8px] border-t border-secondary" />
<div className="p-[8px] flex flex-col gap-[2px]"> <div className="p-[8px] flex flex-col gap-[2px]">
<button
onClick={() => { onToggleDevConsole(); setMenuOpen(false); }}
className={
"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
(devConsoleOpen ? "bg-blue/10 text-blue font-medium" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")
}
>
<IconCode size={16} className="shrink-0" />
Dev Console
</button>
{auth && ( {auth && (
<button <button onClick={() => { onToggleDevConsole(); setMenuOpen(false); }}
onClick={handleLogout} className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer font-medium" (devConsoleOpen ? "bg-blue/10 text-blue font-medium" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
> <IconCode size={16} className="shrink-0" />
Dev Console
</button>
)}
{auth && (
<button onClick={handleLogout}
className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer font-medium">
<IconLogout size={16} className="shrink-0" /> <IconLogout size={16} className="shrink-0" />
Log out Log out
</button> </button>
)} )}
<button <button onClick={setTheme}
onClick={setTheme} className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer font-medium">
className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer font-medium"
>
<IconMoon size={16} className="shrink-0 dark-theme:hidden" /> <IconMoon size={16} className="shrink-0 dark-theme:hidden" />
<IconSun size={16} className="shrink-0 hidden dark-theme:block" /> <IconSun size={16} className="shrink-0 hidden dark-theme:block" />
<span className="dark-theme:hidden">Dark mode</span> <span className="dark-theme:hidden">Dark mode</span>

View file

@ -0,0 +1,234 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { IconLayoutGrid } from "@tabler/icons-react";
import { type Stats, type NetworkInterface } from "../lib/getStats";
import { type PowerData } from "../lib/getPower";
import { formatBytes, statColor } from "../lib/utils";
export type WidgetId =
| "cpu" | "memory" | "disk" | "temp"
| "power-server" | "power-desktop"
| "network" | "uptime" | "empty";
const WIDGET_OPTIONS: { id: WidgetId; label: string }[] = [
{ id: "empty", label: "Empty" },
{ id: "cpu", label: "CPU" },
{ id: "memory", label: "Memory" },
{ id: "disk", label: "Disk" },
{ id: "temp", label: "Temp" },
{ id: "power-server", label: "Server Power" },
{ id: "power-desktop", label: "Desktop Power" },
{ id: "network", label: "Network" },
{ id: "uptime", label: "Uptime" },
];
const DEFAULT_WIDGETS: WidgetId[] = ["power-server", "power-desktop", "cpu", "memory"];
const STORAGE_KEY = "sidenav-widgets";
const card = "flex flex-col gap-2 p-3 bg-secondary/30 border border-secondary rounded-xl";
function MiniStatWidget({ label, value, percent }: { label: string; value: string; percent?: number }) {
const color = statColor(percent ?? 0);
return (
<div className={card}>
<span className="text-[11px] text-foreground-sec">{label}</span>
<span className="text-sm font-medium text-foreground leading-none">{value}</span>
{percent !== undefined && (
<div className="h-[3px] bg-secondary rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all duration-700" style={{ width: `${percent}%`, background: color }} />
</div>
)}
</div>
);
}
function MiniPowerWidget({ label, device }: { label: string; device: { on: boolean; current_power_w: number } | null }) {
return (
<div className={card}>
<div className="flex items-center justify-between">
<span className="text-[11px] text-foreground-sec truncate">{label}</span>
{device && (
<span className="w-[6px] h-[6px] rounded-full shrink-0 ml-1" style={{ background: device.on ? "#5dd776" : "rgba(125,140,155,0.3)" }} />
)}
</div>
<span className="text-sm font-medium text-foreground leading-none">
{device ? `${device.current_power_w.toFixed(1)} W` : "—"}
</span>
<span className="text-[11px] text-foreground-sec leading-none">
{device ? (device.on ? "On" : "Off") : "—"}
</span>
</div>
);
}
function MiniNetworkWidget({ speed }: { speed: { rx: number; tx: number } | null }) {
return (
<div className={card}>
<span className="text-[11px] text-foreground-sec">Network</span>
{speed ? (
<>
<span className="text-[11px] font-medium text-blue leading-none truncate"> {formatBytes(speed.rx)}/s</span>
<span className="text-[11px] font-medium text-blue/70 leading-none truncate"> {formatBytes(speed.tx)}/s</span>
</>
) : (
<span className="text-sm font-medium text-foreground-sec"></span>
)}
</div>
);
}
function MiniUptimeWidget({ uptime }: { uptime: { days: number; hours: number; minutes: number } | null }) {
return (
<div className={card}>
<span className="text-[11px] text-foreground-sec">Uptime</span>
{uptime ? (
<>
<span className="text-sm font-medium text-foreground leading-none">{uptime.days}d {uptime.hours}h</span>
<span className="text-[11px] text-foreground-sec leading-none">{uptime.minutes}m</span>
</>
) : (
<span className="text-sm font-medium text-foreground"></span>
)}
</div>
);
}
function WidgetSlot({
id, stats, power, netSpeed, editing, onChange,
}: {
id: WidgetId;
stats: Stats | null;
power: PowerData | null;
netSpeed: { rx: number; tx: number } | null;
editing: boolean;
onChange: (id: WidgetId) => void;
}) {
if (editing) {
return (
<select
value={id}
onChange={(e) => onChange(e.target.value as WidgetId)}
className="text-[11px] bg-secondary border border-secondary rounded-xl px-2 py-2 text-foreground w-full cursor-pointer"
>
{WIDGET_OPTIONS.map((o) => (
<option key={o.id} value={o.id}>{o.label}</option>
))}
</select>
);
}
if (id === "empty") return <div className="rounded-xl border border-secondary/40 border-dashed" />;
if (id === "cpu") return <MiniStatWidget label="CPU" value={stats ? `${stats.cpu.percent.toFixed(1)}%` : "—"} percent={stats?.cpu.percent} />;
if (id === "memory") return <MiniStatWidget label="Memory" value={stats ? `${stats.memory.percent}%` : "—"} percent={stats?.memory.percent} />;
if (id === "disk") return <MiniStatWidget label="Disk" value={stats ? `${stats.disk.percent}%` : "—"} percent={stats?.disk.percent} />;
if (id === "temp") return <MiniStatWidget label="Temp" value={stats?.temperature != null ? `${stats.temperature}°C` : "—"} />;
if (id === "power-server") return <MiniPowerWidget label="Server" device={power?.devices.find((d) => d.name === "server") ?? null} />;
if (id === "power-desktop") return <MiniPowerWidget label="Desktop" device={power?.devices.find((d) => d.name === "desktop") ?? null} />;
if (id === "network") return <MiniNetworkWidget speed={netSpeed} />;
if (id === "uptime") return <MiniUptimeWidget uptime={stats?.uptime ?? null} />;
return null;
}
export function SideNavWidgets() {
const [widgets, setWidgets] = useState<WidgetId[]>(DEFAULT_WIDGETS);
const [editing, setEditing] = useState(false);
const [mounted, setMounted] = useState(false);
const [stats, setStats] = useState<Stats | null>(null);
const [power, setPower] = useState<PowerData | null>(null);
const [netSpeed, setNetSpeed] = useState<{ rx: number; tx: number } | null>(null);
const prevNetRef = useRef<Record<string, NetworkInterface> | null>(null);
const lastFetchRef = useRef<number>(0);
useEffect(() => {
setMounted(true);
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) setWidgets(JSON.parse(saved));
} catch {}
}, []);
useEffect(() => {
const fetchStats = async () => {
try {
const now = Date.now();
const res = await fetch("/api/stats");
if (!res.ok) return;
const data: Stats = await res.json();
if (prevNetRef.current && lastFetchRef.current > 0) {
const elapsed = (now - lastFetchRef.current) / 1000;
const primary = Object.keys(data.network).find(
(k) => !k.startsWith("docker") && !k.startsWith("br-") && data.network[k].rx > 0,
);
if (primary && prevNetRef.current[primary]) {
setNetSpeed({
rx: Math.max(0, (data.network[primary].rx - prevNetRef.current[primary].rx) / elapsed),
tx: Math.max(0, (data.network[primary].tx - prevNetRef.current[primary].tx) / elapsed),
});
}
}
prevNetRef.current = data.network;
lastFetchRef.current = now;
setStats(data);
} catch {}
};
fetchStats();
const id = setInterval(fetchStats, 4000);
return () => clearInterval(id);
}, []);
useEffect(() => {
const fetchPower = async () => {
try {
const res = await fetch("/api/power");
if (!res.ok) return;
setPower(await res.json());
} catch {}
};
fetchPower();
const id = setInterval(fetchPower, 3000);
return () => clearInterval(id);
}, []);
function updateWidget(index: number, id: WidgetId) {
setWidgets((prev) => {
const next = [...prev] as WidgetId[];
next[index] = id;
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
return next;
});
}
if (!mounted) return null;
return (
<div className="px-[8px] mb-[4px] shrink-0">
<div className="flex items-center justify-between mb-2 px-[2px]">
<span className="text-xs text-foreground-sec font-medium">Widgets</span>
<button
onClick={() => setEditing((e) => !e)}
title={editing ? "Done" : "Customize widgets"}
className={`p-1 rounded-md transition-colors cursor-pointer ${editing ? "text-blue bg-blue/10" : "text-foreground-sec hover:text-foreground hover:bg-secondary/50"}`}
>
<IconLayoutGrid size={13} />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{widgets.map((id, i) => (
<WidgetSlot
key={i}
id={id}
stats={stats}
power={power}
netSpeed={netSpeed}
editing={editing}
onChange={(newId) => updateWidget(i, newId)}
/>
))}
</div>
</div>
);
}

View file

@ -17,7 +17,7 @@ export default function StatCard({ label, value, sub, percent, delay = 0 }: Stat
className="bg-primary border border-secondary rounded-2xl p-5 flex flex-col gap-1 hover:-translate-y-0.5 transition-all duration-200 animate-fade-up" className="bg-primary border border-secondary rounded-2xl p-5 flex flex-col gap-1 hover:-translate-y-0.5 transition-all duration-200 animate-fade-up"
style={{ animationDelay: `${delay}ms` }} style={{ animationDelay: `${delay}ms` }}
> >
<span className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec"> <span className="text-xs font-medium text-foreground-sec">
{label} {label}
</span> </span>
<span className="text-3xl font-medium tracking-tight text-foreground leading-none mt-1"> <span className="text-3xl font-medium tracking-tight text-foreground leading-none mt-1">

View file

@ -12,7 +12,7 @@ export default function UptimeCard({ uptime, delay = 0 }: UptimeCardProps) {
className="bg-primary border border-secondary rounded-2xl p-6 animate-fade-up" className="bg-primary border border-secondary rounded-2xl p-6 animate-fade-up"
style={{ animationDelay: `${delay}ms` }} style={{ animationDelay: `${delay}ms` }}
> >
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec mb-4"> <p className="text-xs font-medium text-foreground-sec mb-4">
Uptime Uptime
</p> </p>
{uptime ? ( {uptime ? (
@ -31,7 +31,7 @@ export default function UptimeCard({ uptime, delay = 0 }: UptimeCardProps) {
<span className="text-3xl font-medium tracking-tight text-foreground leading-none"> <span className="text-3xl font-medium tracking-tight text-foreground leading-none">
{pad(val)} {pad(val)}
</span> </span>
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec mt-1.5"> <span className="text-[0.7rem] text-foreground-sec mt-1.5">
{unit} {unit}
</span> </span>
</div> </div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,362 @@
"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>
);
}

View file

@ -0,0 +1,11 @@
"use client";
import LinksGrid from "../LinksGrid";
export default function LinksPanel() {
return (
<div className="p-4">
<LinksGrid />
</div>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { getStats, type Stats, type NetworkInterface } from "../../lib/getStats";
import NetworkCard from "../NetworkCard";
export default function NetworkPanel() {
const [iface, setIface] = useState<string | null>(null);
const [speed, setSpeed] = useState<{ rx: number; tx: number } | null>(null);
const prevRef = useRef<Record<string, NetworkInterface> | null>(null);
const lastRef = useRef<number>(0);
useEffect(() => {
const go = async () => {
try {
const now = Date.now();
const data = await getStats();
const primary = Object.keys(data.network).find(
(k) => !k.startsWith("docker") && !k.startsWith("br-") && data.network[k].rx > 0,
);
setIface(primary ?? null);
if (primary && prevRef.current?.[primary] && lastRef.current > 0) {
const elapsed = (now - lastRef.current) / 1000;
const prev = prevRef.current[primary];
setSpeed({
rx: Math.max(0, (data.network[primary].rx - prev.rx) / elapsed),
tx: Math.max(0, (data.network[primary].tx - prev.tx) / elapsed),
});
}
prevRef.current = data.network;
lastRef.current = now;
} catch {}
};
go();
const id = setInterval(go, 4000);
return () => clearInterval(id);
}, []);
return (
<div className="p-4">
<NetworkCard iface={iface} speed={speed} />
</div>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { getStats, type Stats, type NetworkInterface } from "../../lib/getStats";
import StatsGrid from "../StatsGrid";
export default function OverviewPanel() {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
const go = async () => {
try { setStats(await getStats()); } catch {}
};
go();
const id = setInterval(go, 4000);
return () => clearInterval(id);
}, []);
return (
<div className="p-4 pb-2">
<StatsGrid stats={stats} />
</div>
);
}

View file

@ -0,0 +1,25 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getPower, type PowerData } from "../../lib/getPower";
import PowerGrid from "../PowerGrid";
export default function PowerPanel({ isAuthed }: { isAuthed: boolean }) {
const [power, setPower] = useState<PowerData | null>(null);
const fetchPower = useCallback(async () => {
try { setPower(await getPower()); } catch {}
}, []);
useEffect(() => {
fetchPower();
const id = setInterval(fetchPower, 3000);
return () => clearInterval(id);
}, [fetchPower]);
return (
<div className="p-4">
<PowerGrid power={power} onRefresh={fetchPower} showControls={isAuthed} />
</div>
);
}

View file

@ -0,0 +1,33 @@
"use client";
import { useState, useEffect } from "react";
import { getStats, type Stats } from "../../lib/getStats";
import ServicesCard from "../ServicesCard";
export default function ServicesPanel({ isAuthed }: { isAuthed: boolean }) {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
if (!isAuthed) return;
const go = async () => {
try { setStats(await getStats()); } catch {}
};
go();
const id = setInterval(go, 4000);
return () => clearInterval(id);
}, [isAuthed]);
if (!isAuthed) {
return (
<div className="p-4 text-sm text-foreground-sec">
Authentication required to view services.
</div>
);
}
return (
<div className="p-4">
<ServicesCard services={stats?.services ?? null} />
</div>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import { useState, useEffect } from "react";
import { getStats, type Stats } from "../../lib/getStats";
import UptimeCard from "../UptimeCard";
export default function UptimePanel() {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
const go = async () => {
try { setStats(await getStats()); } catch {}
};
go();
const id = setInterval(go, 4000);
return () => clearInterval(id);
}, []);
return (
<div className="p-4">
<UptimeCard uptime={stats?.uptime ?? null} />
</div>
);
}

View file

@ -0,0 +1,281 @@
"use client";
import React, { useState, useCallback, useRef, useEffect, createContext, useContext, memo } from "react";
import { TileNode, LeafNode, ContainerNode, PanelId } from "./types";
import {
splitLeaf, closeLeaf, updatePanelId, patchSizes,
getFirstLeafId, countLeaves, getLeafPanel,
} from "./treeUtils";
import { setFocusedContext, subscribeViewChange } from "@/stores/windowStore";
import WindowPane from "./WindowPane";
// ── Persistence ───────────────────────────────────────────────────────────────
const STORAGE_KEY = "wm-layout-v3";
const DEFAULT_TREE: TileNode = { type: "leaf", id: "root", panelId: "dashboard" };
function persist(t: TileNode) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(t)); } catch {}
}
// ── Internal context ──────────────────────────────────────────────────────────
interface WMCtx {
focusedId: string | null;
totalPanes: number;
isAuthed: boolean;
onFocus: (paneId: string, panelId: PanelId) => void;
onSplit: (leafId: string, dir: "h" | "v", newFirst: boolean, panelId: PanelId) => void;
onClose: (leafId: string) => void;
onResizeContainer: (containerId: string, sizes: number[]) => void;
}
const WMContext = createContext<WMCtx>(null!);
const useWM = () => useContext(WMContext);
// ── Resize handle ─────────────────────────────────────────────────────────────
interface ResizeHandleProps {
containerId: string;
index: number;
dir: "h" | "v";
sizes: number[];
containerRef: React.RefObject<HTMLDivElement | null>;
}
function ResizeHandle({ containerId, index, dir, sizes, containerRef }: ResizeHandleProps) {
const { onResizeContainer } = useWM();
const isCol = dir === "h";
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const totalPx = isCol ? rect.width : rect.height;
const totalRatio = sizes.reduce((a, b) => a + b, 0);
const startPos = isCol ? e.clientX : e.clientY;
const startSizes = [...sizes];
const onMove = (mv: MouseEvent) => {
const delta = (isCol ? mv.clientX : mv.clientY) - startPos;
const deltaRatio = (delta / totalPx) * totalRatio;
const minRatio = totalRatio * 0.08;
const next = [...startSizes];
next[index] = Math.max(minRatio, startSizes[index] + deltaRatio);
next[index + 1] = Math.max(minRatio, startSizes[index + 1] - deltaRatio);
if (next[index] < minRatio) {
next[index] = minRatio;
next[index + 1] = startSizes[index] + startSizes[index + 1] - minRatio;
} else if (next[index + 1] < minRatio) {
next[index + 1] = minRatio;
next[index] = startSizes[index] + startSizes[index + 1] - minRatio;
}
onResizeContainer(containerId, next);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.body.style.cursor = isCol ? "col-resize" : "row-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}, [containerId, index, dir, sizes, containerRef, onResizeContainer, isCol]);
return (
<div
className={[
"group/div shrink-0 flex items-center justify-center z-10",
"hover:bg-blue/5 transition-colors",
isCol ? "w-[6px] cursor-col-resize" : "h-[6px] cursor-row-resize",
].join(" ")}
onMouseDown={handleMouseDown}
>
<div className={[
"rounded-full bg-secondary/40 group-hover/div:bg-blue/50 transition-colors",
isCol ? "w-[2px] h-8" : "h-[2px] w-8",
].join(" ")} />
</div>
);
}
// ── Recursive tree renderer ───────────────────────────────────────────────────
const LeafCard = memo(function LeafCard({ leaf }: { leaf: LeafNode }) {
const { focusedId, totalPanes, isAuthed, onFocus, onSplit, onClose } = useWM();
const isFocused = leaf.id === focusedId;
return (
<div className="p-[2px] flex flex-col w-full h-full min-w-0 min-h-0 box-border">
<div
className={[
"flex-1 min-h-0 rounded-xl overflow-hidden border transition-colors duration-150",
totalPanes > 1 && isFocused
? "border-blue/60 shadow-[0_0_0_1px_rgba(66,140,226,0.2)]"
: "border-secondary/60",
].join(" ")}
onClick={() => onFocus(leaf.id, leaf.panelId)}
>
<WindowPane
node={leaf}
isFocused={isFocused}
canClose={totalPanes > 1}
onSplit={onSplit}
onClose={onClose}
isAuthed={isAuthed}
/>
</div>
</div>
);
});
function RenderTree({ node }: { node: TileNode }) {
const containerRef = useRef<HTMLDivElement>(null);
const { focusedId } = useWM();
if (node.type === "leaf") return <LeafCard leaf={node as LeafNode} />;
const container = node as ContainerNode;
const isCol = container.dir === "h";
return (
<div
ref={containerRef}
className="flex w-full h-full min-w-0 min-h-0"
style={{ flexDirection: isCol ? "row" : "column" }}
>
{container.children.map((child, i) => (
<React.Fragment key={child.id}>
<div
className="min-w-0 min-h-0 flex"
style={{
flex: container.sizes[i] ?? 1,
flexDirection: isCol ? "row" : "column",
}}
>
<RenderTree node={child} />
</div>
{i < container.children.length - 1 && (
<ResizeHandle
containerId={container.id}
index={i}
dir={container.dir}
sizes={container.sizes}
containerRef={containerRef}
/>
)}
</React.Fragment>
))}
</div>
);
}
// ── Root ──────────────────────────────────────────────────────────────────────
export default function WindowManager({ isAuthed }: { isAuthed: boolean }) {
const [tree, setTree] = useState<TileNode>(DEFAULT_TREE);
const [focusedId, setFocusedId] = useState<string | null>(null);
const [ready, setReady] = useState(false);
// Refs for stale-closure-safe callbacks
const treeRef = useRef(tree);
const focusedIdRef = useRef(focusedId);
treeRef.current = tree;
focusedIdRef.current = focusedId;
// Load persisted layout
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) setTree(JSON.parse(saved));
} catch {}
setReady(true);
}, []);
// Sync focused context to store whenever tree or focus changes
useEffect(() => {
const id = focusedId ?? getFirstLeafId(tree);
const panelId = getLeafPanel(tree, id);
if (id && panelId) setFocusedContext(id, panelId);
}, [tree, focusedId]);
// Listen for sidebar view-change requests
useEffect(() => {
const unsub = subscribeViewChange((panelId) => {
const id = focusedIdRef.current ?? getFirstLeafId(treeRef.current);
setTree((prev) => {
const next = updatePanelId(prev, id, panelId);
persist(next);
return next;
});
// Update store immediately so sidebar highlight updates
setFocusedContext(id, panelId);
});
return unsub;
}, []);
const onFocus = useCallback((paneId: string, panelId: PanelId) => {
setFocusedId(paneId);
setFocusedContext(paneId, panelId);
}, []);
const onSplit = useCallback((leafId: string, dir: "h" | "v", newFirst: boolean, panelId: PanelId) => {
setTree((prev) => {
const next = splitLeaf(prev, leafId, dir, newFirst, panelId);
persist(next);
return next;
});
}, []);
const onClose = useCallback((leafId: string) => {
setTree((prev) => {
const next = closeLeaf(prev, leafId);
if (!next) return prev;
persist(next);
// If closed pane was focused, move focus to first leaf
if (focusedIdRef.current === leafId) {
const firstId = getFirstLeafId(next);
const panelId = getLeafPanel(next, firstId);
setFocusedId(firstId);
if (panelId) setFocusedContext(firstId, panelId);
}
return next;
});
}, []);
const onResizeContainer = useCallback((containerId: string, sizes: number[]) => {
setTree((prev) => {
const next = patchSizes(prev, containerId, sizes);
persist(next);
return next;
});
}, []);
const total = countLeaves(tree);
const ctx: WMCtx = {
focusedId: focusedId ?? (ready ? getFirstLeafId(tree) : null),
totalPanes: total,
isAuthed,
onFocus,
onSplit,
onClose,
onResizeContainer,
};
if (!ready) return null;
return (
<WMContext.Provider value={ctx}>
<div className="w-full h-full overflow-hidden">
<RenderTree node={tree} />
</div>
</WMContext.Provider>
);
}

View file

@ -0,0 +1,296 @@
"use client";
import React, { useState, useRef, useEffect, lazy, Suspense } from "react";
import { createPortal } from "react-dom";
import { LeafNode, PanelId, PANEL_LABELS, PANEL_SECTIONS } from "./types";
import {
IconX, IconLayoutColumns, IconLayoutRows, IconRefresh,
} from "@tabler/icons-react";
const DashboardPanel = lazy(() => import("../panels/DashboardPanel"));
const AnalyticsPanel = lazy(() => import("../panels/AnalyticsPanel"));
function PanelContent({ panelId, isAuthed }: { panelId: PanelId; isAuthed: boolean }) {
return (
<Suspense fallback={<div className="p-4"><div className="skeleton h-32 rounded-2xl" /></div>}>
{panelId === "dashboard" && <DashboardPanel isAuthed={isAuthed} />}
{panelId === "analytics-line" && <AnalyticsPanel chartType="line" />}
{panelId === "analytics-bar" && <AnalyticsPanel chartType="bar" />}
{panelId === "analytics-candle" && <AnalyticsPanel chartType="candle" />}
</Suspense>
);
}
// ── View picker portal ────────────────────────────────────────────────────────
type SplitDir = "right" | "down";
function ViewPickerPortal({
anchorRef,
mode,
currentView,
onPick,
onClose,
}: {
anchorRef: React.RefObject<HTMLButtonElement | null>;
mode: "change" | SplitDir;
currentView: PanelId;
onPick: (panelId: PanelId) => void;
onClose: () => void;
}) {
const menuRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
useEffect(() => {
const rect = anchorRef.current?.getBoundingClientRect();
if (rect) setPos({ top: rect.bottom + 6, left: rect.left });
}, []);
// Clamp to viewport after mount
useEffect(() => {
if (!menuRef.current) return;
const r = menuRef.current.getBoundingClientRect();
const pad = 8;
let { left, top } = pos;
if (r.right > window.innerWidth - pad) left = window.innerWidth - r.width - pad;
if (r.left < pad) left = pad;
if (r.bottom > window.innerHeight - pad) top = window.innerHeight - r.height - pad;
if (left !== pos.left || top !== pos.top) setPos({ top, left });
}, [pos]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!menuRef.current?.contains(e.target as Node) &&
!anchorRef.current?.contains(e.target as Node)) onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [onClose]);
const title = mode === "change" ? "Change View" : mode === "right" ? "Tile Right" : "Tile Down";
// Dashboard entry + sections
const dashboardActive = currentView === "dashboard";
return createPortal(
<div
ref={menuRef}
style={{ position: "fixed", top: pos.top, left: pos.left, zIndex: 9999 }}
className="w-[220px] max-h-[420px] overflow-y-auto bg-primary border border-secondary rounded-xl shadow-2xl py-1"
>
<p className="px-3 pt-1.5 pb-1 text-[10px] font-semibold text-foreground-sec uppercase tracking-wider">
{title}
</p>
{/* Dashboard standalone */}
<button
onClick={() => { onPick("dashboard"); onClose(); }}
className={[
"w-[calc(100%-8px)] mx-[4px] flex items-center gap-2.5 px-2.5 py-[5px] rounded-lg text-left text-xs transition-colors cursor-pointer",
dashboardActive
? "bg-blue/12 border border-blue/30 text-blue font-medium"
: "text-foreground hover:bg-secondary/60 border border-transparent",
].join(" ")}
>
<span className={[
"w-[28px] h-[28px] shrink-0 flex items-center justify-center rounded-[7px] border text-[10px] font-bold",
dashboardActive
? "bg-blue/15 border-blue/35 text-blue"
: "bg-secondary/50 border-secondary text-foreground-sec",
].join(" ")}>D</span>
Dashboard
</button>
{/* Power Analytics section */}
{PANEL_SECTIONS.map((section, si) => (
<div key={section.id}>
<div className="mx-2 my-1 h-px bg-secondary" />
<p className="px-3 pt-1 pb-0.5 text-[9px] font-semibold text-foreground-sec/60 uppercase tracking-wider">
{section.label}
</p>
{section.items.map(({ panelId, label }) => {
const active = panelId === currentView;
return (
<button
key={panelId}
onClick={() => { onPick(panelId); onClose(); }}
className={[
"w-[calc(100%-8px)] mx-[4px] flex items-center gap-2.5 px-2.5 py-[5px] rounded-lg text-left text-xs transition-colors cursor-pointer",
active
? "bg-blue/12 border border-blue/30 text-blue font-medium"
: "text-foreground hover:bg-secondary/60 border border-transparent",
].join(" ")}
>
<span className={[
"w-[28px] h-[28px] shrink-0 flex items-center justify-center rounded-[7px] border text-[10px] font-bold",
active
? "bg-blue/15 border-blue/35 text-blue"
: "bg-secondary/50 border-secondary text-foreground-sec",
].join(" ")}>
{label.charAt(0)}
</span>
{label}
</button>
);
})}
</div>
))}
</div>,
document.body,
);
}
// ── Window controls pill ──────────────────────────────────────────────────────
type MenuMode = "change" | SplitDir;
function WindowControls({
paneId,
currentView,
canClose,
onClose,
onSplit,
onChangeView,
}: {
paneId: string;
currentView: PanelId;
canClose: boolean;
onClose: (id: string) => void;
onSplit: (leafId: string, dir: "h" | "v", newFirst: boolean, panelId: PanelId) => void;
onChangeView: (panelId: PanelId) => void;
}) {
const [menu, setMenu] = useState<MenuMode | null>(null);
const [pillHovered, setPillHovered] = useState(false);
const changeRef = useRef<HTMLButtonElement>(null);
const rightRef = useRef<HTMLButtonElement>(null);
const downRef = useRef<HTMLButtonElement>(null);
const handlePick = (panelId: PanelId) => {
if (menu === "change") onChangeView(panelId);
else if (menu === "right") onSplit(paneId, "h", false, panelId);
else if (menu === "down") onSplit(paneId, "v", false, panelId);
setMenu(null);
};
const anchorRef = menu === "change" ? changeRef : menu === "right" ? rightRef : downRef;
const BTN = "w-[26px] h-[26px] flex items-center justify-center rounded-full transition-colors cursor-pointer text-foreground-sec hover:text-foreground";
return (
<>
<div
className="flex items-center gap-0.5 px-1 py-0.5 rounded-full border transition-colors duration-150"
style={{
borderColor: pillHovered ? "rgba(66,140,226,0.35)" : "var(--color-secondary)",
background: pillHovered ? "color-mix(in srgb, var(--color-blue) 8%, var(--color-primary))" : "var(--color-primary)",
boxShadow: pillHovered ? "none" : "0 1px 4px rgba(0,0,0,0.18)",
transition: "background 150ms, border-color 150ms, box-shadow 150ms",
}}
onMouseEnter={() => setPillHovered(true)}
onMouseLeave={() => setPillHovered(false)}
>
{canClose && (
<button
onClick={(e) => { e.stopPropagation(); onClose(paneId); }}
className={`${BTN} hover:text-red-400 hover:bg-red-500/10`}
title="Close"
>
<IconX size={13} />
</button>
)}
<button
ref={changeRef}
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "change" ? null : "change"); }}
className={`${BTN} ${menu === "change" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
title="Change view"
>
<IconRefresh size={13} />
</button>
<button
ref={rightRef}
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "right" ? null : "right"); }}
className={`${BTN} ${menu === "right" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
title="Tile right"
>
<IconLayoutColumns size={13} />
</button>
<button
ref={downRef}
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "down" ? null : "down"); }}
className={`${BTN} ${menu === "down" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
title="Tile down"
>
<IconLayoutRows size={13} />
</button>
</div>
{menu && (
<ViewPickerPortal
anchorRef={anchorRef}
mode={menu}
currentView={currentView}
onPick={handlePick}
onClose={() => setMenu(null)}
/>
)}
</>
);
}
// ── WindowPane ────────────────────────────────────────────────────────────────
export default function WindowPane({
node,
isFocused,
canClose,
onSplit,
onClose,
isAuthed,
}: {
node: LeafNode;
isFocused: boolean;
canClose: boolean;
onSplit: (leafId: string, dir: "h" | "v", newFirst: boolean, panelId: PanelId) => void;
onClose: (leafId: string) => void;
isAuthed: boolean;
}) {
const [hovered, setHovered] = useState(false);
const handleChangeView = (panelId: PanelId) => {
import("@/stores/windowStore").then(({ requestViewChange }) => requestViewChange(panelId));
};
return (
<div
className="relative w-full h-full flex flex-col bg-primary"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Title bar */}
<div className="shrink-0 h-[28px] flex items-center justify-between px-3 border-b border-secondary/40">
<span className="text-[11px] font-medium text-foreground-sec truncate select-none">
{PANEL_LABELS[node.panelId]}
</span>
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<PanelContent panelId={node.panelId} isAuthed={isAuthed} />
</div>
{/* Floating controls pill */}
<div
className="absolute top-[4px] right-[8px] z-20 transition-opacity duration-150 pointer-events-none"
style={{ opacity: hovered ? 1 : 0, pointerEvents: hovered ? "auto" : "none" }}
>
<WindowControls
paneId={node.id}
currentView={node.panelId}
canClose={canClose}
onClose={onClose}
onSplit={onSplit}
onChangeView={handleChangeView}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
import { TileNode, LeafNode, ContainerNode, PanelId } from "./types";
let _id = 0;
export function gid(): string {
return `wn-${Date.now()}-${++_id}`;
}
// ── Leaf operations ───────────────────────────────────────────────────────────
function mapLeaf(tree: TileNode, leafId: string, fn: (leaf: LeafNode) => TileNode): TileNode {
if (tree.type === "leaf") return tree.id === leafId ? fn(tree) : tree;
return { ...tree, children: tree.children.map((c) => mapLeaf(c, leafId, fn)) };
}
export function splitLeaf(
tree: TileNode,
leafId: string,
dir: "h" | "v",
newFirst: boolean,
newPanelId: PanelId,
): TileNode {
const newLeaf: LeafNode = { type: "leaf", id: gid(), panelId: newPanelId };
return mapLeaf(tree, leafId, (leaf) => ({
type: "container",
id: gid(),
dir,
children: newFirst ? [newLeaf, leaf] : [leaf, newLeaf],
sizes: [1, 1],
}));
}
export function closeLeaf(tree: TileNode, leafId: string): TileNode | null {
if (tree.type === "leaf") return tree.id === leafId ? null : tree;
const children: TileNode[] = [];
const sizes: number[] = [];
tree.children.forEach((c, i) => {
const r = closeLeaf(c, leafId);
if (r !== null) { children.push(r); sizes.push(tree.sizes[i]); }
});
if (children.length === 0) return null;
if (children.length === 1) return children[0]; // collapse single-child container
return { ...tree, children, sizes };
}
export function updatePanelId(tree: TileNode, leafId: string, panelId: PanelId): TileNode {
if (tree.type === "leaf") return tree.id === leafId ? { ...tree, panelId } : tree;
return { ...tree, children: tree.children.map((c) => updatePanelId(c, leafId, panelId)) };
}
export function patchSizes(tree: TileNode, containerId: string, sizes: number[]): TileNode {
if (tree.type === "leaf") return tree;
if (tree.id === containerId) return { ...tree, sizes };
return { ...tree, children: tree.children.map((c) => patchSizes(c, containerId, sizes)) };
}
// ── Query helpers ─────────────────────────────────────────────────────────────
export function getFirstLeafId(tree: TileNode): string {
if (tree.type === "leaf") return tree.id;
return getFirstLeafId(tree.children[0]);
}
export function countLeaves(tree: TileNode): number {
if (tree.type === "leaf") return 1;
return tree.children.reduce((s, c) => s + countLeaves(c), 0);
}
export function getLeafPanel(tree: TileNode, leafId: string): PanelId | null {
if (tree.type === "leaf") return tree.id === leafId ? tree.panelId : null;
for (const c of tree.children) {
const p = getLeafPanel(c, leafId);
if (p) return p;
}
return null;
}

View file

@ -0,0 +1,43 @@
export type PanelId =
| "dashboard"
| "analytics-line"
| "analytics-bar"
| "analytics-candle";
export const PANEL_LABELS: Record<PanelId, string> = {
dashboard: "Dashboard",
"analytics-line": "Power Analytics — Line",
"analytics-bar": "Power Analytics — Bar",
"analytics-candle": "Power Analytics — Candlestick",
};
// ── Sidebar sections ──────────────────────────────────────────────────────────
export interface PanelEntry { panelId: PanelId; label: string; }
export interface PanelSection { id: string; label: string; items: PanelEntry[]; }
export const PANEL_SECTIONS: PanelSection[] = [
{
id: "power-analytics",
label: "Power Analytics",
items: [
{ panelId: "analytics-line", label: "Line Chart" },
{ panelId: "analytics-bar", label: "Bar Chart" },
{ panelId: "analytics-candle", label: "Candlestick" },
],
},
];
export const ALL_PANELS: PanelId[] = Object.keys(PANEL_LABELS) as PanelId[];
// ── N-ary pane tree ───────────────────────────────────────────────────────────
export type LeafNode = { type: "leaf"; id: string; panelId: PanelId };
export type ContainerNode = {
type: "container";
id: string;
dir: "h" | "v";
children: TileNode[];
sizes: number[];
};
export type TileNode = LeafNode | ContainerNode;

View file

@ -18,7 +18,6 @@ export interface PowerData {
export async function getPower(): Promise<PowerData> { export async function getPower(): Promise<PowerData> {
const res = await fetch("/api/power"); const res = await fetch("/api/power");
if (res.status === 401) throw new Error("UNAUTHORIZED");
if (!res.ok) throw new Error(`Failed to fetch power: ${res.status}`); if (!res.ok) throw new Error(`Failed to fetch power: ${res.status}`);
return res.json() as Promise<PowerData>; return res.json() as Promise<PowerData>;
} }

View file

@ -51,10 +51,6 @@ export interface Stats {
export async function getStats(): Promise<Stats> { export async function getStats(): Promise<Stats> {
const res = await fetch("/api/stats"); const res = await fetch("/api/stats");
if (res.status === 401) {
throw new Error("UNAUTHORIZED");
}
if (!res.ok) throw new Error(`Failed to fetch stats: ${res.status}`); if (!res.ok) throw new Error(`Failed to fetch stats: ${res.status}`);
return res.json() as Promise<Stats>; return res.json() as Promise<Stats>;
} }

View file

@ -1,40 +1,22 @@
"use client"; "use client";
import { useEffect, useState, useRef, useCallback } from "react"; import { useEffect, useState, useRef } from "react";
import { getStats, type Stats, type NetworkInterface } from "./lib/getStats";
import SideNav from "./components/SideNav"; import SideNav from "./components/SideNav";
import Hero from "./components/Hero"; import WindowManager from "./components/windows/WindowManager";
import StatsGrid from "./components/StatsGrid";
import ServicesCard from "./components/ServicesCard";
import UptimeCard from "./components/UptimeCard";
import NetworkCard from "./components/NetworkCard";
import PowerGrid from "./components/PowerGrid";
import LinksGrid from "./components/LinksGrid";
import DevConsole, { type LogEntry } from "./components/DevConsole"; import DevConsole, { type LogEntry } from "./components/DevConsole";
import { useCheckAuth } from "@/hooks/useCheckAuth";
import { useRouter } from "next/navigation";
import { getPower, type PowerData } from "./lib/getPower";
export default function Home() { export default function Home() {
const router = useRouter(); const [isAuthed, setIsAuthed] = useState<boolean | null>(null);
useCheckAuth(); const [online, setOnline] = useState(false);
const [mounted, setMounted] = useState(false);
const [stats, setStats] = useState<Stats | null>(null);
const [power, setPower] = useState<PowerData | null>(null);
const [netSpeed, setNetSpeed] = useState<
Record<string, { rx: number; tx: number }>
>({});
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
const [panelWidth, setPanelWidth] = useState(440); const [panelWidth, setPanelWidth] = useState(440);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const prevNetRef = useRef<Record<string, NetworkInterface> | null>(null);
const lastFetchRef = useRef<number>(0);
const logIdRef = useRef(0); const logIdRef = useRef(0);
useEffect(() => { setMounted(true); }, []);
useEffect(() => { useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768); const check = () => setIsMobile(window.innerWidth < 768);
check(); check();
@ -43,8 +25,22 @@ export default function Home() {
}, []); }, []);
useEffect(() => { useEffect(() => {
const original = window.fetch; fetch("/api/auth/check").then((r) => setIsAuthed(r.ok));
}, []);
// Lightweight online check
useEffect(() => {
const check = async () => {
try { setOnline((await fetch("/api/stats")).ok); } catch { setOnline(false); }
};
check();
const id = setInterval(check, 5000);
return () => clearInterval(id);
}, []);
// Dev console fetch logger
useEffect(() => {
const original = window.fetch;
window.fetch = async (input, init) => { window.fetch = async (input, init) => {
const url = const url =
typeof input === "string" typeof input === "string"
@ -62,11 +58,7 @@ export default function Home() {
const method = ((init?.method ?? (input instanceof Request ? input.method : "GET")) as string).toUpperCase(); const method = ((init?.method ?? (input instanceof Request ? input.method : "GET")) as string).toUpperCase();
const id = ++logIdRef.current; const id = ++logIdRef.current;
let path = url; let path = url;
try { try { path = new URL(url, window.location.origin).pathname; } catch {}
path = new URL(url, window.location.origin).pathname;
} catch {
// use raw url
}
const timestamp = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); const timestamp = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const start = Date.now(); const start = Date.now();
@ -81,147 +73,50 @@ export default function Home() {
const text = await clone.text(); const text = await clone.text();
const duration = Date.now() - start; const duration = Date.now() - start;
setLogs((prev) => setLogs((prev) =>
prev.map((e) => (e.id === id ? { ...e, status: res.status, duration, response: text } : e)) prev.map((e) => (e.id === id ? { ...e, status: res.status, duration, response: text } : e)),
); );
return res; return res;
} catch (err) { } catch (err) {
const duration = Date.now() - start; const duration = Date.now() - start;
setLogs((prev) => setLogs((prev) =>
prev.map((e) => (e.id === id ? { ...e, status: 0, duration, response: String(err) } : e)) prev.map((e) => (e.id === id ? { ...e, status: 0, duration, response: String(err) } : e)),
); );
throw err; throw err;
} }
}; };
return () => { window.fetch = original; };
return () => {
window.fetch = original;
};
}, []); }, []);
useEffect(() => {
const fetchData = async () => {
try {
const now = Date.now();
const data = await getStats();
if (prevNetRef.current && lastFetchRef.current > 0) {
const elapsed = (now - lastFetchRef.current) / 1000;
const speeds: Record<string, { rx: number; tx: number }> = {};
for (const iface of Object.keys(data.network)) {
const prev = prevNetRef.current[iface];
if (prev) {
speeds[iface] = {
rx: Math.max(0, (data.network[iface].rx - prev.rx) / elapsed),
tx: Math.max(0, (data.network[iface].tx - prev.tx) / elapsed),
};
}
}
setNetSpeed(speeds);
}
prevNetRef.current = data.network;
lastFetchRef.current = now;
setStats(data);
} catch (e) {
if (e instanceof Error && e.message === "UNAUTHORIZED") {
router.push(
"/auth?callbackUrl=" + encodeURIComponent(window.location.pathname),
);
return;
}
console.error("Dashboard fetch failed:", e);
}
};
fetchData();
const id = setInterval(fetchData, 4000);
return () => clearInterval(id);
}, []);
const fetchPower = useCallback(async () => {
try {
setPower(await getPower());
} catch (e) {
if (e instanceof Error && e.message === "UNAUTHORIZED") return;
console.error("Power fetch failed:", e);
}
}, []);
useEffect(() => {
fetchPower();
const id = setInterval(fetchPower, 10000);
return () => clearInterval(id);
}, [fetchPower]);
const primaryIface = stats
? Object.keys(stats.network).find(
(k) =>
!k.startsWith("docker") &&
!k.startsWith("br-") &&
stats.network[k].rx > 0,
)
: null;
const primarySpeed = primaryIface ? netSpeed[primaryIface] || null : null;
return ( return (
<div className="w-full h-full bg-primary text-foreground overflow-hidden flex flex-row"> <div className="w-full h-full bg-primary text-foreground overflow-hidden flex flex-row">
<SideNav <SideNav
online={!!stats} online={online}
devConsoleOpen={panelOpen} devConsoleOpen={panelOpen}
onToggleDevConsole={() => setPanelOpen((o) => !o)} onToggleDevConsole={() => setPanelOpen((o) => !o)}
/> />
<div <div
className="flex-1 overflow-y-auto pt-[52px] lg:pt-0 lg:m-[10px_10px_10px_0px] lg:rounded-2xl lg:border lg:border-blue/20 min-w-0" className="flex-1 overflow-hidden pt-[52px] lg:pt-0 lg:m-[10px_10px_10px_0px] lg:rounded-2xl min-w-0"
style={{ style={{
paddingRight: panelOpen && !isMobile ? panelWidth : 0, paddingRight: panelOpen && !isMobile ? panelWidth : 0,
transition: "padding-right 280ms cubic-bezier(0.4,0,0.2,1)", transition: "padding-right 280ms cubic-bezier(0.4,0,0.2,1)",
}} }}
> >
<div className="max-w-5xl mx-auto px-6 pb-20 pt-8"> {mounted && (
<Hero lastUpdated={stats?.timestamp ?? null} /> <WindowManager isAuthed={!!isAuthed} />
)}
<div className="flex items-baseline justify-between mb-5">
<h2 className="text-lg font-medium tracking-tight text-foreground" style={{ lineHeight: "normal", marginTop: 0 }}>
System Stats
</h2>
</div>
<StatsGrid stats={stats} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5 mb-11">
<ServicesCard services={stats?.services ?? null} delay={200} />
<div className="flex flex-col gap-3.5">
<UptimeCard uptime={stats?.uptime ?? null} delay={250} />
<NetworkCard
iface={primaryIface ?? null}
speed={primarySpeed}
delay={300}
/>
</div>
</div>
<div className="flex items-baseline justify-between mb-5">
<h2 className="text-lg font-medium tracking-tight text-foreground" style={{ lineHeight: "normal", marginTop: 0 }}>
Power Consumption
</h2>
</div>
<PowerGrid power={power} onRefresh={fetchPower} />
<LinksGrid />
</div>
</div> </div>
<DevConsole {mounted && isAuthed && (
open={panelOpen} <DevConsole
width={panelWidth} open={panelOpen}
isMobile={isMobile} width={panelWidth}
onClose={() => setPanelOpen(false)} isMobile={isMobile}
onWidthChange={setPanelWidth} onClose={() => setPanelOpen(false)}
logs={logs} onWidthChange={setPanelWidth}
/> logs={logs}
/>
)}
</div> </div>
); );
} }

639
app/users/page.tsx Normal file
View file

@ -0,0 +1,639 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import SideNav from "../components/SideNav";
import {
IconSearch,
IconX,
IconKey,
IconTrash,
IconPlus,
} from "@tabler/icons-react";
function b64uToBuf(b64u: string): ArrayBuffer {
const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(b64);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}
function bufToB64u(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
interface Credential {
id: string;
created_at?: string;
}
interface User {
username: string;
credentials: Credential[];
}
type EnrollStatus = "idle" | "starting" | "waiting_yubikey" | "saving" | "done" | "error";
const truncId = (id: string) =>
id.length > 20 ? id.slice(0, 10) + "…" + id.slice(-6) : id;
const fmtDate = (iso?: string) => {
if (!iso) return null;
try {
return new Date(iso).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "2-digit",
});
} catch {
return null;
}
};
export default function UsersPage() {
const [users, setUsers] = useState<User[] | null>(null);
const [selected, setSelected] = useState<User | null>(null);
const [search, setSearch] = useState("");
const [enrollPassword, setEnrollPassword] = useState("");
const [enrollStatus, setEnrollStatus] = useState<EnrollStatus>("idle");
const [enrollMessage, setEnrollMessage] = useState("");
const [deletingId, setDeletingId] = useState<string | null>(null);
const [leftPct, setLeftPct] = useState(35);
const isDragging = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const fetchUsers = useCallback(async () => {
const res = await fetch("/api/users");
if (!res.ok) return;
const data = await res.json();
const list: User[] = data.users ?? data;
setUsers(list);
setSelected((prev) => {
const match = prev ? list.find((u) => u.username === prev.username) : null;
return match ?? list[0] ?? null;
});
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
if (!isDragging.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const pct = ((e.clientX - rect.left) / rect.width) * 100;
setLeftPct(Math.min(Math.max(pct, 20), 60));
};
const onUp = () => {
isDragging.current = false;
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, []);
const selectUser = (user: User) => {
setSelected(user);
setEnrollPassword("");
setEnrollStatus("idle");
setEnrollMessage("");
};
const deleteCredential = async (credId: string) => {
if (!selected) return;
setDeletingId(credId);
await fetch(`/api/users/${selected.username}/credentials/${credId}`, {
method: "DELETE",
});
setDeletingId(null);
await fetchUsers();
};
const enrollKey = async (e: React.FormEvent) => {
e.preventDefault();
if (!selected) return;
setEnrollStatus("starting");
setEnrollMessage("");
try {
const startRes = await fetch(
`/api/users/${selected.username}/enroll/start`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: enrollPassword }),
},
);
if (!startRes.ok) {
setEnrollMessage("Invalid password.");
setEnrollStatus("error");
return;
}
const { session_id, challenge } = await startRes.json();
setEnrollStatus("waiting_yubikey");
const opts = challenge.publicKey;
opts.challenge = b64uToBuf(opts.challenge);
opts.user.id = b64uToBuf(opts.user.id);
opts.authenticatorSelection = {
authenticatorAttachment: "cross-platform",
residentKey: "discouraged",
requireResidentKey: false,
userVerification: "discouraged",
};
(opts as Record<string, unknown>).hints = ["security-key"];
if (opts.excludeCredentials) {
opts.excludeCredentials = opts.excludeCredentials.map(
(c: { id: string; type: string; transports?: string[] }) => ({
...c,
id: b64uToBuf(c.id),
}),
);
}
let cred: PublicKeyCredential;
try {
cred = (await navigator.credentials.create({
publicKey: opts,
})) as PublicKeyCredential;
} catch (err) {
setEnrollMessage(
"YubiKey error: " + (err instanceof Error ? err.message : "cancelled"),
);
setEnrollStatus("error");
return;
}
setEnrollStatus("saving");
const attestation = cred.response as AuthenticatorAttestationResponse;
const finishRes = await fetch(
`/api/users/${selected.username}/enroll/finish`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id,
credential: {
id: cred.id,
rawId: bufToB64u(cred.rawId),
type: cred.type,
response: {
attestationObject: bufToB64u(attestation.attestationObject),
clientDataJSON: bufToB64u(attestation.clientDataJSON),
transports: ["usb", "nfc", "ble", "hybrid"],
},
extensions: {},
},
}),
},
);
if (!finishRes.ok) {
setEnrollMessage("Registration failed. Check server logs.");
setEnrollStatus("error");
return;
}
setEnrollStatus("done");
setEnrollMessage("YubiKey enrolled successfully.");
setEnrollPassword("");
await fetchUsers();
} catch {
setEnrollMessage("Something went wrong. Try again.");
setEnrollStatus("error");
}
};
const filtered = (users ?? []).filter((u) =>
u.username.toLowerCase().includes(search.toLowerCase()),
);
const enrollBusy = enrollStatus !== "idle" && enrollStatus !== "error";
return (
<div className="w-full h-full bg-primary text-foreground overflow-hidden flex flex-row">
<SideNav online={false} devConsoleOpen={false} onToggleDevConsole={() => {}} isAuthed={true} />
{/* Desktop split pane */}
<div
ref={containerRef}
className="flex-1 overflow-hidden hidden lg:flex flex-row lg:m-[10px_10px_10px_0px] lg:rounded-2xl lg:border lg:border-blue/20 select-none"
>
{/* Left panel — user list */}
<div
className="h-full flex flex-col shrink-0 overflow-hidden border-r border-secondary"
style={{ width: `${leftPct}%` }}
>
<div className="p-[10px] border-b border-secondary shrink-0">
<div className="flex items-center gap-[8px] bg-secondary/50 rounded-[10px] px-[10px] py-[6px]">
<IconSearch size={13} className="text-foreground-sec shrink-0" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users…"
className="flex-1 bg-transparent text-[12px] text-foreground placeholder:text-foreground-sec outline-none"
/>
{search && (
<button
onClick={() => setSearch("")}
className="text-foreground-sec hover:text-foreground cursor-pointer"
>
<IconX size={12} />
</button>
)}
</div>
</div>
<div className="overflow-y-auto flex-1 min-h-0">
{users === null ? (
<div className="p-[8px] flex flex-col gap-[4px]">
{[...Array(4)].map((_, i) => (
<div key={i} className="skeleton h-[52px] rounded-xl" />
))}
</div>
) : filtered.length === 0 ? (
<p className="text-foreground-sec text-[12px] text-center py-[24px] px-[10px]">
No users found.
</p>
) : (
<div className="p-[5px] flex flex-col">
{filtered.map((user) => {
const active = selected?.username === user.username;
return (
<div
key={user.username}
onClick={() => selectUser(user)}
className={
"flex items-center justify-between gap-[10px] px-[10px] py-[9px] rounded-xl cursor-pointer transition-colors m-[2px] " +
(active
? "bg-blue/20 shadow-sm shadow-blue/10"
: "hover:bg-secondary/50")
}
>
<div className="flex items-center gap-[10px] min-w-0">
<div
className={
"w-[28px] h-[28px] rounded-full flex items-center justify-center shrink-0 text-[11px] font-bold " +
(active
? "bg-blue text-primary"
: "bg-secondary text-foreground-sec")
}
>
{user.username[0]?.toUpperCase()}
</div>
<span
className={
"text-[13px] font-semibold truncate " +
(active ? "text-blue" : "text-foreground")
}
>
{user.username}
</span>
</div>
<span
className={
"text-[10px] font-bold tracking-wider py-[2px] px-[8px] rounded-full shrink-0 " +
(active
? "bg-blue/30 text-blue"
: "bg-secondary text-foreground-sec")
}
>
{user.credentials.length}{" "}
{user.credentials.length === 1 ? "key" : "keys"}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Drag handle */}
<div
onMouseDown={(e) => {
isDragging.current = true;
e.preventDefault();
}}
className="w-[10px] shrink-0 flex items-center justify-center cursor-col-resize group"
>
<div className="w-[3px] h-[40px] rounded-full bg-blue/20 group-hover:bg-blue/50 transition-colors" />
</div>
{/* Right panel — credential management */}
<div className="flex-1 overflow-y-auto min-w-0 h-full">
{selected ? (
<div className="p-[25px] flex flex-col gap-[22px]">
{/* User header */}
<div>
<div className="flex items-center gap-[12px]">
<div className="w-[38px] h-[38px] rounded-full bg-blue/20 flex items-center justify-center text-[15px] font-bold text-blue shrink-0">
{selected.username[0]?.toUpperCase()}
</div>
<div>
<p className="text-[18px] font-bold text-foreground" style={{ lineHeight: "normal", fontSize: "18px", fontWeight: 700 }}>
{selected.username}
</p>
<p className="text-[11px] text-foreground-sec" style={{ lineHeight: "normal", fontSize: "11px" }}>
{selected.credentials.length} security{" "}
{selected.credentials.length === 1 ? "key" : "keys"} enrolled
</p>
</div>
</div>
<div className="h-px bg-secondary mt-[16px]" />
</div>
{/* Enrolled keys */}
<div>
<div className="flex items-center gap-[8px] mb-[10px]">
<span className="text-[10px] font-bold tracking-wider text-foreground-sec uppercase">
Enrolled Keys
</span>
<div className="flex-1 h-px bg-secondary" />
<span className="text-[10px] text-foreground-sec">
{selected.credentials.length}
</span>
</div>
{selected.credentials.length === 0 ? (
<div className="flex items-center gap-[8px] px-[12px] py-[10px] rounded-xl bg-secondary/30 text-foreground-sec">
<IconKey size={13} />
<span className="text-[12px]">No keys enrolled</span>
</div>
) : (
<div className="flex flex-col gap-[5px]">
{selected.credentials.map((cred) => (
<div
key={cred.id}
className="flex items-center gap-[10px] px-[12px] py-[9px] rounded-xl bg-secondary/30 group"
>
<IconKey size={13} className="text-blue shrink-0" />
<div className="flex flex-col gap-[1px] flex-1 min-w-0">
<span className="text-[12px] font-mono text-foreground truncate">
{truncId(cred.id)}
</span>
{fmtDate(cred.created_at) && (
<span className="text-[10px] text-foreground-sec">
{fmtDate(cred.created_at)}
</span>
)}
</div>
<button
onClick={() => deleteCredential(cred.id)}
disabled={deletingId === cred.id}
title="Remove key"
className="p-[5px] rounded-[7px] text-foreground-sec hover:text-red-400 hover:bg-red-400/10 transition-colors opacity-0 group-hover:opacity-100 cursor-pointer disabled:opacity-50 shrink-0"
>
{deletingId === cred.id ? (
<span className="w-[13px] h-[13px] border-2 border-current border-t-transparent rounded-full inline-block animate-spin" />
) : (
<IconTrash size={13} />
)}
</button>
</div>
))}
</div>
)}
</div>
{/* Enroll new key */}
<div>
<div className="flex items-center gap-[8px] mb-[10px]">
<span className="text-[10px] font-bold tracking-wider text-foreground-sec uppercase">
Enroll New Key
</span>
<div className="flex-1 h-px bg-secondary" />
</div>
{enrollStatus === "done" ? (
<div className="flex items-center gap-[8px] px-[12px] py-[10px] rounded-xl bg-green/10 text-green text-[12px] font-semibold">
{enrollMessage}
<button
onClick={() => setEnrollStatus("idle")}
className="ml-auto text-green/60 hover:text-green cursor-pointer"
>
<IconX size={13} />
</button>
</div>
) : (
<form onSubmit={enrollKey} className="flex flex-col gap-[10px]">
<div>
<label className="block text-[10px] tracking-wider text-foreground-sec uppercase mb-[6px]">
Password for {selected.username}
</label>
<input
type="password"
value={enrollPassword}
onChange={(e) => setEnrollPassword(e.target.value)}
required
disabled={enrollBusy}
placeholder="Enter user password"
autoComplete="current-password"
className="w-full px-[12px] py-[8px] bg-secondary/50 border border-secondary rounded-xl text-[13px] text-foreground outline-none focus:border-blue/50 transition-colors disabled:opacity-50"
/>
</div>
{enrollStatus === "waiting_yubikey" && (
<p className="text-[12px] text-blue text-center animate-pulse">
Touch your YubiKey
</p>
)}
{enrollStatus === "error" && enrollMessage && (
<p className="text-[12px] text-red-400">{enrollMessage}</p>
)}
<button
type="submit"
disabled={enrollBusy}
className={
"flex items-center justify-center gap-[7px] w-fit px-[14px] py-[7px] rounded-xl text-[12px] font-bold border transition-colors " +
(enrollBusy
? "bg-blue/20 border-blue/20 text-blue/40 cursor-not-allowed"
: "bg-blue/10 border-blue/30 text-blue hover:bg-blue/20 cursor-pointer")
}
>
<IconPlus size={13} />
{enrollStatus === "idle" || enrollStatus === "error"
? "Enroll YubiKey"
: enrollStatus === "starting"
? "Starting…"
: enrollStatus === "waiting_yubikey"
? "Touch YubiKey…"
: "Saving…"}
</button>
</form>
)}
</div>
</div>
) : (
<div className="h-full flex items-center justify-center text-foreground-sec text-[13px]">
Select a user
</div>
)}
</div>
</div>
{/* Mobile — stacked */}
<div className="lg:hidden flex-1 overflow-y-auto pt-[52px]">
{/* User list */}
<div className="p-[10px] border-b border-secondary">
<div className="flex items-center gap-[8px] bg-secondary/50 rounded-[10px] px-[10px] py-[6px]">
<IconSearch size={13} className="text-foreground-sec shrink-0" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users…"
className="flex-1 bg-transparent text-[12px] text-foreground placeholder:text-foreground-sec outline-none"
/>
</div>
</div>
<div className="p-[8px]">
{users === null ? (
<div className="flex flex-col gap-[4px]">
{[...Array(3)].map((_, i) => (
<div key={i} className="skeleton h-[52px] rounded-xl" />
))}
</div>
) : (
filtered.map((user) => {
const open = selected?.username === user.username;
return (
<div key={user.username} className="mb-[4px]">
<div
onClick={() => selectUser(open ? { ...user } : user)}
className={
"flex items-center justify-between gap-[10px] px-[10px] py-[9px] rounded-xl cursor-pointer transition-colors " +
(open ? "bg-blue/20" : "hover:bg-secondary/50")
}
>
<div className="flex items-center gap-[10px]">
<div
className={
"w-[28px] h-[28px] rounded-full flex items-center justify-center text-[11px] font-bold shrink-0 " +
(open
? "bg-blue text-primary"
: "bg-secondary text-foreground-sec")
}
>
{user.username[0]?.toUpperCase()}
</div>
<span
className={
"text-[13px] font-semibold " +
(open ? "text-blue" : "text-foreground")
}
>
{user.username}
</span>
</div>
<span
className={
"text-[10px] font-bold tracking-wider py-[2px] px-[8px] rounded-full " +
(open
? "bg-blue/30 text-blue"
: "bg-secondary text-foreground-sec")
}
>
{user.credentials.length} {user.credentials.length === 1 ? "key" : "keys"}
</span>
</div>
{open && (
<div className="mx-[4px] mb-[8px] px-[10px] py-[12px] rounded-xl bg-secondary/20 flex flex-col gap-[14px]">
{/* Keys */}
<div>
<p className="text-[10px] font-bold tracking-wider text-foreground-sec uppercase mb-[8px]">
Enrolled Keys
</p>
{user.credentials.length === 0 ? (
<p className="text-[12px] text-foreground-sec">No keys enrolled.</p>
) : (
<div className="flex flex-col gap-[5px]">
{user.credentials.map((cred) => (
<div
key={cred.id}
className="flex items-center gap-[8px] px-[10px] py-[8px] rounded-xl bg-secondary/40"
>
<IconKey size={12} className="text-blue shrink-0" />
<span className="text-[11px] font-mono text-foreground flex-1 truncate">
{truncId(cred.id)}
</span>
<button
onClick={() => deleteCredential(cred.id)}
disabled={deletingId === cred.id}
className="p-[4px] rounded-[6px] text-foreground-sec hover:text-red-400 hover:bg-red-400/10 transition-colors cursor-pointer"
>
{deletingId === cred.id ? (
<span className="w-[12px] h-[12px] border-2 border-current border-t-transparent rounded-full inline-block animate-spin" />
) : (
<IconTrash size={12} />
)}
</button>
</div>
))}
</div>
)}
</div>
{/* Enroll */}
<div>
<p className="text-[10px] font-bold tracking-wider text-foreground-sec uppercase mb-[8px]">
Enroll New Key
</p>
{enrollStatus === "done" ? (
<p className="text-[12px] text-green font-semibold">{enrollMessage}</p>
) : (
<form onSubmit={enrollKey} className="flex flex-col gap-[8px]">
<input
type="password"
value={enrollPassword}
onChange={(e) => setEnrollPassword(e.target.value)}
required
disabled={enrollBusy}
placeholder={`Password for ${user.username}`}
className="w-full px-[10px] py-[7px] bg-secondary/50 border border-secondary rounded-xl text-[12px] text-foreground outline-none"
/>
{enrollStatus === "waiting_yubikey" && (
<p className="text-[11px] text-blue animate-pulse text-center">
Touch your YubiKey
</p>
)}
{enrollStatus === "error" && enrollMessage && (
<p className="text-[11px] text-red-400">{enrollMessage}</p>
)}
<button
type="submit"
disabled={enrollBusy}
className="flex items-center justify-center gap-[6px] px-[12px] py-[6px] rounded-xl text-[12px] font-bold bg-blue/10 border border-blue/30 text-blue hover:bg-blue/20 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<IconPlus size={12} />
{enrollBusy ? "Working…" : "Enroll YubiKey"}
</button>
</form>
)}
</div>
</div>
)}
</div>
);
})
)}
</div>
</div>
</div>
);
}

View file

@ -21,6 +21,15 @@ export function middleware(req: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
// Public routes — accessible without auth
if (
pathname === "/" ||
pathname === "/analytics" ||
((pathname === "/api/power" || pathname === "/api/stats" || pathname === "/api/power/history") && req.method === "GET")
) {
return NextResponse.next();
}
// No token — redirect to login // No token — redirect to login
const token = req.cookies.get("token")?.value; const token = req.cookies.get("token")?.value;
if (!token) { if (!token) {
@ -33,5 +42,5 @@ export function middleware(req: NextRequest) {
} }
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.svg|.*\\.png|.*\\.jpg|.*\\.ico).*)"],
}; };

407
package-lock.json generated
View file

@ -12,7 +12,8 @@
"next": "16.2.1", "next": "16.2.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-icons": "^5.6.0" "react-icons": "^5.6.0",
"recharts": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@ -1228,6 +1229,42 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -1235,6 +1272,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -1552,6 +1601,69 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1587,7 +1699,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -1603,6 +1715,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.2", "version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
@ -2633,6 +2751,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2686,9 +2813,130 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -2768,6 +3016,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -3055,6 +3309,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.46.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -3508,6 +3772,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -3964,6 +4234,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -4006,6 +4286,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@ -5495,9 +5784,76 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -5542,6 +5898,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -6131,6 +6493,12 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -6468,6 +6836,37 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -14,7 +14,8 @@
"next": "16.2.1", "next": "16.2.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-icons": "^5.6.0" "react-icons": "^5.6.0",
"recharts": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

42
stores/windowStore.ts Normal file
View file

@ -0,0 +1,42 @@
"use client";
import { useSyncExternalStore } from "react";
import { PanelId } from "@/app/components/windows/types";
// ── State ─────────────────────────────────────────────────────────────────────
interface WindowFocus {
paneId: string | null;
panelId: PanelId | null;
}
let focus: WindowFocus = { paneId: null, panelId: null };
const focusListeners = new Set<() => void>();
const INITIAL_FOCUS: WindowFocus = { paneId: null, panelId: null };
export function setFocusedContext(paneId: string, panelId: PanelId) {
focus = { paneId, panelId };
focusListeners.forEach((l) => l());
}
export function useFocusedWindowState(): WindowFocus {
return useSyncExternalStore(
(cb) => { focusListeners.add(cb); return () => focusListeners.delete(cb); },
() => focus,
() => INITIAL_FOCUS,
);
}
// ── View change requests (sidebar → focused pane) ─────────────────────────────
type ViewChangeListener = (panelId: PanelId) => void;
const viewChangeListeners = new Set<ViewChangeListener>();
export function requestViewChange(panelId: PanelId) {
viewChangeListeners.forEach((l) => l(panelId));
}
export function subscribeViewChange(fn: ViewChangeListener): () => void {
viewChangeListeners.add(fn);
return () => { viewChangeListeners.delete(fn); };
}