From 43318fb8cdfa120e694643f5773f8f553d45f94e Mon Sep 17 00:00:00 2001 From: Jack Mechem Date: Fri, 22 May 2026 02:19:57 -0700 Subject: [PATCH] Tile windowing system --- app/analytics/page.tsx | 603 ++++++++++ app/api/power/history/route.ts | 8 + app/api/power/route.ts | 13 +- app/api/stats/route.ts | 15 +- .../[username]/credentials/[credId]/route.ts | 19 + .../users/[username]/enroll/finish/route.ts | 21 + .../users/[username]/enroll/start/route.ts | 22 + app/api/users/route.ts | 13 + app/components/NetworkCard.tsx | 8 +- app/components/PowerCard.tsx | 12 +- app/components/PowerGrid.tsx | 7 +- app/components/SideNav.tsx | 480 +++++--- app/components/SideNavWidgets.tsx | 234 ++++ app/components/StatCard.tsx | 2 +- app/components/UptimeCard.tsx | 4 +- app/components/panels/AnalyticsPanel.tsx | 1044 +++++++++++++++++ app/components/panels/DashboardPanel.tsx | 362 ++++++ app/components/panels/LinksPanel.tsx | 11 + app/components/panels/NetworkPanel.tsx | 44 + app/components/panels/OverviewPanel.tsx | 24 + app/components/panels/PowerPanel.tsx | 25 + app/components/panels/ServicesPanel.tsx | 33 + app/components/panels/UptimePanel.tsx | 24 + app/components/windows/WindowManager.tsx | 281 +++++ app/components/windows/WindowPane.tsx | 296 +++++ app/components/windows/treeUtils.ts | 75 ++ app/components/windows/types.ts | 43 + app/lib/getPower.ts | 1 - app/lib/getStats.ts | 4 - app/page.tsx | 189 +-- app/users/page.tsx | 639 ++++++++++ middleware.ts | 11 +- package-lock.json | 407 ++++++- package.json | 3 +- stores/windowStore.ts | 42 + 35 files changed, 4659 insertions(+), 360 deletions(-) create mode 100644 app/analytics/page.tsx create mode 100644 app/api/power/history/route.ts create mode 100644 app/api/users/[username]/credentials/[credId]/route.ts create mode 100644 app/api/users/[username]/enroll/finish/route.ts create mode 100644 app/api/users/[username]/enroll/start/route.ts create mode 100644 app/api/users/route.ts create mode 100644 app/components/SideNavWidgets.tsx create mode 100644 app/components/panels/AnalyticsPanel.tsx create mode 100644 app/components/panels/DashboardPanel.tsx create mode 100644 app/components/panels/LinksPanel.tsx create mode 100644 app/components/panels/NetworkPanel.tsx create mode 100644 app/components/panels/OverviewPanel.tsx create mode 100644 app/components/panels/PowerPanel.tsx create mode 100644 app/components/panels/ServicesPanel.tsx create mode 100644 app/components/panels/UptimePanel.tsx create mode 100644 app/components/windows/WindowManager.tsx create mode 100644 app/components/windows/WindowPane.tsx create mode 100644 app/components/windows/treeUtils.ts create mode 100644 app/components/windows/types.ts create mode 100644 app/users/page.tsx create mode 100644 stores/windowStore.ts diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx new file mode 100644 index 0000000..f739be6 --- /dev/null +++ b/app/analytics/page.tsx @@ -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 = { 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>(); + 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[], 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(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(null); + + const onMouseDown = (e: React.MouseEvent) => { + const rect = svgRef.current!.getBoundingClientRect(); + setSelecting({ startPx: e.clientX - rect.left, endPx: e.clientX - rect.left }); + }; + const onMouseMove = (e: React.MouseEvent) => { + 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 ( +
+ { onYDomainChange(null as any); onBrush(0, data.length - 1); }} + > + {/* Grid */} + {yTicks.map((v, i) => ( + + + {Math.round(v)}W + + ))} + {data.map((pt, i) => i % xStep !== 0 && i !== data.length - 1 ? null : ( + {pt.label} + ))} + + {/* 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 ( + { + const r = containerRef.current?.getBoundingClientRect(); + if (r) setTooltip({ x: e.clientX - r.left, y: e.clientY - r.top, bucket: pt }); + }} + onMouseLeave={() => setTooltip(null)} + > + + + + + ); + }); + })} + + {/* Drag selection overlay */} + {selecting && selX2 - selX1 > 2 && ( + + )} + + + {/* Hover tooltip */} + {tooltip && !selecting && ( +
+

{tooltip.bucket.label}

+ {activeDevices.map(name => { + const c = tooltip.bucket[name] as Candle | undefined; + if (!c || typeof c !== "object") return null; + return ( +
+ {name} + + O {c.open.toFixed(1)} H {c.high.toFixed(1)}
+ L {c.low.toFixed(1)} C {c.close.toFixed(1)} W +
+
+ ); + })} +
+ )} +
+ ); +} + +// ── Shared tooltip ──────────────────────────────────────────────────────────── + +const CustomTooltip = ({ active, payload, label }: any) => { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+ {payload.map((p: any) => ( +

+ {String(p.name).charAt(0).toUpperCase() + String(p.name).slice(1)}: {Number(p.value).toFixed(1)} W +

+ ))} +
+ ); +}; + +function SummaryCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) { + return ( +
+ {label} + {value} + {sub && {sub}} +
+ ); +} + +// ── 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([]); + const [loading, setLoading] = useState(true); + + // Chart + const [chartType, setChartType] = useState("line"); + const [activeDevices, setActiveDevices] = useState>(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(null); + const yDomainRef = useRef<[number, number] | null>(null); + const visibleRef = useRef[]>([]); + const activeListRef = useRef([]); + 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 = { 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 ( +
+ {}} /> + +
+
+ + {/* Header */} +
+
+

Power Analytics

+

+ {readings.length} readings · LADWP ${COST_PER_KWH}/kWh + {appliedRange && · {fmtDt(appliedRange.from)} – {fmtDt(appliedRange.to)}} +

+
+ + {/* Range picker */} +
+
+ {PRESETS.map(({ label, h }) => ( + + ))} + +
+ + {/* Custom date range picker */} + {showCustom && ( +
+
+ From + 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" /> +
+
+ To + 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" /> +
+ +
+ )} +
+
+ + {/* Summary cards */} +
+ + {summaries.map(({ name, summary }) => ( + + ))} + {summaries.map(({ name, summary }) => ( + + ))} +
+ + {/* Chart card */} +
+ {/* Controls row */} +
+
+

Power over time

+ {deviceNames.map(name => { + const active = activeDevices.has(name); + const color = DEVICE_COLORS[name] ?? "#888"; + return ( + + ); + })} +
+
+ {isZoomed && ( + + )} +
+ {(["line", "bar", "candle"] as ChartType[]).map(type => ( + + ))} +
+
+
+ +

+ {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"} +

+ + {/* Chart body */} + {loading ? ( +
+ ) : readings.length === 0 ? ( +
+ No data yet — readings are recorded every 5 minutes. +
+ ) : chartType === "candle" ? ( + setYDomain(d)} + onBrush={(s, e) => { + const base = candleBrushIdx ? candleBrushIdx[0] : 0; + setCandleBrushIdx([base + s, base + e]); + }} + /> + ) : ( +
+ + {chartType === "bar" ? ( + + + + `${v}W`} width={50} /> + } /> + String(v)[0].toUpperCase() + String(v).slice(1)} /> + {activeList.map(name => )} + { + setBrushIdx([startIndex as number, endIndex as number]); + setYDomain(null); + }} /> + + ) : ( + + + + `${v}W`} width={50} /> + } /> + String(v)[0].toUpperCase() + String(v).slice(1)} /> + {activeList.map(name => ( + + ))} + { + setBrushIdx([startIndex as number, endIndex as number]); + setYDomain(null); + }} /> + + )} + +
+ )} +
+ + {/* Device breakdown */} + {summaries.length > 0 && ( + <> +

Device breakdown

+
+ {summaries.map(({ name, summary }) => { + if (!summary) return null; + const color = DEVICE_COLORS[name] ?? "#888"; + return ( +
+
+ +

{name}

+
+
+ {[ + { 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 }) => ( +
+ {label} + {value} +
+ ))} +
+
+ ); + })} +
+ + )} +
+
+
+ ); +} diff --git a/app/api/power/history/route.ts b/app/api/power/history/route.ts new file mode 100644 index 0000000..2c5388a --- /dev/null +++ b/app/api/power/history/route.ts @@ -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()); +} diff --git a/app/api/power/route.ts b/app/api/power/route.ts index 5deca73..afca93d 100644 --- a/app/api/power/route.ts +++ b/app/api/power/route.ts @@ -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 }); diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts index bf0e244..8b98110 100644 --- a/app/api/stats/route.ts +++ b/app/api/stats/route.ts @@ -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()); diff --git a/app/api/users/[username]/credentials/[credId]/route.ts b/app/api/users/[username]/credentials/[credId]/route.ts new file mode 100644 index 0000000..376f5ea --- /dev/null +++ b/app/api/users/[username]/credentials/[credId]/route.ts @@ -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 }); +} diff --git a/app/api/users/[username]/enroll/finish/route.ts b/app/api/users/[username]/enroll/finish/route.ts new file mode 100644 index 0000000..746561c --- /dev/null +++ b/app/api/users/[username]/enroll/finish/route.ts @@ -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()); +} diff --git a/app/api/users/[username]/enroll/start/route.ts b/app/api/users/[username]/enroll/start/route.ts new file mode 100644 index 0000000..c00f3f0 --- /dev/null +++ b/app/api/users/[username]/enroll/start/route.ts @@ -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()); +} diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 0000000..211eb73 --- /dev/null +++ b/app/api/users/route.ts @@ -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()); +} diff --git a/app/components/NetworkCard.tsx b/app/components/NetworkCard.tsx index 392198a..7c11549 100644 --- a/app/components/NetworkCard.tsx +++ b/app/components/NetworkCard.tsx @@ -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` }} > -

+

Network

{iface && speed ? ( <> -

+

{iface}

@@ -25,7 +25,7 @@ export default function NetworkCard({ iface, speed, delay = 0 }: NetworkCardProp ↓ {formatBytes(speed.rx)}/s - + Download
@@ -33,7 +33,7 @@ export default function NetworkCard({ iface, speed, delay = 0 }: NetworkCardProp ↑ {formatBytes(speed.tx)}/s - + Upload
diff --git a/app/components/PowerCard.tsx b/app/components/PowerCard.tsx index dd86294..6ff2759 100644 --- a/app/components/PowerCard.tsx +++ b/app/components/PowerCard.tsx @@ -27,13 +27,13 @@ export default function PowerCard({ device, label, delay = 0, toggling = false, style={{ animationDelay: `${delay}ms` }} >
- + {label} {device ? (
@@ -48,7 +48,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
@@ -98,7 +98,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false, {(device.month_energy_wh / 1000).toFixed(2)} kWh
- + Month
@@ -106,7 +106,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false, {runtimeHours}h {runtimeMins}m - + Runtime diff --git a/app/components/PowerGrid.tsx b/app/components/PowerGrid.tsx index 66d5a1b..176aebe 100644 --- a/app/components/PowerGrid.tsx +++ b/app/components/PowerGrid.tsx @@ -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(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} /> handleToggle("desktop", on)} + onToggle={showControls ? (on) => handleToggle("desktop", on) : undefined} /> ); diff --git a/app/components/SideNav.tsx b/app/components/SideNav.tsx index 1459ea9..d6c4ae8 100644 --- a/app/components/SideNav.tsx +++ b/app/components/SideNav.tsx @@ -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 = { + "power-analytics": IconBolt, +}; + +const ANALYTICS_ICONS: Record = { + 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>( + 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 ( +
+ {/* Dashboard icon */} + + {/* Section items */} + {PANEL_SECTIONS.flatMap((s) => + s.items.map(({ panelId, label }) => { + const Icon = ANALYTICS_ICONS[panelId]; + const active = focusedPanelId === panelId; + return ( + + ); + }) + )} +
+ ); + } + + return ( +
+ {/* Dashboard standalone */} + + + {/* Power Analytics section */} + {PANEL_SECTIONS.map((section) => { + const SectionIcon = SECTION_ICONS[section.id] ?? IconBolt; + const isOpen = openSections.has(section.id); + + return ( +
+ + + {isOpen && ( +
+ {section.items.map(({ panelId, label }) => { + const Icon = ANALYTICS_ICONS[panelId]; + const active = focusedPanelId === panelId; + return ( + + ); + })} +
+ )} +
+ ); + })} +
+ ); +} + +// ── 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(isAuthed ?? null); const isDragging = useRef(false); const wrapperRef = useRef(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 */}
{/* Logo */} -
+
- logo + logo
- {/* Nav links */} -