bar graph fix

This commit is contained in:
Jack Mechem 2026-05-22 15:10:54 -07:00
parent 43318fb8cd
commit 8c3d749197
15 changed files with 973 additions and 401 deletions

View file

@ -7,6 +7,7 @@ import {
} from "recharts"; } from "recharts";
import { IconRefresh } from "@tabler/icons-react"; import { IconRefresh } from "@tabler/icons-react";
import SideNav from "../components/SideNav"; import SideNav from "../components/SideNav";
import HelpTooltip from "../components/HelpTooltip";
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@ -406,20 +407,25 @@ export default function AnalyticsPage() {
</div> </div>
{/* Range picker */} {/* Range picker */}
<div className="flex flex-col gap-2 items-end"> <div className="flex flex-col gap-1.5 items-end">
<span className="text-[10px] font-medium text-foreground-sec">Time Range</span>
<div className="flex gap-1 bg-secondary/50 rounded-xl p-1"> <div className="flex gap-1 bg-secondary/50 rounded-xl p-1">
{PRESETS.map(({ label, h }) => ( {PRESETS.map(({ label, h }) => (
<button key={h} onClick={() => { setPresetH(h); setAppliedRange(null); setShowCustom(false); }} <HelpTooltip key={h} text={`Show data for the last ${label}.`}>
<button onClick={() => { setPresetH(h); setAppliedRange(null); setShowCustom(false); }}
className={"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer " + 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")}> (!appliedRange && presetH === h ? "bg-blue text-white" : "text-foreground-sec hover:text-foreground")}>
{label} {label}
</button> </button>
</HelpTooltip>
))} ))}
<HelpTooltip text="Enter a custom date and time range to query exactly the data you want.">
<button onClick={() => setShowCustom(s => !s)} <button onClick={() => setShowCustom(s => !s)}
className={"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer " + 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")}> (showCustom || appliedRange ? "bg-blue text-white" : "text-foreground-sec hover:text-foreground")}>
Custom Custom
</button> </button>
</HelpTooltip>
</div> </div>
{/* Custom date range picker */} {/* Custom date range picker */}
@ -435,11 +441,13 @@ export default function AnalyticsPage() {
<input type="datetime-local" value={customTo} onChange={e => setCustomTo(e.target.value)} <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" /> 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>
<HelpTooltip text="Apply the selected custom date range to the chart.">
<button <button
onClick={() => { if (customFrom && customTo) { setAppliedRange({ from: customFrom, to: customTo }); setShowCustom(false); } }} 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"> 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 Apply
</button> </button>
</HelpTooltip>
</div> </div>
)} )}
</div> </div>
@ -464,41 +472,57 @@ export default function AnalyticsPage() {
{/* Chart card */} {/* Chart card */}
<div className="bg-primary border border-secondary rounded-2xl p-5 mb-8"> <div className="bg-primary border border-secondary rounded-2xl p-5 mb-8">
{/* Controls row */} {/* Controls row */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-4"> <div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-3 mb-4">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex flex-col gap-1.5">
<h2 className="text-sm font-medium text-foreground">Power over time</h2> <h2 className="text-sm font-medium text-foreground">Power over time</h2>
{deviceNames.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] font-medium text-foreground-sec">Devices</span>
<div className="flex items-center gap-1.5 flex-wrap">
{deviceNames.map(name => { {deviceNames.map(name => {
const active = activeDevices.has(name); const active = activeDevices.has(name);
const color = DEVICE_COLORS[name] ?? "#888"; const color = DEVICE_COLORS[name] ?? "#888";
return ( return (
<button key={name} onClick={() => toggleDevice(name)} <HelpTooltip key={name} text={`Toggle ${name} on or off in the chart.`}>
<button 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" 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)" }}> 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="w-2 h-2 rounded-full" style={{ background: active ? color : "var(--color-secondary)" }} />
<span className="capitalize">{name}</span> <span className="capitalize">{name}</span>
</button> </button>
</HelpTooltip>
); );
})} })}
</div> </div>
<div className="flex items-center gap-2"> </div>
)}
</div>
<div className="flex items-end gap-3">
{isZoomed && ( {isZoomed && (
<HelpTooltip text="Reset the chart zoom back to the full selected time range.">
<button onClick={resetZoom} <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"> 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} /> <IconRefresh size={11} />
Reset zoom Reset zoom
</button> </button>
</HelpTooltip>
)} )}
<div className="flex flex-col gap-1">
<span className="text-[10px] font-medium text-foreground-sec">Chart Type</span>
<div className="flex gap-0.5 bg-secondary/50 rounded-lg p-0.5"> <div className="flex gap-0.5 bg-secondary/50 rounded-lg p-0.5">
{(["line", "bar", "candle"] as ChartType[]).map(type => ( {(["line", "bar", "candle"] as ChartType[]).map(type => (
<button key={type} onClick={() => { setChartType(type); resetZoom(); }} <HelpTooltip key={type} text={type === "line" ? "Line chart: shows power over time as a smooth curve." : type === "bar" ? "Bar chart: shows aggregated energy per time bucket." : "Candlestick chart: shows min/max/open/close power per period."}>
<button onClick={() => { setChartType(type); resetZoom(); }}
className={"px-3 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer capitalize " + 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")}> (chartType === type ? "bg-primary text-foreground shadow-sm" : "text-foreground-sec hover:text-foreground")}>
{type} {type}
</button> </button>
</HelpTooltip>
))} ))}
</div> </div>
</div> </div>
</div> </div>
</div>
<p className="text-[11px] text-foreground-sec/50 mb-3"> <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"} {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"}

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import HelpTooltip from "../components/HelpTooltip";
function b64uToBuf(b64u: string): ArrayBuffer { function b64uToBuf(b64u: string): ArrayBuffer {
const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/"); const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/");
@ -190,6 +191,7 @@ export default function AuthPage() {
<p className="text-[13px] text-red-400 mb-4">{error}</p> <p className="text-[13px] text-red-400 mb-4">{error}</p>
)} )}
<HelpTooltip text="Submit your username and password, then touch your YubiKey when prompted to complete sign-in." block>
<button <button
type="submit" type="submit"
disabled={busy} disabled={busy}
@ -204,6 +206,7 @@ export default function AuthPage() {
{status === "waiting_yubikey" && "Waiting for YubiKey…"} {status === "waiting_yubikey" && "Waiting for YubiKey…"}
{status === "verifying" && "Verifying…"} {status === "verifying" && "Verifying…"}
</button> </button>
</HelpTooltip>
</form> </form>
</div> </div>
</main> </main>

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { LuX, LuTerminal, LuSend } from "react-icons/lu"; import { LuX, LuTerminal, LuSend } from "react-icons/lu";
import HelpTooltip from "./HelpTooltip";
export interface LogEntry { export interface LogEntry {
id: number; id: number;
@ -175,8 +176,8 @@ export default function DevConsole({
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: "4px" }}> <div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: "4px" }}>
{(["logs", "request"] as const).map((tab) => ( {(["logs", "request"] as const).map((tab) => (
<HelpTooltip key={tab} text={tab === "logs" ? "View captured API request logs." : "Build and send a custom API request."}>
<button <button
key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
style={{ style={{
fontSize: "0.68rem", fontSize: "0.68rem",
@ -192,7 +193,9 @@ export default function DevConsole({
> >
{tab === "logs" ? `Logs${logs.length > 0 ? ` (${logs.length})` : ""}` : "Request"} {tab === "logs" ? `Logs${logs.length > 0 ? ` (${logs.length})` : ""}` : "Request"}
</button> </button>
</HelpTooltip>
))} ))}
<HelpTooltip text="Close the dev console.">
<button <button
onClick={onClose} onClick={onClose}
style={{ style={{
@ -208,6 +211,7 @@ export default function DevConsole({
> >
<LuX size={13} /> <LuX size={13} />
</button> </button>
</HelpTooltip>
</div> </div>
</div> </div>
@ -273,6 +277,7 @@ export default function DevConsole({
minWidth: 0, minWidth: 0,
}} }}
/> />
<HelpTooltip text="Send the HTTP request to the API and display the response below.">
<button <button
onClick={sendRequest} onClick={sendRequest}
disabled={reqLoading} disabled={reqLoading}
@ -295,6 +300,7 @@ export default function DevConsole({
<LuSend size={11} /> <LuSend size={11} />
Send Send
</button> </button>
</HelpTooltip>
</div> </div>
{["POST", "PUT", "PATCH"].includes(reqMethod) && ( {["POST", "PUT", "PATCH"].includes(reqMethod) && (

View file

@ -0,0 +1,130 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { useHelpMode } from "@/stores/helpModeStore";
interface HelpTooltipProps {
text: string;
children: React.ReactNode;
/** Use block=true for full-width elements so the ? sits to the right without breaking layout */
block?: boolean;
/** Set hidden=true to suppress the ? badge (e.g. collapsed sidebar items) */
hidden?: boolean;
}
export default function HelpTooltip({ text, children, block = false, hidden = false }: HelpTooltipProps) {
const helpMode = useHelpMode();
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const flyoutRef = useRef<HTMLDivElement>(null);
const touchHandled = useRef(false);
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
useEffect(() => {
if (!helpMode) setOpen(false);
}, [helpMode]);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent | TouchEvent) => {
const target = e.target as Node;
if (!btnRef.current?.contains(target) && !flyoutRef.current?.contains(target)) {
setOpen(false);
}
};
document.addEventListener("mousedown", close);
document.addEventListener("touchstart", close, { passive: true });
return () => {
document.removeEventListener("mousedown", close);
document.removeEventListener("touchstart", close);
};
}, [open]);
const calcPos = useCallback(() => {
if (!btnRef.current) return;
const rect = btnRef.current.getBoundingClientRect();
const flyW = 210;
const x = rect.right + 8 + flyW > window.innerWidth
? Math.max(4, rect.left - flyW - 8)
: rect.right + 8;
const y = Math.min(rect.top - 4, window.innerHeight - 130);
setPos({ x, y });
}, []);
const handleClick = useCallback((e: React.MouseEvent) => {
if (touchHandled.current) { touchHandled.current = false; return; }
e.stopPropagation();
calcPos();
setOpen((v) => !v);
}, [calcPos]);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
touchHandled.current = true;
calcPos();
setOpen((v) => !v);
}, [calcPos]);
if (!helpMode || hidden) return <>{children}</>;
const qBtn = (
<button
ref={btnRef}
onClick={handleClick}
onTouchEnd={handleTouchEnd}
aria-label="Help"
style={{
display: "inline-flex", alignItems: "center", justifyContent: "center",
width: 14, height: 14, borderRadius: "50%", flexShrink: 0,
border: "1px solid var(--color-blue)",
background: "color-mix(in srgb, var(--color-blue) 15%, transparent)",
color: "var(--color-blue)",
fontSize: 9, fontWeight: 700, lineHeight: 1,
cursor: "pointer", padding: 0,
}}
>
?
</button>
);
return (
<>
{block ? (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
{qBtn}
</div>
) : (
<span style={{ display: "inline-flex", alignItems: "center", gap: 3 }}>
{children}
{qBtn}
</span>
)}
{open && mounted && pos && createPortal(
<div
ref={flyoutRef}
style={{
position: "fixed", left: pos.x, top: pos.y, zIndex: 9999,
width: 210,
background: "var(--color-primary)",
border: "1px solid var(--color-secondary)",
borderRadius: 10,
padding: "10px 12px",
fontSize: 12,
color: "var(--color-foreground)",
lineHeight: 1.55,
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
}}
>
{text}
</div>,
document.body
)}
</>
);
}

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { type TapoDevice } from "../lib/getPower"; import { type TapoDevice } from "../lib/getPower";
import HelpTooltip from "./HelpTooltip";
interface PowerCardProps { interface PowerCardProps {
device: TapoDevice | null; device: TapoDevice | null;
@ -45,6 +46,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
{device.on ? "On" : "Off"} {device.on ? "On" : "Off"}
</span> </span>
{onToggle && ( {onToggle && (
<HelpTooltip text="Remotely turn this smart plug on or off.">
<button <button
onClick={() => onToggle(!device.on)} onClick={() => onToggle(!device.on)}
disabled={toggling} disabled={toggling}
@ -56,6 +58,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
> >
{toggling ? "···" : device.on ? "Turn off" : "Turn on"} {toggling ? "···" : device.on ? "Turn off" : "Turn on"}
</button> </button>
</HelpTooltip>
)} )}
</div> </div>
) : null} ) : null}
@ -70,7 +73,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
<span className="text-base text-foreground-sec font-medium">W</span> <span className="text-base text-foreground-sec font-medium">W</span>
</div> </div>
<span className="text-[0.7rem] text-foreground-sec mt-1 truncate"> <span className="text-[0.7rem] text-foreground-sec mt-1 truncate">
{device.alias} · {device.model} {device.model} · {device.ip}
</span> </span>
<div className="h-[3px] bg-secondary rounded-full mt-4 overflow-hidden"> <div className="h-[3px] bg-secondary rounded-full mt-4 overflow-hidden">

View file

@ -13,9 +13,6 @@ interface PowerGridProps {
export default function PowerGrid({ power, onRefresh, showControls = true }: PowerGridProps) { export default function PowerGrid({ power, onRefresh, showControls = true }: PowerGridProps) {
const [toggling, setToggling] = useState<string | null>(null); const [toggling, setToggling] = useState<string | null>(null);
const server = power?.devices.find((d) => d.name === "server") ?? null;
const desktop = power?.devices.find((d) => d.name === "desktop") ?? null;
const handleToggle = async (deviceName: string, on: boolean) => { const handleToggle = async (deviceName: string, on: boolean) => {
setToggling(deviceName); setToggling(deviceName);
try { try {
@ -28,22 +25,27 @@ export default function PowerGrid({ power, onRefresh, showControls = true }: Pow
} }
}; };
const devices = power?.devices ?? [];
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5 mb-11"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3.5 mb-11">
{devices.length > 0 ? (
devices.map((device, i) => (
<PowerCard <PowerCard
device={server} key={device.ip}
label="Server" device={device}
delay={0} label={device.name}
toggling={toggling === "server"} delay={i * 60}
onToggle={showControls ? (on) => handleToggle("server", on) : undefined} toggling={toggling === device.name}
/> onToggle={showControls ? (on) => handleToggle(device.name, on) : undefined}
<PowerCard
device={desktop}
label="Desktop"
delay={60}
toggling={toggling === "desktop"}
onToggle={showControls ? (on) => handleToggle("desktop", on) : undefined}
/> />
))
) : (
<>
<PowerCard device={null} label="" delay={0} />
<PowerCard device={null} label="" delay={60} />
</>
)}
</div> </div>
); );
} }

View file

@ -6,9 +6,11 @@ import { usePathname, useRouter } from "next/navigation";
import { import {
IconHome2, IconMoon, IconSun, IconChevronsLeft, IconChevronsRight, IconHome2, IconMoon, IconSun, IconChevronsLeft, IconChevronsRight,
IconMenu2, IconX, IconCode, IconKey, IconLogout, IconUsers, IconChartLine, IconMenu2, IconX, IconCode, IconKey, IconLogout, IconUsers, IconChartLine,
IconChevronDown, IconBolt, IconChartBar, IconChartCandle, IconChevronDown, IconBolt, IconChartBar, IconChartCandle, IconHelpCircle,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useSetTheme } from "@/stores/useThemeStore"; import { useSetTheme } from "@/stores/useThemeStore";
import { useHelpMode, useToggleHelpMode } from "@/stores/helpModeStore";
import HelpTooltip from "./HelpTooltip";
import { useFocusedWindowState, requestViewChange } from "@/stores/windowStore"; import { useFocusedWindowState, requestViewChange } from "@/stores/windowStore";
import { PANEL_SECTIONS, type PanelId } from "@/app/components/windows/types"; import { PANEL_SECTIONS, type PanelId } from "@/app/components/windows/types";
import { SideNavWidgets } from "./SideNavWidgets"; import { SideNavWidgets } from "./SideNavWidgets";
@ -51,7 +53,6 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP
if (collapsed) { if (collapsed) {
return ( return (
<div className="flex flex-col gap-[2px] px-[8px] mt-1"> <div className="flex flex-col gap-[2px] px-[8px] mt-1">
{/* Dashboard icon */}
<button <button
onClick={() => requestViewChange("dashboard")} onClick={() => requestViewChange("dashboard")}
title="Dashboard" title="Dashboard"
@ -64,7 +65,6 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP
> >
<IconHome2 size={15} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" /> <IconHome2 size={15} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
</button> </button>
{/* Section items */}
{PANEL_SECTIONS.flatMap((s) => {PANEL_SECTIONS.flatMap((s) =>
s.items.map(({ panelId, label }) => { s.items.map(({ panelId, label }) => {
const Icon = ANALYTICS_ICONS[panelId]; const Icon = ANALYTICS_ICONS[panelId];
@ -92,7 +92,7 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP
return ( return (
<div className="flex flex-col gap-[1px] px-[8px] mt-1"> <div className="flex flex-col gap-[1px] px-[8px] mt-1">
{/* Dashboard standalone */} <HelpTooltip text="Switch to the main dashboard showing live system stats and power usage." block>
<button <button
onClick={() => requestViewChange("dashboard")} onClick={() => requestViewChange("dashboard")}
className={[ className={[
@ -105,14 +105,15 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP
<IconHome2 size={14} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" /> <IconHome2 size={14} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
Dashboard Dashboard
</button> </button>
</HelpTooltip>
{/* Power Analytics section */}
{PANEL_SECTIONS.map((section) => { {PANEL_SECTIONS.map((section) => {
const SectionIcon = SECTION_ICONS[section.id] ?? IconBolt; const SectionIcon = SECTION_ICONS[section.id] ?? IconBolt;
const isOpen = openSections.has(section.id); const isOpen = openSections.has(section.id);
return ( return (
<div key={section.id}> <div key={section.id}>
<HelpTooltip text={`Expand or collapse the ${section.label} section.`} block>
<button <button
onClick={() => toggleSection(section.id)} 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" 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"
@ -125,6 +126,7 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP
style={{ transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)" }} style={{ transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)" }}
/> />
</button> </button>
</HelpTooltip>
{isOpen && ( {isOpen && (
<div className="flex flex-col gap-[1px] pl-[6px]"> <div className="flex flex-col gap-[1px] pl-[6px]">
@ -132,8 +134,8 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP
const Icon = ANALYTICS_ICONS[panelId]; const Icon = ANALYTICS_ICONS[panelId];
const active = focusedPanelId === panelId; const active = focusedPanelId === panelId;
return ( return (
<HelpTooltip key={panelId} text={`Open the ${label} analytics chart.`} block>
<button <button
key={panelId}
onClick={() => requestViewChange(panelId)} onClick={() => requestViewChange(panelId)}
className={[ className={[
"w-full flex items-center gap-[8px] px-[10px] py-[6px] text-[12px] rounded-[8px] transition-colors cursor-pointer", "w-full flex items-center gap-[8px] px-[10px] py-[6px] text-[12px] rounded-[8px] transition-colors cursor-pointer",
@ -145,6 +147,7 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP
<Icon size={13} strokeWidth={active ? 2.5 : 2} className="shrink-0" /> <Icon size={13} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
{label} {label}
</button> </button>
</HelpTooltip>
); );
})} })}
</div> </div>
@ -162,6 +165,8 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const setTheme = useSetTheme(); const setTheme = useSetTheme();
const helpMode = useHelpMode();
const toggleHelp = useToggleHelpMode();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(220); const [sidebarWidth, setSidebarWidth] = useState(220);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@ -282,6 +287,7 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
{/* Dev console */} {/* Dev console */}
{auth && ( {auth && (
<div className="px-[8px] shrink-0"> <div className="px-[8px] shrink-0">
<HelpTooltip text="Open the dev console to inspect live API requests and send test requests." block hidden={collapsed}>
<button onClick={onToggleDevConsole} title={collapsed ? "Dev Console" : undefined} <button onClick={onToggleDevConsole} title={collapsed ? "Dev Console" : undefined}
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium " + 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 ") + (collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") +
@ -289,23 +295,27 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
<IconCode size={16} className="shrink-0" /> <IconCode size={16} className="shrink-0" />
{!collapsed && "Dev Console"} {!collapsed && "Dev Console"}
</button> </button>
</HelpTooltip>
</div> </div>
)} )}
{/* Logout */} {/* Logout */}
{auth && ( {auth && (
<div className="px-[8px] shrink-0"> <div className="px-[8px] shrink-0">
<HelpTooltip text="Sign out of your account and return to the login screen." block hidden={collapsed}>
<button onClick={handleLogout} title={collapsed ? "Log out" : undefined} <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 " + 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")}> (collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
<IconLogout size={16} className="shrink-0" /> <IconLogout size={16} className="shrink-0" />
{!collapsed && "Log out"} {!collapsed && "Log out"}
</button> </button>
</HelpTooltip>
</div> </div>
)} )}
{/* Theme toggle */} {/* Theme toggle */}
<div className="px-[8px] mt-[4px] shrink-0"> <div className="px-[8px] mt-[4px] shrink-0">
<HelpTooltip text="Switch between light and dark color scheme." block hidden={collapsed}>
<button onClick={setTheme} title={collapsed ? "Toggle theme" : undefined} <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 " + 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 ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
@ -313,11 +323,26 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
<IconSun size={16} className="shrink-0 hidden dark-theme:block" /> <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> </button>
</HelpTooltip>
</div>
{/* Help mode toggle */}
<div className="px-[8px] shrink-0">
<HelpTooltip text="Toggle help mode — shows a ? badge next to every button explaining what it does." block hidden={collapsed}>
<button onClick={toggleHelp} title={collapsed ? "Help mode" : 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 ") +
(helpMode ? "text-blue bg-blue/10" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground")}>
<IconHelpCircle size={16} className="shrink-0" />
{!collapsed && "Help mode"}
</button>
</HelpTooltip>
</div> </div>
{/* Divider + collapse */} {/* Divider + collapse */}
<div className="mx-[8px] my-[8px] border-t border-secondary shrink-0" /> <div className="mx-[8px] my-[8px] border-t border-secondary shrink-0" />
<div className="px-[8px] shrink-0"> <div className="px-[8px] shrink-0">
<HelpTooltip text="Collapse the sidebar to icons only to give more space to the main content." block hidden={collapsed}>
<button onClick={() => setCollapsed((c) => !c)} <button onClick={() => setCollapsed((c) => !c)}
title={collapsed ? "Expand sidebar" : "Collapse sidebar"} 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 " + className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " +
@ -327,6 +352,7 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
: <IconChevronsLeft size={16} className="shrink-0" />} : <IconChevronsLeft size={16} className="shrink-0" />}
{!collapsed && "Collapse"} {!collapsed && "Collapse"}
</button> </button>
</HelpTooltip>
</div> </div>
</div> </div>
@ -344,10 +370,12 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
<Link href="/" onClick={() => setMenuOpen(false)}> <Link href="/" onClick={() => setMenuOpen(false)}>
<img src="/logo.svg" alt="logo" className="max-h-[22px]" /> <img src="/logo.svg" alt="logo" className="max-h-[22px]" />
</Link> </Link>
<HelpTooltip text="Open or close the navigation menu.">
<button onClick={() => setMenuOpen((o) => !o)} <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"> className="ml-auto p-[7px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer">
{menuOpen ? <IconX size={18} /> : <IconMenu2 size={18} />} {menuOpen ? <IconX size={18} /> : <IconMenu2 size={18} />}
</button> </button>
</HelpTooltip>
</div> </div>
{/* Mobile dropdown menu */} {/* Mobile dropdown menu */}
@ -383,6 +411,7 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
Views Views
</p> </p>
{/* Dashboard */} {/* Dashboard */}
<HelpTooltip text="Switch to the main dashboard showing live system stats and power usage." block>
<button <button
onClick={() => { requestViewChange("dashboard"); setMenuOpen(false); }} 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 " + className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
@ -390,6 +419,7 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
<IconHome2 size={15} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" /> <IconHome2 size={15} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
Dashboard Dashboard
</button> </button>
</HelpTooltip>
{/* Sections */} {/* Sections */}
{PANEL_SECTIONS.map((section) => ( {PANEL_SECTIONS.map((section) => (
<div key={section.id} className="mb-1 mt-2"> <div key={section.id} className="mb-1 mt-2">
@ -400,13 +430,15 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
const Icon = ANALYTICS_ICONS[panelId]; const Icon = ANALYTICS_ICONS[panelId];
const active = focusedPanelId === panelId; const active = focusedPanelId === panelId;
return ( return (
<button key={panelId} <HelpTooltip key={panelId} text={`Open the ${label} analytics chart.`} block>
<button
onClick={() => { requestViewChange(panelId); setMenuOpen(false); }} 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 " + 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")}> (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" /> <Icon size={15} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
{label} {label}
</button> </button>
</HelpTooltip>
); );
})} })}
</div> </div>
@ -418,20 +450,25 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
<div className="mx-[8px] border-t border-secondary" /> <div className="mx-[8px] border-t border-secondary" />
<div className="p-[8px] flex flex-col gap-[2px]"> <div className="p-[8px] flex flex-col gap-[2px]">
{auth && ( {auth && (
<HelpTooltip text="Open the dev console to inspect live API requests and send test requests." block>
<button onClick={() => { onToggleDevConsole(); setMenuOpen(false); }} <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 " + 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")}> (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" /> <IconCode size={16} className="shrink-0" />
Dev Console Dev Console
</button> </button>
</HelpTooltip>
)} )}
{auth && ( {auth && (
<HelpTooltip text="Sign out of your account and return to the login screen." block>
<button onClick={handleLogout} <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"> className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer font-medium">
<IconLogout size={16} className="shrink-0" /> <IconLogout size={16} className="shrink-0" />
Log out Log out
</button> </button>
</HelpTooltip>
)} )}
<HelpTooltip text="Switch between light and dark color scheme." block>
<button onClick={setTheme} <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"> className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer font-medium">
<IconMoon size={16} className="shrink-0 dark-theme:hidden" /> <IconMoon size={16} className="shrink-0 dark-theme:hidden" />
@ -439,6 +476,15 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
<span className="dark-theme:hidden">Dark mode</span> <span className="dark-theme:hidden">Dark mode</span>
<span className="hidden dark-theme:block">Light mode</span> <span className="hidden dark-theme:block">Light mode</span>
</button> </button>
</HelpTooltip>
<HelpTooltip text="Toggle help mode — shows a ? badge next to every button explaining what it does." block>
<button onClick={() => { toggleHelp(); setMenuOpen(false); }}
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer font-medium " +
(helpMode ? "text-blue bg-blue/10" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground")}>
<IconHelpCircle size={16} className="shrink-0" />
Help mode
</button>
</HelpTooltip>
</div> </div>
</div> </div>
)} )}

View file

@ -2,28 +2,24 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { IconLayoutGrid } from "@tabler/icons-react"; import { IconLayoutGrid } from "@tabler/icons-react";
import HelpTooltip from "./HelpTooltip";
import { type Stats, type NetworkInterface } from "../lib/getStats"; import { type Stats, type NetworkInterface } from "../lib/getStats";
import { type PowerData } from "../lib/getPower"; import { type PowerData } from "../lib/getPower";
import { formatBytes, statColor } from "../lib/utils"; import { formatBytes, statColor } from "../lib/utils";
export type WidgetId = export type WidgetId = string;
| "cpu" | "memory" | "disk" | "temp"
| "power-server" | "power-desktop"
| "network" | "uptime" | "empty";
const WIDGET_OPTIONS: { id: WidgetId; label: string }[] = [ const STATIC_WIDGET_OPTIONS: { id: WidgetId; label: string }[] = [
{ id: "empty", label: "Empty" }, { id: "empty", label: "Empty" },
{ id: "cpu", label: "CPU" }, { id: "cpu", label: "CPU" },
{ id: "memory", label: "Memory" }, { id: "memory", label: "Memory" },
{ id: "disk", label: "Disk" }, { id: "disk", label: "Disk" },
{ id: "temp", label: "Temp" }, { id: "temp", label: "Temp" },
{ id: "power-server", label: "Server Power" },
{ id: "power-desktop", label: "Desktop Power" },
{ id: "network", label: "Network" }, { id: "network", label: "Network" },
{ id: "uptime", label: "Uptime" }, { id: "uptime", label: "Uptime" },
]; ];
const DEFAULT_WIDGETS: WidgetId[] = ["power-server", "power-desktop", "cpu", "memory"]; const DEFAULT_WIDGETS: WidgetId[] = ["cpu", "memory", "network", "uptime"];
const STORAGE_KEY = "sidenav-widgets"; const STORAGE_KEY = "sidenav-widgets";
const card = "flex flex-col gap-2 p-3 bg-secondary/30 border border-secondary rounded-xl"; const card = "flex flex-col gap-2 p-3 bg-secondary/30 border border-secondary rounded-xl";
@ -104,14 +100,20 @@ function WidgetSlot({
editing: boolean; editing: boolean;
onChange: (id: WidgetId) => void; onChange: (id: WidgetId) => void;
}) { }) {
const powerOptions = (power?.devices ?? []).map((d) => ({
id: `power-${d.name}`,
label: `${d.name} Power`,
}));
const allOptions = [...STATIC_WIDGET_OPTIONS, ...powerOptions];
if (editing) { if (editing) {
return ( return (
<select <select
value={id} value={id}
onChange={(e) => onChange(e.target.value as WidgetId)} onChange={(e) => onChange(e.target.value)}
className="text-[11px] bg-secondary border border-secondary rounded-xl px-2 py-2 text-foreground w-full cursor-pointer" className="text-[11px] bg-secondary border border-secondary rounded-xl px-2 py-2 text-foreground w-full cursor-pointer"
> >
{WIDGET_OPTIONS.map((o) => ( {allOptions.map((o) => (
<option key={o.id} value={o.id}>{o.label}</option> <option key={o.id} value={o.id}>{o.label}</option>
))} ))}
</select> </select>
@ -123,10 +125,15 @@ function WidgetSlot({
if (id === "memory") return <MiniStatWidget label="Memory" value={stats ? `${stats.memory.percent}%` : "—"} percent={stats?.memory.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 === "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 === "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 === "network") return <MiniNetworkWidget speed={netSpeed} />;
if (id === "uptime") return <MiniUptimeWidget uptime={stats?.uptime ?? null} />; if (id === "uptime") return <MiniUptimeWidget uptime={stats?.uptime ?? null} />;
if (id.startsWith("power-")) {
const deviceName = id.slice("power-".length);
const device = power?.devices.find((d) => d.name === deviceName) ?? null;
return <MiniPowerWidget label={deviceName} device={device} />;
}
return null; return null;
} }
@ -195,7 +202,7 @@ export function SideNavWidgets() {
function updateWidget(index: number, id: WidgetId) { function updateWidget(index: number, id: WidgetId) {
setWidgets((prev) => { setWidgets((prev) => {
const next = [...prev] as WidgetId[]; const next = [...prev];
next[index] = id; next[index] = id;
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {} try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
return next; return next;
@ -208,6 +215,7 @@ export function SideNavWidgets() {
<div className="px-[8px] mb-[4px] shrink-0"> <div className="px-[8px] mb-[4px] shrink-0">
<div className="flex items-center justify-between mb-2 px-[2px]"> <div className="flex items-center justify-between mb-2 px-[2px]">
<span className="text-xs text-foreground-sec font-medium">Widgets</span> <span className="text-xs text-foreground-sec font-medium">Widgets</span>
<HelpTooltip text="Customize which stats appear in the sidebar widgets. Click each slot to swap it for a different metric.">
<button <button
onClick={() => setEditing((e) => !e)} onClick={() => setEditing((e) => !e)}
title={editing ? "Done" : "Customize widgets"} title={editing ? "Done" : "Customize widgets"}
@ -215,6 +223,7 @@ export function SideNavWidgets() {
> >
<IconLayoutGrid size={13} /> <IconLayoutGrid size={13} />
</button> </button>
</HelpTooltip>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{widgets.map((id, i) => ( {widgets.map((id, i) => (

View file

@ -4,8 +4,14 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import {
IconRefresh, IconBolt, IconClock, IconCalendarStats, IconRefresh, IconBolt, IconClock, IconCalendarStats,
IconEye, IconChevronDown, IconChartBar, IconEye, IconChevronDown, IconChartBar, IconArrowsHorizontal,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import HelpTooltip from "../HelpTooltip";
const ctrlLabelStyle: React.CSSProperties = {
fontSize: 10, fontWeight: 500,
color: "var(--color-foreground-sec)", paddingLeft: 2,
};
// ── Types ────────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────────
@ -16,7 +22,9 @@ type ChartType = "line" | "bar" | "candle";
type Metric = "watts" | "energy" | "cost"; type Metric = "watts" | "energy" | "cost";
type GroupBy = "auto" | "hour" | "day" | "month" | "year"; type GroupBy = "auto" | "hour" | "day" | "month" | "year";
type CandleInterval = "auto" | "1m" | "5m" | "15m" | "30m" | "1h" | "4h" | "1d" | "1w"; type CandleInterval = "auto" | "1m" | "5m" | "15m" | "30m" | "1h" | "4h" | "1d" | "1w";
type FlyoutId = "metric" | "range" | "groupby" | "devices" | "interval"; type FlyoutId = "metric" | "range" | "groupby" | "devices" | "interval" | "xaxis";
type BarXAxis = "device" | "hour-of-day" | "day-of-week" | "day-of-month" | "month-of-year" | "year" | "custom";
type BarXAxisUnit = "minute" | "hour" | "day" | "week" | "month";
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
@ -58,6 +66,26 @@ const CANDLE_INTERVALS: { id: CandleInterval; label: string; ms: number }[] = [
{ id: "1w", label: "1w", ms: 604_800_000 }, { id: "1w", label: "1w", ms: 604_800_000 },
]; ];
const BAR_X_AXIS_OPTIONS: { id: BarXAxis; label: string }[] = [
{ id: "device", label: "Device" },
{ id: "hour-of-day", label: "Hour of day" },
{ id: "day-of-week", label: "Day of week" },
{ id: "day-of-month", label: "Day of month" },
{ id: "month-of-year", label: "Month of year" },
{ id: "year", label: "Year" },
{ id: "custom", label: "Custom" },
];
const BAR_X_UNIT_MS: Record<BarXAxisUnit, number> = {
minute: 60_000, hour: 3_600_000, day: 86_400_000,
week: 604_800_000, month: 30 * 86_400_000,
};
const BAR_X_UNIT_SHORT: Record<BarXAxisUnit, string> = {
minute: "min", hour: "hr", day: "d", week: "wk", month: "mo",
};
const BAR_X_DOM_LABELS = Array.from({ length: 31 }, (_, i) => String(i + 1));
const BAR_X_MONTH_LABELS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const CHART_PALETTE = [ const CHART_PALETTE = [
"#60a5fa", "#f87171", "#34d399", "#fbbf24", "#a78bfa", "#60a5fa", "#f87171", "#34d399", "#fbbf24", "#a78bfa",
"#f472b6", "#22d3ee", "#a3e635", "#fb923c", "#818cf8", "#f472b6", "#22d3ee", "#a3e635", "#fb923c", "#818cf8",
@ -66,6 +94,17 @@ function chartColor(i: number) { return CHART_PALETTE[i % CHART_PALETTE.length];
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function getBarXMinHours(xAxis: BarXAxis, customUnit: BarXAxisUnit): number {
if (xAxis === "device" || xAxis === "hour-of-day") return 1;
if (xAxis === "day-of-week" || xAxis === "day-of-month") return 24;
if (xAxis === "month-of-year" || xAxis === "year") return 720;
// custom
if (customUnit === "minute" || customUnit === "hour") return 1;
if (customUnit === "day") return 24;
if (customUnit === "week") return 168;
return 720; // month
}
function getBucketKey(d: Date, groupBy: GroupBy): number { function getBucketKey(d: Date, groupBy: GroupBy): number {
const n = new Date(d); const n = new Date(d);
if (groupBy === "hour") { n.setMinutes(0, 0, 0); } if (groupBy === "hour") { n.setMinutes(0, 0, 0); }
@ -411,7 +450,7 @@ function LineChart({ readings, deviceNames, colors, visible, hours, metric, grou
{visEntries.map(([n]) => { {visEntries.map(([n]) => {
const c = colors.get(n) ?? "#888"; const c = colors.get(n) ?? "#888";
return ( return (
<linearGradient key={n} id={`${uid}g${n}`} x1="0" y1="0" x2="0" y2="1"> <linearGradient key={n} id={`${uid}g${n.replace(/\W/g, '_')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={c} stopOpacity="0.22" /> <stop offset="0%" stopColor={c} stopOpacity="0.22" />
<stop offset="100%" stopColor={c} stopOpacity="0" /> <stop offset="100%" stopColor={c} stopOpacity="0" />
</linearGradient> </linearGradient>
@ -451,7 +490,7 @@ function LineChart({ readings, deviceNames, colors, visible, hours, metric, grou
const fillPath = `${linePath} L ${pts[pts.length - 1][0].toFixed(1)} ${iH.toFixed(1)} L ${pts[0][0].toFixed(1)} ${iH.toFixed(1)} Z`; const fillPath = `${linePath} L ${pts[pts.length - 1][0].toFixed(1)} ${iH.toFixed(1)} L ${pts[0][0].toFixed(1)} ${iH.toFixed(1)} Z`;
return ( return (
<g key={n}> <g key={n}>
<path d={fillPath} fill={`url(#${uid}g${n})`} /> <path d={fillPath} fill={`url(#${uid}g${n.replace(/\W/g, '_')})`} />
<path d={linePath} fill="none" stroke={c} strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" /> <path d={linePath} fill="none" stroke={c} strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" />
</g> </g>
); );
@ -491,17 +530,21 @@ function LineChart({ readings, deviceNames, colors, visible, hours, metric, grou
// ── Bar Chart ───────────────────────────────────────────────────────────────── // ── Bar Chart ─────────────────────────────────────────────────────────────────
const BM = { top: 16, right: 16, bottom: 56, left: 64 }; const BM = { top: 16, right: 16, bottom: 56, left: 64 };
const BAR_X_HOUR_LABELS = Array.from({ length: 24 }, (_, h) => `${h.toString().padStart(2, "0")}:00`);
const BAR_X_DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function BarChart({ readings, deviceNames, colors, visible, metric }: { function BarChart({ readings, deviceNames, colors, visible, metric, xAxis, xAxisCustomN, xAxisCustomUnit }: {
readings: HistoryEntry[]; deviceNames: string[]; colors: Map<string, string>; readings: HistoryEntry[]; deviceNames: string[]; colors: Map<string, string>;
visible: Set<string>; metric: Metric; visible: Set<string>; metric: Metric; xAxis: BarXAxis;
xAxisCustomN: number; xAxisCustomUnit: BarXAxisUnit;
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [uid] = useState(() => `bc${Math.random().toString(36).slice(2, 7)}`); const [uid] = useState(() => `bc${Math.random().toString(36).slice(2, 7)}`);
const [size, setSize] = useState({ w: 0, h: 0 }); const [size, setSize] = useState({ w: 0, h: 0 });
const [hover, setHover] = useState<string | null>(null); const [hover, setHover] = useState<{ label: string; name: string } | null>(null);
const [mouse, setMouse] = useState({ x: 0, y: 0 }); const [mouse, setMouse] = useState({ x: 0, y: 0 });
// Always render the container div so ResizeObserver fires on mount
useEffect(() => { useEffect(() => {
const el = containerRef.current; if (!el) return; const el = containerRef.current; if (!el) return;
const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }));
@ -509,7 +552,10 @@ function BarChart({ readings, deviceNames, colors, visible, metric }: {
return () => ro.disconnect(); return () => ro.disconnect();
}, []); }, []);
const deviceVals = useMemo(() => { const groups = useMemo((): Array<{ label: string; bars: Array<{ name: string; val: number }> }> => {
const vis = deviceNames.filter(n => visible.has(n));
if (xAxis === "device") {
const map = new Map<string, number>(); const map = new Map<string, number>();
for (const name of deviceNames) { for (const name of deviceNames) {
const pts = readings const pts = readings
@ -522,31 +568,114 @@ function BarChart({ readings, deviceNames, colors, visible, metric }: {
const avgW = pts.length ? pts.reduce((s, p) => s + p.w, 0) / pts.length : 0; const avgW = pts.length ? pts.reduce((s, p) => s + p.w, 0) / pts.length : 0;
map.set(name, metric === "watts" ? avgW : metric === "energy" ? wh : wh / 1000 * COST_PER_KWH); map.set(name, metric === "watts" ? avgW : metric === "energy" ? wh : wh / 1000 * COST_PER_KWH);
} }
return map; return vis.map(name => ({ label: name, bars: [{ name, val: map.get(name) ?? 0 }] }));
}, [readings, deviceNames, metric]); }
// Cyclical grouping helper: group by a repeating key (hour 0-23, dow 0-6, etc.)
const makeCyclical = (keyFn: (ts: string) => number, allLabels: string[], keyOffset = 0) => {
const buckets = new Map<number, Map<string, number[]>>();
for (const r of readings) {
const key = keyFn(r.ts);
if (!buckets.has(key)) buckets.set(key, new Map());
for (const d of r.devices) {
if (!visible.has(d.name)) continue;
if (!buckets.get(key)!.has(d.name)) buckets.get(key)!.set(d.name, []);
buckets.get(key)!.get(d.name)!.push(d.watts);
}
}
return allLabels
.map((label, i) => ({
label,
bars: vis.map(name => {
const vals = buckets.get(i + keyOffset)?.get(name) ?? [];
return { name, val: vals.length ? vals.reduce((s, v) => s + v, 0) / vals.length : 0 };
}),
}))
.filter(g => g.bars.some(b => b.val > 0));
};
// Absolute grouping helper: group by a computed key, sort by time
const makeAbsolute = (keyFn: (ts: string) => number, labelFn: (key: number) => string) => {
const buckets = new Map<number, Map<string, number[]>>();
for (const r of readings) {
const key = keyFn(r.ts);
if (!buckets.has(key)) buckets.set(key, new Map());
for (const d of r.devices) {
if (!visible.has(d.name)) continue;
if (!buckets.get(key)!.has(d.name)) buckets.get(key)!.set(d.name, []);
buckets.get(key)!.get(d.name)!.push(d.watts);
}
}
return [...buckets.entries()]
.sort(([a], [b]) => a - b)
.map(([key, devMap]) => ({
label: labelFn(key),
bars: vis.map(name => {
const vals = devMap.get(name) ?? [];
return { name, val: vals.length ? vals.reduce((s, v) => s + v, 0) / vals.length : 0 };
}),
}))
.filter(g => g.bars.some(b => b.val > 0));
};
if (xAxis === "hour-of-day") return makeCyclical(ts => new Date(ts).getHours(), BAR_X_HOUR_LABELS);
if (xAxis === "day-of-week") return makeCyclical(ts => new Date(ts).getDay(), BAR_X_DOW_LABELS);
if (xAxis === "day-of-month") return makeCyclical(ts => new Date(ts).getDate(), BAR_X_DOM_LABELS, 1);
if (xAxis === "month-of-year") return makeCyclical(ts => new Date(ts).getMonth(), BAR_X_MONTH_LABELS);
if (xAxis === "year") return makeAbsolute(ts => new Date(ts).getFullYear(), key => String(key));
// Custom: absolute bucket of N units
const bucketMs = Math.max(60_000, xAxisCustomN * BAR_X_UNIT_MS[xAxisCustomUnit]);
return makeAbsolute(
ts => Math.floor(new Date(ts).getTime() / bucketMs) * bucketMs,
key => {
const d = new Date(key);
if (xAxisCustomUnit === "minute" || xAxisCustomUnit === "hour")
return d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
if (xAxisCustomUnit === "day" || xAxisCustomUnit === "week")
return d.toLocaleDateString([], { month: "short", day: "numeric" });
return d.toLocaleDateString([], { month: "short", year: "2-digit" });
}
);
}, [readings, deviceNames, metric, xAxis, xAxisCustomN, xAxisCustomUnit, visible]);
const visDev = deviceNames.filter(n => visible.has(n)); const visDev = deviceNames.filter(n => visible.has(n));
if (visDev.length === 0 || readings.length === 0) return <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--color-foreground-sec)" }}>No data</div>; const noData = visDev.length === 0 || readings.length === 0;
const effectiveMetric: Metric = xAxis !== "device" ? "watts" : metric;
const { w, h } = size; const { w, h } = size;
const iW = Math.max(0, w - BM.left - BM.right); const iW = Math.max(0, w - BM.left - BM.right);
const iH = Math.max(0, h - BM.top - BM.bottom); const iH = Math.max(0, h - BM.top - BM.bottom);
const maxVal = Math.max(...visDev.map(n => deviceVals.get(n) ?? 0), 0.001);
const allVals = groups.flatMap(g => g.bars.map(b => b.val));
const maxVal = Math.max(...allVals, 0.001);
const yTicks = computeYTicks(maxVal); const yTicks = computeYTicks(maxVal);
const yMax = yTicks[yTicks.length - 1]; const yMax = yTicks[yTicks.length - 1];
const yS = (v: number) => iH - (v / Math.max(yMax, 0.001)) * iH; const yS = (v: number) => iH - (v / Math.max(yMax, 0.001)) * iH;
const gap = Math.max(6, iW * 0.1 / visDev.length);
const barW = Math.max(6, (iW - gap * (visDev.length + 1)) / visDev.length); const numGroups = Math.max(groups.length, 1);
const barsPerGroup = groups[0]?.bars.length ?? Math.max(visDev.length, 1);
const groupW = iW / numGroups;
const groupPad = Math.max(4, groupW * 0.15);
const barSlot = (groupW - groupPad) / Math.max(barsPerGroup, 1);
const barGap = Math.max(1, barSlot * 0.1);
const barW = Math.max(4, barSlot - barGap);
const hoverVal = hover
? groups.find(g => g.label === hover.label)?.bars.find(b => b.name === hover.name)?.val ?? 0
: 0;
return ( return (
<div ref={containerRef} style={{ width: "100%", height: "100%", position: "relative" }}> <div ref={containerRef} style={{ width: "100%", height: "100%", position: "relative" }}>
{w > 0 && h > 0 && ( {noData ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--color-foreground-sec)" }}>No data</div>
) : w > 0 && h > 0 ? (
<svg width={w} height={h} style={{ display: "block" }}> <svg width={w} height={h} style={{ display: "block" }}>
<defs> <defs>
{visDev.map(n => { {visDev.map(n => {
const c = colors.get(n) ?? "#888"; const c = colors.get(n) ?? "#888";
return ( return (
<linearGradient key={n} id={`${uid}g${n}`} x1="0" y1="0" x2="0" y2="1"> <linearGradient key={n} id={`${uid}g${n.replace(/\W/g, '_')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={c} stopOpacity="0.9" /> <stop offset="0%" stopColor={c} stopOpacity="0.9" />
<stop offset="100%" stopColor={c} stopOpacity="0.6" /> <stop offset="100%" stopColor={c} stopOpacity="0.6" />
</linearGradient> </linearGradient>
@ -557,34 +686,49 @@ function BarChart({ readings, deviceNames, colors, visible, metric }: {
{yTicks.map(v => ( {yTicks.map(v => (
<g key={v}> <g key={v}>
<line x1={0} x2={iW} y1={yS(v)} y2={yS(v)} stroke="var(--color-secondary)" strokeDasharray="3,3" strokeWidth={1} /> <line x1={0} x2={iW} y1={yS(v)} y2={yS(v)} stroke="var(--color-secondary)" strokeDasharray="3,3" strokeWidth={1} />
<text x={-6} y={yS(v)} textAnchor="end" dominantBaseline="middle" fill="var(--color-foreground-sec)" fontSize={10}>{fmtMetricTick(v, metric)}</text> <text x={-6} y={yS(v)} textAnchor="end" dominantBaseline="middle" fill="var(--color-foreground-sec)" fontSize={10}>{fmtMetricTick(v, effectiveMetric)}</text>
</g> </g>
))} ))}
{visDev.map((n, i) => { {groups.map((g, gi) => {
const val = deviceVals.get(n) ?? 0; const gx = gi * groupW + groupPad / 2;
const x = gap + i * (barW + gap); const groupCenter = gi * groupW + groupW / 2;
return ( return (
<g key={n} <g key={g.label}>
onMouseEnter={e => { setHover(n); setMouse({ x: e.clientX, y: e.clientY }); }} {g.bars.map((bar, bi) => {
const bx = gx + bi * barSlot;
const bh = Math.max(0, iH - yS(bar.val));
const isHovered = hover?.label === g.label && hover?.name === bar.name;
return (
<g key={bar.name}
onMouseEnter={e => { setHover({ label: g.label, name: bar.name }); setMouse({ x: e.clientX, y: e.clientY }); }}
onMouseMove={e => setMouse({ x: e.clientX, y: e.clientY })} onMouseMove={e => setMouse({ x: e.clientX, y: e.clientY })}
onMouseLeave={() => setHover(null)} onMouseLeave={() => setHover(null)}
style={{ cursor: "default" }} style={{ cursor: "default" }}
> >
<rect x={x} y={yS(val)} width={barW} height={Math.max(0, iH - yS(val))} fill={`url(#${uid}g${n})`} opacity={hover === n ? 1 : 0.85} rx={3} /> <rect x={bx} y={yS(bar.val)} width={barW} height={bh}
<text transform={`translate(${x + barW / 2},${iH + 8}) rotate(-35)`} textAnchor="end" fill="var(--color-foreground-sec)" fontSize={10} style={{ textTransform: "capitalize" }}>{n}</text> fill={`url(#${uid}g${bar.name.replace(/\W/g, '_')})`}
opacity={isHovered ? 1 : 0.85} rx={3} />
</g>
);
})}
<text
transform={`translate(${groupCenter},${iH + 8}) rotate(-35)`}
textAnchor="end" fill="var(--color-foreground-sec)" fontSize={10}
style={{ textTransform: "capitalize" }}
>{g.label}</text>
</g> </g>
); );
})} })}
<line x1={0} x2={iW} y1={iH} y2={iH} stroke="var(--color-secondary)" strokeWidth={1} /> <line x1={0} x2={iW} y1={iH} y2={iH} stroke="var(--color-secondary)" strokeWidth={1} />
</g> </g>
</svg> </svg>
)} ) : null}
{hover !== null && typeof document !== "undefined" && createPortal( {hover !== null && typeof document !== "undefined" && createPortal(
<ChartTooltip label="" mouseX={mouse.x} mouseY={mouse.y}> <ChartTooltip label={hover.label} mouseX={mouse.x} mouseY={mouse.y}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: colors.get(hover) ?? "#888", flexShrink: 0 }} /> <span style={{ width: 8, height: 8, borderRadius: "50%", background: colors.get(hover.name) ?? "#888", flexShrink: 0 }} />
<span style={{ color: "var(--color-foreground)", fontWeight: 500, textTransform: "capitalize" }}>{hover}</span> <span style={{ color: "var(--color-foreground)", fontWeight: 500, textTransform: "capitalize" }}>{hover.name}</span>
<span style={{ color: colors.get(hover) ?? "#888", fontWeight: 700 }}>{fmtMetricVal(deviceVals.get(hover) ?? 0, metric)}</span> <span style={{ color: colors.get(hover.name) ?? "#888", fontWeight: 700 }}>{fmtMetricVal(hoverVal, effectiveMetric)}</span>
</div> </div>
</ChartTooltip>, </ChartTooltip>,
document.body document.body
@ -750,11 +894,28 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
const [metric, setMetric] = useState<Metric>("watts"); const [metric, setMetric] = useState<Metric>("watts");
const [groupBy, setGroupBy] = useState<GroupBy>("auto"); const [groupBy, setGroupBy] = useState<GroupBy>("auto");
const [candleInterval, setCandleInterval] = useState<CandleInterval>("auto"); const [candleInterval, setCandleInterval] = useState<CandleInterval>("auto");
const [barXAxis, setBarXAxis] = useState<BarXAxis>("device");
const [barXAxisCustomN, setBarXAxisCustomN] = useState(1);
const [barXAxisCustomUnit, setBarXAxisCustomUnit] = useState<BarXAxisUnit>("hour");
const [flyout, setFlyout] = useState<(FlyoutPos & { id: FlyoutId }) | null>(null); const [flyout, setFlyout] = useState<(FlyoutPos & { id: FlyoutId }) | null>(null);
const hours = Math.max(1, parseInt(hoursInput, 10) || 24); const hours = Math.max(1, parseInt(hoursInput, 10) || 24);
const matchedPreset = PRESETS.find(p => p.h === hours); const matchedPreset = PRESETS.find(p => p.h === hours);
const barXMinHours = chartType === "bar" ? getBarXMinHours(barXAxis, barXAxisCustomUnit) : 1;
const filteredPresets = PRESETS.filter(p => p.h >= barXMinHours);
// Auto-bump time range when X axis changes to require more data
useEffect(() => {
if (chartType !== "bar") return;
const min = getBarXMinHours(barXAxis, barXAxisCustomUnit);
const currentH = Math.max(1, parseInt(hoursInput, 10) || 24);
if (currentH < min) {
const firstValid = PRESETS.find(p => p.h >= min);
if (firstValid) setHoursInput(String(firstValid.h));
}
}, [barXAxis, barXAxisCustomUnit, chartType]);
const openFlyout = useCallback((id: FlyoutId, rect: DOMRect, el: HTMLButtonElement) => { const openFlyout = useCallback((id: FlyoutId, rect: DOMRect, el: HTMLButtonElement) => {
if (flyout?.id === id) { setFlyout(null); return; } if (flyout?.id === id) { setFlyout(null); return; }
const alignRight = rect.left > window.innerWidth * 0.55; const alignRight = rect.left > window.innerWidth * 0.55;
@ -855,6 +1016,9 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
const groupLabel = GROUP_BY_OPTIONS.find(g => g.id === groupBy)?.label ?? "Auto"; const groupLabel = GROUP_BY_OPTIONS.find(g => g.id === groupBy)?.label ?? "Auto";
const intervalLabel = CANDLE_INTERVALS.find(c => c.id === candleInterval)?.label ?? "Auto"; const intervalLabel = CANDLE_INTERVALS.find(c => c.id === candleInterval)?.label ?? "Auto";
const devLabel = visible.size === deviceNames.length ? "All" : `${visible.size}`; const devLabel = visible.size === deviceNames.length ? "All" : `${visible.size}`;
const barXAxisLabel = barXAxis === "custom"
? `Every ${barXAxisCustomN}${BAR_X_UNIT_SHORT[barXAxisCustomUnit]}`
: BAR_X_AXIS_OPTIONS.find(o => o.id === barXAxis)?.label ?? "Device";
const refreshBtn = ( const refreshBtn = (
<button onClick={fetchHistory} disabled={loading} style={{ <button onClick={fetchHistory} disabled={loading} style={{
@ -875,18 +1039,38 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
{!readOnly && (chartType === "candle" ? ( {!readOnly && (chartType === "candle" ? (
<> <>
{/* ── Candlestick toolbar ── */} {/* ── Candlestick toolbar ── */}
<div style={{ flexShrink: 0, display: "flex", alignItems: "center", gap: 6, padding: "10px 14px 8px", borderBottom: "1px solid color-mix(in srgb, var(--color-secondary) 60%, transparent)" }}> <div style={{ flexShrink: 0, display: "flex", alignItems: "flex-end", gap: 6, padding: "6px 14px 8px", borderBottom: "1px solid color-mix(in srgb, var(--color-secondary) 60%, transparent)" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={ctrlLabelStyle}>Time Range</span>
<HelpTooltip text="How far back to load data. Pick a preset or type a custom number of hours.">
<CtrlBtn icon={<IconClock size={13} />} label={rangeLabel} isOpen={flyout?.id === "range"} onClick={(rect, el) => openFlyout("range", rect, el)} /> <CtrlBtn icon={<IconClock size={13} />} label={rangeLabel} isOpen={flyout?.id === "range"} onClick={(rect, el) => openFlyout("range", rect, el)} />
</HelpTooltip>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={ctrlLabelStyle}>Candle Period</span>
<HelpTooltip text="The time interval each candlestick represents. Auto picks the best size for the chosen range.">
<CtrlBtn icon={<IconChartBar size={13} />} label={intervalLabel} isOpen={flyout?.id === "interval"} onClick={(rect, el) => openFlyout("interval", rect, el)} /> <CtrlBtn icon={<IconChartBar size={13} />} label={intervalLabel} isOpen={flyout?.id === "interval"} onClick={(rect, el) => openFlyout("interval", rect, el)} />
</HelpTooltip>
</div>
{deviceNames.length > 0 && ( {deviceNames.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={ctrlLabelStyle}>Devices</span>
<HelpTooltip text="Toggle individual devices on or off to focus the chart on specific plugs.">
<CtrlBtn icon={<IconEye size={13} />} label={devLabel} isOpen={flyout?.id === "devices"} onClick={(rect, el) => openFlyout("devices", rect, el)} /> <CtrlBtn icon={<IconEye size={13} />} label={devLabel} isOpen={flyout?.id === "devices"} onClick={(rect, el) => openFlyout("devices", rect, el)} />
</HelpTooltip>
</div>
)} )}
<div style={{ marginLeft: "auto" }}>{refreshBtn}</div> <div style={{ marginLeft: "auto", display: "flex", flexDirection: "column", gap: 2, alignItems: "flex-end" }}>
<span style={ctrlLabelStyle}>Refresh</span>
<HelpTooltip text="Reload chart data from the server.">
{refreshBtn}
</HelpTooltip>
</div>
</div> </div>
{activeFlyout?.id === "range" && ( {activeFlyout?.id === "range" && (
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Time Range"> <Flyout pos={activeFlyout} onClose={closeFlyout} title="Time Range">
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 4, marginBottom: 8 }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 4, marginBottom: 8 }}>
{PRESETS.map(({ label, h }) => ( {filteredPresets.map(({ label, h }) => (
<button key={h} onClick={() => { setHoursInput(String(h)); closeFlyout(); }} style={{ <button key={h} onClick={() => { setHoursInput(String(h)); closeFlyout(); }} style={{
padding: "5px 4px", borderRadius: 7, fontSize: 11, fontWeight: 500, cursor: "pointer", padding: "5px 4px", borderRadius: 7, fontSize: 11, fontWeight: 500, cursor: "pointer",
border: `1px solid ${matchedPreset?.h === h ? "var(--color-blue)" : "var(--color-secondary)"}`, border: `1px solid ${matchedPreset?.h === h ? "var(--color-blue)" : "var(--color-secondary)"}`,
@ -928,26 +1112,122 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
) : ( ) : (
<> <>
{/* ── Line / bar toolbar ── */} {/* ── Line / bar toolbar ── */}
<div style={{ flexShrink: 0, display: "flex", alignItems: "center", gap: 6, padding: "10px 14px 8px", borderBottom: "1px solid color-mix(in srgb, var(--color-secondary) 60%, transparent)" }}> <div style={{ flexShrink: 0, display: "flex", alignItems: "flex-end", gap: 6, padding: "6px 14px 8px", borderBottom: "1px solid color-mix(in srgb, var(--color-secondary) 60%, transparent)" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={ctrlLabelStyle}>Y axis</span>
<HelpTooltip text="What to measure: Power (live watts), Energy (kWh consumed), or estimated Cost in dollars.">
<CtrlBtn icon={<IconBolt size={13} />} label={metricLabel} isOpen={flyout?.id === "metric"} onClick={(rect, el) => openFlyout("metric", rect, el)} /> <CtrlBtn icon={<IconBolt size={13} />} label={metricLabel} isOpen={flyout?.id === "metric"} onClick={(rect, el) => openFlyout("metric", rect, el)} />
<CtrlBtn icon={<IconClock size={13} />} label={rangeLabel} isOpen={flyout?.id === "range"} onClick={(rect, el) => openFlyout("range", rect, el)} /> </HelpTooltip>
<CtrlBtn icon={<IconCalendarStats size={13} />} label={groupLabel} isOpen={flyout?.id === "groupby"} onClick={(rect, el) => openFlyout("groupby", rect, el)} /> </div>
{deviceNames.length > 0 && ( {chartType === "bar" && (
<CtrlBtn icon={<IconEye size={13} />} label={devLabel} isOpen={flyout?.id === "devices"} onClick={(rect, el) => openFlyout("devices", rect, el)} /> <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={ctrlLabelStyle}>X axis</span>
<HelpTooltip text="What to use as the X axis: one bar per device, or group readings by hour of day or day of week.">
<CtrlBtn icon={<IconArrowsHorizontal size={13} />} label={barXAxisLabel} isOpen={flyout?.id === "xaxis"} onClick={(rect, el) => openFlyout("xaxis", rect, el)} />
</HelpTooltip>
</div>
)} )}
<div style={{ marginLeft: "auto" }}>{refreshBtn}</div> <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={ctrlLabelStyle}>Time Range</span>
<HelpTooltip text="How far back to load data. Pick a preset or type a custom number of hours.">
<CtrlBtn icon={<IconClock size={13} />} label={rangeLabel} isOpen={flyout?.id === "range"} onClick={(rect, el) => openFlyout("range", rect, el)} />
</HelpTooltip>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={ctrlLabelStyle}>Group By</span>
<HelpTooltip text="How to bucket data points in time. Auto selects the best interval for the chosen range.">
<CtrlBtn icon={<IconCalendarStats size={13} />} label={groupLabel} isOpen={flyout?.id === "groupby"} onClick={(rect, el) => openFlyout("groupby", rect, el)} />
</HelpTooltip>
</div>
{deviceNames.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={ctrlLabelStyle}>Devices</span>
<HelpTooltip text="Toggle individual devices on or off to focus the chart on specific plugs.">
<CtrlBtn icon={<IconEye size={13} />} label={devLabel} isOpen={flyout?.id === "devices"} onClick={(rect, el) => openFlyout("devices", rect, el)} />
</HelpTooltip>
</div>
)}
<div style={{ marginLeft: "auto", display: "flex", flexDirection: "column", gap: 2, alignItems: "flex-end" }}>
<span style={ctrlLabelStyle}>Refresh</span>
<HelpTooltip text="Reload chart data from the server.">
{refreshBtn}
</HelpTooltip>
</div>
</div> </div>
{activeFlyout?.id === "metric" && ( {activeFlyout?.id === "metric" && (
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Metric"> <Flyout pos={activeFlyout} onClose={closeFlyout} title="Y Axis">
{METRICS.map(m => ( {METRICS.map(m => (
<FlyoutOpt key={m.id} label={m.label} selected={metric === m.id} onClick={() => { setMetric(m.id); closeFlyout(); }} /> <FlyoutOpt key={m.id} label={m.label} selected={metric === m.id} onClick={() => { setMetric(m.id); closeFlyout(); }} />
))} ))}
</Flyout> </Flyout>
)} )}
{activeFlyout?.id === "xaxis" && (
<Flyout pos={activeFlyout} onClose={closeFlyout} title="X Axis">
<FlyoutOpt label="Device" selected={barXAxis === "device"} onClick={() => { setBarXAxis("device"); closeFlyout(); }} />
<div style={{ height: 1, background: "var(--color-secondary)", margin: "6px 2px 2px" }} />
<p style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", color: "var(--color-foreground-sec)", margin: "4px 4px 2px" }}>Time</p>
<p style={{ fontSize: 9, fontWeight: 600, color: "var(--color-foreground-sec)", margin: "4px 10px 2px", opacity: 0.75 }}>Hour</p>
<FlyoutOpt label="Hour of day" selected={barXAxis === "hour-of-day"} onClick={() => { setBarXAxis("hour-of-day"); closeFlyout(); }} />
<p style={{ fontSize: 9, fontWeight: 600, color: "var(--color-foreground-sec)", margin: "6px 10px 2px", opacity: 0.75 }}>Day</p>
<FlyoutOpt label="Day of week" selected={barXAxis === "day-of-week"} onClick={() => { setBarXAxis("day-of-week"); closeFlyout(); }} />
<FlyoutOpt label="Day of month" selected={barXAxis === "day-of-month"} onClick={() => { setBarXAxis("day-of-month"); closeFlyout(); }} />
<p style={{ fontSize: 9, fontWeight: 600, color: "var(--color-foreground-sec)", margin: "6px 10px 2px", opacity: 0.75 }}>Month</p>
<FlyoutOpt label="Month of year" selected={barXAxis === "month-of-year"} onClick={() => { setBarXAxis("month-of-year"); closeFlyout(); }} />
<p style={{ fontSize: 9, fontWeight: 600, color: "var(--color-foreground-sec)", margin: "6px 10px 2px", opacity: 0.75 }}>Year</p>
<FlyoutOpt label="Year" selected={barXAxis === "year"} onClick={() => { setBarXAxis("year"); closeFlyout(); }} />
<div style={{ height: 1, background: "var(--color-secondary)", margin: "6px 2px 4px" }} />
<p style={{ fontSize: 9, fontWeight: 600, color: "var(--color-foreground-sec)", margin: "2px 10px 4px", opacity: 0.75 }}>Custom</p>
<button
onClick={() => setBarXAxis("custom")}
style={{
width: "100%", display: "flex", alignItems: "center", gap: 7,
padding: "7px 10px", borderRadius: 8, border: "none",
background: barXAxis === "custom" ? "color-mix(in srgb, var(--color-blue) 14%, transparent)" : "transparent",
color: barXAxis === "custom" ? "var(--color-blue)" : "var(--color-foreground)",
cursor: "pointer", fontSize: 12, fontWeight: barXAxis === "custom" ? 600 : 400,
transition: "background 100ms",
}}
>
<span style={{
width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
background: barXAxis === "custom" ? "var(--color-blue)" : "var(--color-secondary)",
boxShadow: barXAxis === "custom" ? "0 0 0 2px color-mix(in srgb, var(--color-blue) 25%, transparent)" : "none",
}} />
Every
<input
type="text" inputMode="numeric" pattern="[0-9]*" value={barXAxisCustomN}
onClick={e => { e.stopPropagation(); setBarXAxis("custom"); }}
onChange={e => { setBarXAxis("custom"); setBarXAxisCustomN(Math.max(1, parseInt(e.target.value.replace(/\D/g, "")) || 1)); }}
style={{
width: 38, padding: "2px 4px", borderRadius: 5, fontSize: 11, textAlign: "center",
background: "color-mix(in srgb, var(--color-secondary) 70%, transparent)",
border: "1px solid var(--color-secondary)", color: "var(--color-foreground)", outline: "none",
}}
/>
<select
value={barXAxisCustomUnit}
onClick={e => { e.stopPropagation(); setBarXAxis("custom"); }}
onChange={e => { setBarXAxis("custom"); setBarXAxisCustomUnit(e.target.value as BarXAxisUnit); }}
style={{
padding: "2px 4px", borderRadius: 5, fontSize: 11,
background: "color-mix(in srgb, var(--color-secondary) 70%, transparent)",
border: "1px solid var(--color-secondary)", color: "var(--color-foreground)",
outline: "none", cursor: "pointer",
}}
>
<option value="minute">min</option>
<option value="hour">hour</option>
<option value="day">day</option>
<option value="week">week</option>
<option value="month">month</option>
</select>
</button>
</Flyout>
)}
{activeFlyout?.id === "range" && ( {activeFlyout?.id === "range" && (
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Time Range"> <Flyout pos={activeFlyout} onClose={closeFlyout} title="Time Range">
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 4, marginBottom: 8 }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 4, marginBottom: 8 }}>
{PRESETS.map(({ label, h }) => ( {filteredPresets.map(({ label, h }) => (
<button key={h} onClick={() => { setHoursInput(String(h)); closeFlyout(); }} style={{ <button key={h} onClick={() => { setHoursInput(String(h)); closeFlyout(); }} style={{
padding: "5px 4px", borderRadius: 7, fontSize: 11, fontWeight: 500, cursor: "pointer", padding: "5px 4px", borderRadius: 7, fontSize: 11, fontWeight: 500, cursor: "pointer",
border: `1px solid ${matchedPreset?.h === h ? "var(--color-blue)" : "var(--color-secondary)"}`, border: `1px solid ${matchedPreset?.h === h ? "var(--color-blue)" : "var(--color-secondary)"}`,
@ -1034,7 +1314,7 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
) : chartType === "line" ? ( ) : chartType === "line" ? (
<LineChart readings={displayReadings} deviceNames={deviceNames} colors={colors} visible={visible} hours={hours} metric={metric} groupBy={groupBy} /> <LineChart readings={displayReadings} deviceNames={deviceNames} colors={colors} visible={visible} hours={hours} metric={metric} groupBy={groupBy} />
) : chartType === "bar" ? ( ) : chartType === "bar" ? (
<BarChart readings={readings} deviceNames={deviceNames} colors={colors} visible={visible} metric={metric} /> <BarChart readings={readings} deviceNames={deviceNames} colors={colors} visible={visible} metric={metric} xAxis={barXAxis} xAxisCustomN={barXAxisCustomN} xAxisCustomUnit={barXAxisCustomUnit} />
) : ( ) : (
<CandleChart readings={readings} deviceNames={deviceNames} colors={colors} visible={visible} hours={hours} candleInterval={candleInterval} /> <CandleChart readings={readings} deviceNames={deviceNames} colors={colors} visible={visible} hours={hours} candleInterval={candleInterval} />
)} )}

View file

@ -3,6 +3,7 @@
import React, { useState, useEffect, useRef, useMemo } from "react"; import React, { useState, useEffect, useRef, useMemo } from "react";
import { getStats, type Stats, type NetworkInterface } from "../../lib/getStats"; import { getStats, type Stats, type NetworkInterface } from "../../lib/getStats";
import AnalyticsPanel from "./AnalyticsPanel"; import AnalyticsPanel from "./AnalyticsPanel";
import HelpTooltip from "../HelpTooltip";
// ── Types / constants ───────────────────────────────────────────────────────── // ── Types / constants ─────────────────────────────────────────────────────────
@ -211,9 +212,12 @@ export default function DashboardPanel({ isAuthed }: { isAuthed: boolean }) {
<p style={{ fontSize: "10pt", fontWeight: 600, color: "var(--color-foreground)", margin: 0 }}> <p style={{ fontSize: "10pt", fontWeight: 600, color: "var(--color-foreground)", margin: 0 }}>
Power Analytics Power Analytics
</p> </p>
<div style={{ display: "flex", flexDirection: "column", gap: 4, alignItems: "flex-end" }}>
<span style={{ fontSize: 10, fontWeight: 500, color: "var(--color-foreground-sec)" }}>Time Range</span>
<div style={{ display: "flex", gap: 4 }}> <div style={{ display: "flex", gap: 4 }}>
{PRESETS.map(p => ( {PRESETS.map(p => (
<button key={p.h} onClick={() => setHours(p.h)} style={{ <HelpTooltip key={p.h} text={`Show power analytics for the last ${p.label}.`}>
<button onClick={() => setHours(p.h)} style={{
padding: "4px 10px", borderRadius: 7, fontSize: "10pt", fontWeight: 500, cursor: "pointer", padding: "4px 10px", borderRadius: 7, fontSize: "10pt", fontWeight: 500, cursor: "pointer",
border: `1px solid ${hours === p.h ? "var(--color-blue)" : "var(--color-secondary)"}`, 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", background: hours === p.h ? "color-mix(in srgb, var(--color-blue) 14%, transparent)" : "transparent",
@ -222,9 +226,11 @@ export default function DashboardPanel({ isAuthed }: { isAuthed: boolean }) {
}}> }}>
{p.label} {p.label}
</button> </button>
</HelpTooltip>
))} ))}
</div> </div>
</div> </div>
</div>
{/* Summary cards */} {/* Summary cards */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 10 }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 10 }}>

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import HelpTooltip from "./HelpTooltip";
const SERVICES = [ const SERVICES = [
"syncthing", "syncthing",
@ -140,12 +141,14 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) {
Manage services Manage services
</h2> </h2>
</div> </div>
<HelpTooltip text="Close this control panel.">
<button <button
onClick={onClose} onClick={onClose}
className="border border-gray-200 rounded-lg px-3.5 py-1.5 text-[13px] text-gray-400 cursor-pointer hover:bg-gray-50 transition-colors" className="border border-gray-200 rounded-lg px-3.5 py-1.5 text-[13px] text-gray-400 cursor-pointer hover:bg-gray-50 transition-colors"
> >
Close Close
</button> </button>
</HelpTooltip>
</div> </div>
{/* Services */} {/* Services */}
@ -165,8 +168,8 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) {
</div> </div>
<div className="flex gap-1.5 shrink-0"> <div className="flex gap-1.5 shrink-0">
{["start", "stop", "restart"].map((action) => ( {["start", "stop", "restart"].map((action) => (
<HelpTooltip key={action} text={`${action.charAt(0).toUpperCase() + action.slice(1)} the ${svc} service.`}>
<button <button
key={action}
onClick={() => onClick={() =>
!isLoading(svc, action) && handleService(action, svc) !isLoading(svc, action) && handleService(action, svc)
} }
@ -177,13 +180,16 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) {
? "..." ? "..."
: action.charAt(0).toUpperCase() + action.slice(1)} : action.charAt(0).toUpperCase() + action.slice(1)}
</button> </button>
</HelpTooltip>
))} ))}
<HelpTooltip text={`Fetch and display recent logs for the ${svc} service.`}>
<button <button
onClick={() => handleLogs(svc)} onClick={() => handleLogs(svc)}
className="rounded-md px-2.5 py-1 text-[13px] whitespace-nowrap border border-blue-100 text-blue-400 cursor-pointer hover:bg-blue-50 transition-colors" className="rounded-md px-2.5 py-1 text-[13px] whitespace-nowrap border border-blue-100 text-blue-400 cursor-pointer hover:bg-blue-50 transition-colors"
> >
Logs Logs
</button> </button>
</HelpTooltip>
</div> </div>
</div> </div>
))} ))}
@ -202,12 +208,14 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) {
Immediately restarts the machine Immediately restarts the machine
</p> </p>
</div> </div>
<HelpTooltip text="Immediately restart the server. All services will briefly go offline.">
<button <button
onClick={handleReboot} onClick={handleReboot}
className="border border-red-200 rounded-lg px-3.5 py-1.5 text-[13px] text-red-400 cursor-pointer hover:bg-red-50 transition-colors whitespace-nowrap" className="border border-red-200 rounded-lg px-3.5 py-1.5 text-[13px] text-red-400 cursor-pointer hover:bg-red-50 transition-colors whitespace-nowrap"
> >
Reboot Reboot
</button> </button>
</HelpTooltip>
</div> </div>
</div> </div>
<div className="bg-gray-50 border border-gray-100 rounded-xl px-4 py-3.5 flex items-center justify-between mt-2"> <div className="bg-gray-50 border border-gray-100 rounded-xl px-4 py-3.5 flex items-center justify-between mt-2">
@ -219,12 +227,14 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) {
Powers off the machine Powers off the machine
</p> </p>
</div> </div>
<HelpTooltip text="Power off the server completely. You will need physical access to turn it back on.">
<button <button
onClick={handleShutdown} onClick={handleShutdown}
className="border border-red-200 rounded-lg px-3.5 py-1.5 text-[13px] text-red-400 cursor-pointer hover:bg-red-50 transition-colors whitespace-nowrap" className="border border-red-200 rounded-lg px-3.5 py-1.5 text-[13px] text-red-400 cursor-pointer hover:bg-red-50 transition-colors whitespace-nowrap"
> >
Shut down Shut down
</button> </button>
</HelpTooltip>
</div> </div>
{/* Toast */} {/* Toast */}

View file

@ -6,6 +6,7 @@ import { LeafNode, PanelId, PANEL_LABELS, PANEL_SECTIONS } from "./types";
import { import {
IconX, IconLayoutColumns, IconLayoutRows, IconRefresh, IconX, IconLayoutColumns, IconLayoutRows, IconRefresh,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import HelpTooltip from "../HelpTooltip";
const DashboardPanel = lazy(() => import("../panels/DashboardPanel")); const DashboardPanel = lazy(() => import("../panels/DashboardPanel"));
const AnalyticsPanel = lazy(() => import("../panels/AnalyticsPanel")); const AnalyticsPanel = lazy(() => import("../panels/AnalyticsPanel"));
@ -189,6 +190,7 @@ function WindowControls({
onMouseLeave={() => setPillHovered(false)} onMouseLeave={() => setPillHovered(false)}
> >
{canClose && ( {canClose && (
<HelpTooltip text="Close this panel pane.">
<button <button
onClick={(e) => { e.stopPropagation(); onClose(paneId); }} onClick={(e) => { e.stopPropagation(); onClose(paneId); }}
className={`${BTN} hover:text-red-400 hover:bg-red-500/10`} className={`${BTN} hover:text-red-400 hover:bg-red-500/10`}
@ -196,7 +198,9 @@ function WindowControls({
> >
<IconX size={13} /> <IconX size={13} />
</button> </button>
</HelpTooltip>
)} )}
<HelpTooltip text="Change what this pane displays — pick a different analytics view.">
<button <button
ref={changeRef} ref={changeRef}
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "change" ? null : "change"); }} onClick={(e) => { e.stopPropagation(); setMenu(m => m === "change" ? null : "change"); }}
@ -205,6 +209,8 @@ function WindowControls({
> >
<IconRefresh size={13} /> <IconRefresh size={13} />
</button> </button>
</HelpTooltip>
<HelpTooltip text="Split this pane and open a new panel to the right.">
<button <button
ref={rightRef} ref={rightRef}
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "right" ? null : "right"); }} onClick={(e) => { e.stopPropagation(); setMenu(m => m === "right" ? null : "right"); }}
@ -213,6 +219,8 @@ function WindowControls({
> >
<IconLayoutColumns size={13} /> <IconLayoutColumns size={13} />
</button> </button>
</HelpTooltip>
<HelpTooltip text="Split this pane and open a new panel below.">
<button <button
ref={downRef} ref={downRef}
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "down" ? null : "down"); }} onClick={(e) => { e.stopPropagation(); setMenu(m => m === "down" ? null : "down"); }}
@ -221,6 +229,7 @@ function WindowControls({
> >
<IconLayoutRows size={13} /> <IconLayoutRows size={13} />
</button> </button>
</HelpTooltip>
</div> </div>
{menu && ( {menu && (

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import HelpTooltip from "../components/HelpTooltip";
function b64uToBuf(b64u: string): ArrayBuffer { function b64uToBuf(b64u: string): ArrayBuffer {
const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/"); const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/");
@ -187,6 +188,7 @@ export default function EnrollPage() {
)} )}
{/* Submit */} {/* Submit */}
<HelpTooltip text="Register your YubiKey as an authentication device for this account." block>
<button <button
type="submit" type="submit"
disabled={busy} disabled={busy}
@ -202,6 +204,7 @@ export default function EnrollPage() {
{status === "waiting_yubikey" && "Touch YubiKey…"} {status === "waiting_yubikey" && "Touch YubiKey…"}
{status === "saving" && "Saving…"} {status === "saving" && "Saving…"}
</button> </button>
</HelpTooltip>
</form> </form>
)} )}
</div> </div>

View file

@ -2,6 +2,7 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import SideNav from "../components/SideNav"; import SideNav from "../components/SideNav";
import HelpTooltip from "../components/HelpTooltip";
import { import {
IconSearch, IconSearch,
IconX, IconX,
@ -246,12 +247,14 @@ export default function UsersPage() {
className="flex-1 bg-transparent text-[12px] text-foreground placeholder:text-foreground-sec outline-none" className="flex-1 bg-transparent text-[12px] text-foreground placeholder:text-foreground-sec outline-none"
/> />
{search && ( {search && (
<HelpTooltip text="Clear the search filter.">
<button <button
onClick={() => setSearch("")} onClick={() => setSearch("")}
className="text-foreground-sec hover:text-foreground cursor-pointer" className="text-foreground-sec hover:text-foreground cursor-pointer"
> >
<IconX size={12} /> <IconX size={12} />
</button> </button>
</HelpTooltip>
)} )}
</div> </div>
</div> </div>
@ -390,6 +393,7 @@ export default function UsersPage() {
</span> </span>
)} )}
</div> </div>
<HelpTooltip text="Remove this YubiKey credential. The user will no longer be able to log in with it.">
<button <button
onClick={() => deleteCredential(cred.id)} onClick={() => deleteCredential(cred.id)}
disabled={deletingId === cred.id} disabled={deletingId === cred.id}
@ -402,6 +406,7 @@ export default function UsersPage() {
<IconTrash size={13} /> <IconTrash size={13} />
)} )}
</button> </button>
</HelpTooltip>
</div> </div>
))} ))}
</div> </div>
@ -420,12 +425,14 @@ export default function UsersPage() {
{enrollStatus === "done" ? ( {enrollStatus === "done" ? (
<div className="flex items-center gap-[8px] px-[12px] py-[10px] rounded-xl bg-green/10 text-green text-[12px] font-semibold"> <div className="flex items-center gap-[8px] px-[12px] py-[10px] rounded-xl bg-green/10 text-green text-[12px] font-semibold">
{enrollMessage} {enrollMessage}
<HelpTooltip text="Dismiss this success message.">
<button <button
onClick={() => setEnrollStatus("idle")} onClick={() => setEnrollStatus("idle")}
className="ml-auto text-green/60 hover:text-green cursor-pointer" className="ml-auto text-green/60 hover:text-green cursor-pointer"
> >
<IconX size={13} /> <IconX size={13} />
</button> </button>
</HelpTooltip>
</div> </div>
) : ( ) : (
<form onSubmit={enrollKey} className="flex flex-col gap-[10px]"> <form onSubmit={enrollKey} className="flex flex-col gap-[10px]">
@ -454,6 +461,7 @@ export default function UsersPage() {
<p className="text-[12px] text-red-400">{enrollMessage}</p> <p className="text-[12px] text-red-400">{enrollMessage}</p>
)} )}
<HelpTooltip text="Start the YubiKey enrollment flow — you'll be prompted to touch the key to register it.">
<button <button
type="submit" type="submit"
disabled={enrollBusy} disabled={enrollBusy}
@ -473,6 +481,7 @@ export default function UsersPage() {
? "Touch YubiKey…" ? "Touch YubiKey…"
: "Saving…"} : "Saving…"}
</button> </button>
</HelpTooltip>
</form> </form>
)} )}
</div> </div>

32
stores/helpModeStore.ts Normal file
View file

@ -0,0 +1,32 @@
"use client";
import { useSyncExternalStore } from "react";
function read(): boolean {
if (typeof localStorage === "undefined") return false;
return localStorage.getItem("helpMode") === "true";
}
let helpMode = read();
const listeners = new Set<() => void>();
function subscribe(cb: () => void) {
listeners.add(cb);
return () => listeners.delete(cb);
}
function notify() {
listeners.forEach((cb) => cb());
}
export function useHelpMode(): boolean {
return useSyncExternalStore(subscribe, () => helpMode, () => false);
}
export function useToggleHelpMode() {
return function toggle() {
helpMode = !helpMode;
try { localStorage.setItem("helpMode", String(helpMode)); } catch {}
notify();
};
}