Tile windowing system
This commit is contained in:
parent
c6e6c5ca48
commit
43318fb8cd
35 changed files with 4659 additions and 360 deletions
603
app/analytics/page.tsx
Normal file
603
app/analytics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
app/api/power/history/route.ts
Normal file
8
app/api/power/history/route.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -1,14 +1,7 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { 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/power", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
export async function GET() {
|
||||
const res = await fetch("http://localhost:3001/power");
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: "Upstream error" }, { status: res.status });
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { 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/stats", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
export async function GET() {
|
||||
const res = await fetch("http://localhost:3001/stats");
|
||||
|
||||
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());
|
||||
|
|
|
|||
19
app/api/users/[username]/credentials/[credId]/route.ts
Normal file
19
app/api/users/[username]/credentials/[credId]/route.ts
Normal 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 });
|
||||
}
|
||||
21
app/api/users/[username]/enroll/finish/route.ts
Normal file
21
app/api/users/[username]/enroll/finish/route.ts
Normal 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());
|
||||
}
|
||||
22
app/api/users/[username]/enroll/start/route.ts
Normal file
22
app/api/users/[username]/enroll/start/route.ts
Normal 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
13
app/api/users/route.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -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"
|
||||
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
|
||||
</p>
|
||||
{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}
|
||||
</p>
|
||||
<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">
|
||||
↓ {formatBytes(speed.rx)}/s
|
||||
</span>
|
||||
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
|
||||
<span className="text-[0.7rem] text-foreground-sec">
|
||||
Download
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -33,7 +33,7 @@ export default function NetworkCard({ iface, speed, delay = 0 }: NetworkCardProp
|
|||
<span className="text-lg font-medium text-blue/70">
|
||||
↑ {formatBytes(speed.tx)}/s
|
||||
</span>
|
||||
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
|
||||
<span className="text-[0.7rem] text-foreground-sec">
|
||||
Upload
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
|
|||
style={{ animationDelay: `${delay}ms` }}
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
{device ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
|
|
@ -48,7 +48,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
|
|||
<button
|
||||
onClick={() => onToggle(!device.on)}
|
||||
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
|
||||
? "border-red-500/20 text-red-400 hover:bg-red-500/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)}
|
||||
<span className="text-foreground-sec text-xs ml-0.5">kWh</span>
|
||||
</span>
|
||||
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
|
||||
<span className="text-[0.7rem] text-foreground-sec">
|
||||
Today
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -98,7 +98,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
|
|||
{(device.month_energy_wh / 1000).toFixed(2)}
|
||||
<span className="text-foreground-sec text-xs ml-0.5">kWh</span>
|
||||
</span>
|
||||
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
|
||||
<span className="text-[0.7rem] text-foreground-sec">
|
||||
Month
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -106,7 +106,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
|
|||
<span className="text-sm font-medium text-foreground">
|
||||
{runtimeHours}h {runtimeMins}m
|
||||
</span>
|
||||
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
|
||||
<span className="text-[0.7rem] text-foreground-sec">
|
||||
Runtime
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import PowerCard from "./PowerCard";
|
|||
interface PowerGridProps {
|
||||
power: PowerData | null;
|
||||
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 server = power?.devices.find((d) => d.name === "server") ?? null;
|
||||
|
|
@ -34,14 +35,14 @@ export default function PowerGrid({ power, onRefresh }: PowerGridProps) {
|
|||
label="Server"
|
||||
delay={0}
|
||||
toggling={toggling === "server"}
|
||||
onToggle={(on) => handleToggle("server", on)}
|
||||
onToggle={showControls ? (on) => handleToggle("server", on) : undefined}
|
||||
/>
|
||||
<PowerCard
|
||||
device={desktop}
|
||||
label="Desktop"
|
||||
delay={60}
|
||||
toggling={toggling === "desktop"}
|
||||
onToggle={(on) => handleToggle("desktop", on)}
|
||||
onToggle={showControls ? (on) => handleToggle("desktop", on) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,58 +4,186 @@ import { useState, useRef, useEffect } from "react";
|
|||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
IconHome2,
|
||||
IconMoon,
|
||||
IconSun,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconMenu2,
|
||||
IconX,
|
||||
IconCode,
|
||||
IconKey,
|
||||
IconLogout,
|
||||
IconHome2, IconMoon, IconSun, IconChevronsLeft, IconChevronsRight,
|
||||
IconMenu2, IconX, IconCode, IconKey, IconLogout, IconUsers, IconChartLine,
|
||||
IconChevronDown, IconBolt, IconChartBar, IconChartCandle,
|
||||
} from "@tabler/icons-react";
|
||||
import { useSetTheme } from "@/stores/useThemeStore";
|
||||
|
||||
const LINKS = [
|
||||
{ href: "/", label: "Dashboard", icon: IconHome2 },
|
||||
{ href: "/auth", label: "Auth", icon: IconKey },
|
||||
];
|
||||
import { useFocusedWindowState, requestViewChange } from "@/stores/windowStore";
|
||||
import { PANEL_SECTIONS, type PanelId } from "@/app/components/windows/types";
|
||||
import { SideNavWidgets } from "./SideNavWidgets";
|
||||
|
||||
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 {
|
||||
online: boolean;
|
||||
devConsoleOpen: boolean;
|
||||
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 router = useRouter();
|
||||
const setTheme = useSetTheme();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(168);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(220);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [auth, setAuth] = useState(false);
|
||||
const [auth, setAuth] = useState<boolean | null>(isAuthed ?? null);
|
||||
const isDragging = useRef(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMenuOpen(false);
|
||||
}, [pathname]);
|
||||
const { panelId: focusedPanelId } = useFocusedWindowState();
|
||||
const isHome = pathname === "/";
|
||||
|
||||
useEffect(() => { setMenuOpen(false); }, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/check")
|
||||
.then((r) => setAuth(r.ok))
|
||||
.catch(() => setAuth(false));
|
||||
}, []);
|
||||
if (isAuthed !== undefined) return;
|
||||
fetch("/api/auth/check").then((r) => setAuth(r.ok)).catch(() => setAuth(false));
|
||||
}, [isAuthed]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!isDragging.current || !wrapperRef.current) return;
|
||||
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; };
|
||||
window.addEventListener("mousemove", onMove);
|
||||
|
|
@ -71,100 +199,105 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) =
|
|||
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 (
|
||||
<>
|
||||
{/* Desktop sidebar + drag handle */}
|
||||
{/* Desktop sidebar */}
|
||||
<div ref={wrapperRef} className="hidden lg:flex flex-row shrink-0 select-none">
|
||||
<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"
|
||||
>
|
||||
{/* 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="/">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="logo"
|
||||
className={collapsed ? "max-h-[24px]" : "max-h-[36px]"}
|
||||
/>
|
||||
<img src="/logo.svg" alt="logo" className={collapsed ? "max-h-[24px]" : "max-h-[36px]"} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex flex-col gap-[2px] px-[8px] flex-1">
|
||||
{LINKS.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname === href || (href !== "/" && pathname?.startsWith(href));
|
||||
{/* Auth/users route links */}
|
||||
<nav className="flex flex-col gap-[2px] px-[8px] shrink-0">
|
||||
{auth === false && (() => {
|
||||
const active = pathname === "/auth";
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
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 href="/auth" title={collapsed ? "Auth" : undefined} className={navItemClass(active, collapsed)}>
|
||||
<IconKey size={16} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
|
||||
{!collapsed && "Auth"}
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="px-[8px] mb-[2px] shrink-0">
|
||||
<div
|
||||
title={collapsed ? (online ? "Online" : "Connecting...") : undefined}
|
||||
className={
|
||||
"w-full flex items-center rounded-[8px] font-medium text-foreground-sec " +
|
||||
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="w-[7px] h-[7px] rounded-full shrink-0"
|
||||
style={{
|
||||
background: online ? "#5dd776" : "#7b899a",
|
||||
animation: online ? "pulse-dot 2s infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
<div className="px-[8px] mb-[2px] shrink-0 mt-auto">
|
||||
<div title={collapsed ? (online ? "Online" : "Connecting...") : undefined}
|
||||
className={"w-full flex items-center rounded-[8px] font-medium text-foreground-sec " +
|
||||
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
|
||||
<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...")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dev console toggle */}
|
||||
<div className="px-[8px] shrink-0">
|
||||
<button
|
||||
onClick={onToggleDevConsole}
|
||||
title={collapsed ? "Dev Console" : undefined}
|
||||
className={
|
||||
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium " +
|
||||
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") +
|
||||
(devConsoleOpen
|
||||
? "bg-blue/10 text-blue"
|
||||
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground")
|
||||
}
|
||||
>
|
||||
<IconCode size={16} className="shrink-0" />
|
||||
{!collapsed && "Dev Console"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Dev console */}
|
||||
{auth && (
|
||||
<div className="px-[8px] shrink-0">
|
||||
<button onClick={onToggleDevConsole} title={collapsed ? "Dev Console" : undefined}
|
||||
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium " +
|
||||
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") +
|
||||
(devConsoleOpen ? "bg-blue/10 text-blue" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground")}>
|
||||
<IconCode size={16} className="shrink-0" />
|
||||
{!collapsed && "Dev Console"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logout */}
|
||||
{auth && (
|
||||
<div className="px-[8px] shrink-0">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title={collapsed ? "Log out" : undefined}
|
||||
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")
|
||||
}
|
||||
>
|
||||
<button onClick={handleLogout} title={collapsed ? "Log out" : undefined}
|
||||
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" />
|
||||
{!collapsed && "Log out"}
|
||||
</button>
|
||||
|
|
@ -173,36 +306,22 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) =
|
|||
|
||||
{/* Theme toggle */}
|
||||
<div className="px-[8px] mt-[4px] shrink-0">
|
||||
<button
|
||||
onClick={setTheme}
|
||||
title={collapsed ? "Toggle theme" : undefined}
|
||||
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")
|
||||
}
|
||||
>
|
||||
<button onClick={setTheme} title={collapsed ? "Toggle theme" : undefined}
|
||||
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" />
|
||||
<IconSun size={16} className="shrink-0 hidden dark-theme:block" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="dark-theme:hidden">Dark mode</span>
|
||||
<span className="hidden dark-theme:block">Light mode</span>
|
||||
</>
|
||||
)}
|
||||
{!collapsed && <><span className="dark-theme:hidden">Dark mode</span><span className="hidden dark-theme:block">Light mode</span></>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider + collapse toggle */}
|
||||
{/* Divider + collapse */}
|
||||
<div className="mx-[8px] my-[8px] border-t border-secondary shrink-0" />
|
||||
<div className="px-[8px] shrink-0">
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
<button onClick={() => setCollapsed((c) => !c)}
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
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")
|
||||
}
|
||||
>
|
||||
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")}>
|
||||
{collapsed
|
||||
? <IconChevronsRight size={16} className="shrink-0" />
|
||||
: <IconChevronsLeft size={16} className="shrink-0" />}
|
||||
|
|
@ -213,10 +332,8 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) =
|
|||
|
||||
{/* Drag handle */}
|
||||
{!collapsed && (
|
||||
<div
|
||||
onMouseDown={(e) => { isDragging.current = true; e.preventDefault(); }}
|
||||
className="w-[10px] shrink-0 flex items-center justify-center cursor-col-resize group"
|
||||
>
|
||||
<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 transition-colors" />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -227,63 +344,96 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) =
|
|||
<Link href="/" onClick={() => setMenuOpen(false)}>
|
||||
<img src="/logo.svg" alt="logo" className="max-h-[22px]" />
|
||||
</Link>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<button 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">
|
||||
{menuOpen ? <IconX size={18} /> : <IconMenu2 size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile dropdown menu */}
|
||||
{menuOpen && (
|
||||
<div className="lg:hidden fixed top-[52px] left-0 right-0 z-[997] bg-primary border-b border-secondary shadow-xl">
|
||||
<nav className="flex flex-col gap-[2px] p-[8px]">
|
||||
{LINKS.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname === href || (href !== "/" && pathname?.startsWith(href));
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => 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={16} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<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">
|
||||
{auth === false && (
|
||||
<nav className="flex flex-col gap-[2px] p-[8px]">
|
||||
<Link href="/auth" onClick={() => setMenuOpen(false)}
|
||||
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
|
||||
(pathname === "/auth" ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
|
||||
<IconKey size={16} strokeWidth={pathname === "/auth" ? 2.5 : 2} className="shrink-0" />
|
||||
Auth
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
{auth && (
|
||||
<nav className="flex flex-col gap-[2px] p-[8px]">
|
||||
<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")}>
|
||||
<IconUsers size={16} strokeWidth={pathname === "/users" ? 2.5 : 2} className="shrink-0" />
|
||||
User Management
|
||||
</Link>
|
||||
</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="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 && (
|
||||
<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"
|
||||
>
|
||||
<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 && (
|
||||
<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" />
|
||||
Log out
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<button 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">
|
||||
<IconMoon size={16} className="shrink-0 dark-theme:hidden" />
|
||||
<IconSun size={16} className="shrink-0 hidden dark-theme:block" />
|
||||
<span className="dark-theme:hidden">Dark mode</span>
|
||||
|
|
|
|||
234
app/components/SideNavWidgets.tsx
Normal file
234
app/components/SideNavWidgets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
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}
|
||||
</span>
|
||||
<span className="text-3xl font-medium tracking-tight text-foreground leading-none mt-1">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
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
|
||||
</p>
|
||||
{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">
|
||||
{pad(val)}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
1044
app/components/panels/AnalyticsPanel.tsx
Normal file
1044
app/components/panels/AnalyticsPanel.tsx
Normal file
File diff suppressed because it is too large
Load diff
362
app/components/panels/DashboardPanel.tsx
Normal file
362
app/components/panels/DashboardPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
app/components/panels/LinksPanel.tsx
Normal file
11
app/components/panels/LinksPanel.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import LinksGrid from "../LinksGrid";
|
||||
|
||||
export default function LinksPanel() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<LinksGrid />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
app/components/panels/NetworkPanel.tsx
Normal file
44
app/components/panels/NetworkPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
app/components/panels/OverviewPanel.tsx
Normal file
24
app/components/panels/OverviewPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
app/components/panels/PowerPanel.tsx
Normal file
25
app/components/panels/PowerPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
app/components/panels/ServicesPanel.tsx
Normal file
33
app/components/panels/ServicesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
app/components/panels/UptimePanel.tsx
Normal file
24
app/components/panels/UptimePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
281
app/components/windows/WindowManager.tsx
Normal file
281
app/components/windows/WindowManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
296
app/components/windows/WindowPane.tsx
Normal file
296
app/components/windows/WindowPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
app/components/windows/treeUtils.ts
Normal file
75
app/components/windows/treeUtils.ts
Normal 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;
|
||||
}
|
||||
43
app/components/windows/types.ts
Normal file
43
app/components/windows/types.ts
Normal 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;
|
||||
|
|
@ -18,7 +18,6 @@ export interface PowerData {
|
|||
|
||||
export async function getPower(): Promise<PowerData> {
|
||||
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}`);
|
||||
return res.json() as Promise<PowerData>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,10 +51,6 @@ export interface Stats {
|
|||
export async function getStats(): Promise<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}`);
|
||||
return res.json() as Promise<Stats>;
|
||||
}
|
||||
|
|
|
|||
189
app/page.tsx
189
app/page.tsx
|
|
@ -1,40 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { getStats, type Stats, type NetworkInterface } from "./lib/getStats";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import SideNav from "./components/SideNav";
|
||||
import Hero from "./components/Hero";
|
||||
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 WindowManager from "./components/windows/WindowManager";
|
||||
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() {
|
||||
const router = useRouter();
|
||||
useCheckAuth();
|
||||
|
||||
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 [isAuthed, setIsAuthed] = useState<boolean | null>(null);
|
||||
const [online, setOnline] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [panelWidth, setPanelWidth] = useState(440);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
|
||||
const prevNetRef = useRef<Record<string, NetworkInterface> | null>(null);
|
||||
const lastFetchRef = useRef<number>(0);
|
||||
const logIdRef = useRef(0);
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768);
|
||||
check();
|
||||
|
|
@ -43,8 +25,22 @@ export default function Home() {
|
|||
}, []);
|
||||
|
||||
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) => {
|
||||
const url =
|
||||
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 id = ++logIdRef.current;
|
||||
let path = url;
|
||||
try {
|
||||
path = new URL(url, window.location.origin).pathname;
|
||||
} catch {
|
||||
// use raw url
|
||||
}
|
||||
try { path = new URL(url, window.location.origin).pathname; } catch {}
|
||||
const timestamp = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const start = Date.now();
|
||||
|
||||
|
|
@ -81,147 +73,50 @@ export default function Home() {
|
|||
const text = await clone.text();
|
||||
const duration = Date.now() - start;
|
||||
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;
|
||||
} catch (err) {
|
||||
const duration = Date.now() - start;
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="w-full h-full bg-primary text-foreground overflow-hidden flex flex-row">
|
||||
<SideNav
|
||||
online={!!stats}
|
||||
online={online}
|
||||
devConsoleOpen={panelOpen}
|
||||
onToggleDevConsole={() => setPanelOpen((o) => !o)}
|
||||
/>
|
||||
|
||||
<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={{
|
||||
paddingRight: panelOpen && !isMobile ? panelWidth : 0,
|
||||
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">
|
||||
<Hero lastUpdated={stats?.timestamp ?? null} />
|
||||
|
||||
<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>
|
||||
{mounted && (
|
||||
<WindowManager isAuthed={!!isAuthed} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DevConsole
|
||||
open={panelOpen}
|
||||
width={panelWidth}
|
||||
isMobile={isMobile}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
onWidthChange={setPanelWidth}
|
||||
logs={logs}
|
||||
/>
|
||||
{mounted && isAuthed && (
|
||||
<DevConsole
|
||||
open={panelOpen}
|
||||
width={panelWidth}
|
||||
isMobile={isMobile}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
onWidthChange={setPanelWidth}
|
||||
logs={logs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
639
app/users/page.tsx
Normal file
639
app/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue