bar graph fix
This commit is contained in:
parent
43318fb8cd
commit
8c3d749197
15 changed files with 973 additions and 401 deletions
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from "recharts";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
import SideNav from "../components/SideNav";
|
||||
import HelpTooltip from "../components/HelpTooltip";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -406,20 +407,25 @@ export default function AnalyticsPage() {
|
|||
</div>
|
||||
|
||||
{/* 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">
|
||||
{PRESETS.map(({ label, h }) => (
|
||||
<button key={h} onClick={() => { setPresetH(h); setAppliedRange(null); setShowCustom(false); }}
|
||||
className={"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer " +
|
||||
(!appliedRange && presetH === h ? "bg-blue text-white" : "text-foreground-sec hover:text-foreground")}>
|
||||
{label}
|
||||
</button>
|
||||
<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 " +
|
||||
(!appliedRange && presetH === h ? "bg-blue text-white" : "text-foreground-sec hover:text-foreground")}>
|
||||
{label}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
))}
|
||||
<button onClick={() => setShowCustom(s => !s)}
|
||||
className={"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer " +
|
||||
(showCustom || appliedRange ? "bg-blue text-white" : "text-foreground-sec hover:text-foreground")}>
|
||||
Custom
|
||||
</button>
|
||||
<HelpTooltip text="Enter a custom date and time range to query exactly the data you want.">
|
||||
<button onClick={() => setShowCustom(s => !s)}
|
||||
className={"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer " +
|
||||
(showCustom || appliedRange ? "bg-blue text-white" : "text-foreground-sec hover:text-foreground")}>
|
||||
Custom
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
|
||||
{/* Custom date range picker */}
|
||||
|
|
@ -435,11 +441,13 @@ export default function AnalyticsPage() {
|
|||
<input type="datetime-local" value={customTo} onChange={e => setCustomTo(e.target.value)}
|
||||
className="bg-secondary/60 border border-secondary rounded-lg px-2 py-1 text-foreground text-xs outline-none focus:border-blue/60" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { if (customFrom && customTo) { setAppliedRange({ from: customFrom, to: customTo }); setShowCustom(false); } }}
|
||||
className="px-3 py-1 bg-blue/10 border border-blue/30 text-blue rounded-lg font-medium hover:bg-blue/20 transition-colors cursor-pointer">
|
||||
Apply
|
||||
</button>
|
||||
<HelpTooltip text="Apply the selected custom date range to the chart.">
|
||||
<button
|
||||
onClick={() => { if (customFrom && customTo) { setAppliedRange({ from: customFrom, to: customTo }); setShowCustom(false); } }}
|
||||
className="px-3 py-1 bg-blue/10 border border-blue/30 text-blue rounded-lg font-medium hover:bg-blue/20 transition-colors cursor-pointer">
|
||||
Apply
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -464,38 +472,54 @@ export default function AnalyticsPage() {
|
|||
{/* Chart card */}
|
||||
<div className="bg-primary border border-secondary rounded-2xl p-5 mb-8">
|
||||
{/* Controls row */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-3 mb-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-sm font-medium text-foreground">Power over time</h2>
|
||||
{deviceNames.map(name => {
|
||||
const active = activeDevices.has(name);
|
||||
const color = DEVICE_COLORS[name] ?? "#888";
|
||||
return (
|
||||
<button key={name} onClick={() => toggleDevice(name)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer border"
|
||||
style={{ borderColor: active ? color + "55" : "transparent", background: active ? color + "18" : "transparent", color: active ? color : "var(--color-foreground-sec)" }}>
|
||||
<span className="w-2 h-2 rounded-full" style={{ background: active ? color : "var(--color-secondary)" }} />
|
||||
<span className="capitalize">{name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isZoomed && (
|
||||
<button onClick={resetZoom}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-foreground-sec hover:text-foreground border border-secondary hover:border-secondary/80 transition-colors cursor-pointer">
|
||||
<IconRefresh size={11} />
|
||||
Reset zoom
|
||||
</button>
|
||||
{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 => {
|
||||
const active = activeDevices.has(name);
|
||||
const color = DEVICE_COLORS[name] ?? "#888";
|
||||
return (
|
||||
<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"
|
||||
style={{ borderColor: active ? color + "55" : "transparent", background: active ? color + "18" : "transparent", color: active ? color : "var(--color-foreground-sec)" }}>
|
||||
<span className="w-2 h-2 rounded-full" style={{ background: active ? color : "var(--color-secondary)" }} />
|
||||
<span className="capitalize">{name}</span>
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-0.5 bg-secondary/50 rounded-lg p-0.5">
|
||||
{(["line", "bar", "candle"] as ChartType[]).map(type => (
|
||||
<button key={type} onClick={() => { setChartType(type); resetZoom(); }}
|
||||
className={"px-3 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer capitalize " +
|
||||
(chartType === type ? "bg-primary text-foreground shadow-sm" : "text-foreground-sec hover:text-foreground")}>
|
||||
{type}
|
||||
</div>
|
||||
<div className="flex items-end gap-3">
|
||||
{isZoomed && (
|
||||
<HelpTooltip text="Reset the chart zoom back to the full selected time range.">
|
||||
<button onClick={resetZoom}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-foreground-sec hover:text-foreground border border-secondary hover:border-secondary/80 transition-colors cursor-pointer">
|
||||
<IconRefresh size={11} />
|
||||
Reset zoom
|
||||
</button>
|
||||
))}
|
||||
</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">
|
||||
{(["line", "bar", "candle"] as ChartType[]).map(type => (
|
||||
<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 " +
|
||||
(chartType === type ? "bg-primary text-foreground shadow-sm" : "text-foreground-sec hover:text-foreground")}>
|
||||
{type}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import HelpTooltip from "../components/HelpTooltip";
|
||||
|
||||
function b64uToBuf(b64u: string): ArrayBuffer {
|
||||
const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
|
@ -190,20 +191,22 @@ export default function AuthPage() {
|
|||
<p className="text-[13px] text-red-400 mb-4">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className={`w-full py-2.5 rounded-xl text-md border border-blue/30 text-white font-[600] tracking-wide transition-colors ${
|
||||
busy
|
||||
? "bg-blue/40 cursor-not-allowed"
|
||||
: "bg-blue hover:bg-blue/80 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<HelpTooltip text="Submit your username and password, then touch your YubiKey when prompted to complete sign-in." block>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className={`w-full py-2.5 rounded-xl text-md border border-blue/30 text-white font-[600] tracking-wide transition-colors ${
|
||||
busy
|
||||
? "bg-blue/40 cursor-not-allowed"
|
||||
: "bg-blue hover:bg-blue/80 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{status === "idle" && "Sign in"}
|
||||
{status === "checking" && "Checking…"}
|
||||
{status === "waiting_yubikey" && "Waiting for YubiKey…"}
|
||||
{status === "verifying" && "Verifying…"}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LuX, LuTerminal, LuSend } from "react-icons/lu";
|
||||
import HelpTooltip from "./HelpTooltip";
|
||||
|
||||
export interface LogEntry {
|
||||
id: number;
|
||||
|
|
@ -175,8 +176,8 @@ export default function DevConsole({
|
|||
|
||||
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
{(["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
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
fontSize: "0.68rem",
|
||||
|
|
@ -192,22 +193,25 @@ export default function DevConsole({
|
|||
>
|
||||
{tab === "logs" ? `Logs${logs.length > 0 ? ` (${logs.length})` : ""}` : "Request"}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
))}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
marginLeft: "4px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--color-foreground-sec)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "2px",
|
||||
}}
|
||||
>
|
||||
<LuX size={13} />
|
||||
</button>
|
||||
<HelpTooltip text="Close the dev console.">
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
marginLeft: "4px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--color-foreground-sec)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "2px",
|
||||
}}
|
||||
>
|
||||
<LuX size={13} />
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -273,28 +277,30 @@ export default function DevConsole({
|
|||
minWidth: 0,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={sendRequest}
|
||||
disabled={reqLoading}
|
||||
style={{
|
||||
background: "#428ce2",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
color: "white",
|
||||
padding: "5px 11px",
|
||||
cursor: reqLoading ? "default" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
fontSize: "0.68rem",
|
||||
fontWeight: 500,
|
||||
opacity: reqLoading ? 0.6 : 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<LuSend size={11} />
|
||||
Send
|
||||
</button>
|
||||
<HelpTooltip text="Send the HTTP request to the API and display the response below.">
|
||||
<button
|
||||
onClick={sendRequest}
|
||||
disabled={reqLoading}
|
||||
style={{
|
||||
background: "#428ce2",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
color: "white",
|
||||
padding: "5px 11px",
|
||||
cursor: reqLoading ? "default" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
fontSize: "0.68rem",
|
||||
fontWeight: 500,
|
||||
opacity: reqLoading ? 0.6 : 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<LuSend size={11} />
|
||||
Send
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
|
||||
{["POST", "PUT", "PATCH"].includes(reqMethod) && (
|
||||
|
|
|
|||
130
app/components/HelpTooltip.tsx
Normal file
130
app/components/HelpTooltip.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { type TapoDevice } from "../lib/getPower";
|
||||
import HelpTooltip from "./HelpTooltip";
|
||||
|
||||
interface PowerCardProps {
|
||||
device: TapoDevice | null;
|
||||
|
|
@ -45,17 +46,19 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
|
|||
{device.on ? "On" : "Off"}
|
||||
</span>
|
||||
{onToggle && (
|
||||
<button
|
||||
onClick={() => onToggle(!device.on)}
|
||||
disabled={toggling}
|
||||
className={`text-[0.7rem] font-medium px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
device.on
|
||||
? "border-red-500/20 text-red-400 hover:bg-red-500/10"
|
||||
: "border-green/20 text-green hover:bg-green/10"
|
||||
}`}
|
||||
>
|
||||
{toggling ? "···" : device.on ? "Turn off" : "Turn on"}
|
||||
</button>
|
||||
<HelpTooltip text="Remotely turn this smart plug on or off.">
|
||||
<button
|
||||
onClick={() => onToggle(!device.on)}
|
||||
disabled={toggling}
|
||||
className={`text-[0.7rem] font-medium px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
device.on
|
||||
? "border-red-500/20 text-red-400 hover:bg-red-500/10"
|
||||
: "border-green/20 text-green hover:bg-green/10"
|
||||
}`}
|
||||
>
|
||||
{toggling ? "···" : device.on ? "Turn off" : "Turn on"}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
)}
|
||||
</div>
|
||||
) : 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>
|
||||
</div>
|
||||
<span className="text-[0.7rem] text-foreground-sec mt-1 truncate">
|
||||
{device.alias} · {device.model}
|
||||
{device.model} · {device.ip}
|
||||
</span>
|
||||
|
||||
<div className="h-[3px] bg-secondary rounded-full mt-4 overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -13,9 +13,6 @@ interface PowerGridProps {
|
|||
export default function PowerGrid({ power, onRefresh, showControls = true }: PowerGridProps) {
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
|
||||
const server = power?.devices.find((d) => d.name === "server") ?? null;
|
||||
const desktop = power?.devices.find((d) => d.name === "desktop") ?? null;
|
||||
|
||||
const handleToggle = async (deviceName: string, on: boolean) => {
|
||||
setToggling(deviceName);
|
||||
try {
|
||||
|
|
@ -28,22 +25,27 @@ export default function PowerGrid({ power, onRefresh, showControls = true }: Pow
|
|||
}
|
||||
};
|
||||
|
||||
const devices = power?.devices ?? [];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5 mb-11">
|
||||
<PowerCard
|
||||
device={server}
|
||||
label="Server"
|
||||
delay={0}
|
||||
toggling={toggling === "server"}
|
||||
onToggle={showControls ? (on) => handleToggle("server", on) : undefined}
|
||||
/>
|
||||
<PowerCard
|
||||
device={desktop}
|
||||
label="Desktop"
|
||||
delay={60}
|
||||
toggling={toggling === "desktop"}
|
||||
onToggle={showControls ? (on) => handleToggle("desktop", on) : undefined}
|
||||
/>
|
||||
{devices.length > 0 ? (
|
||||
devices.map((device, i) => (
|
||||
<PowerCard
|
||||
key={device.ip}
|
||||
device={device}
|
||||
label={device.name}
|
||||
delay={i * 60}
|
||||
toggling={toggling === device.name}
|
||||
onToggle={showControls ? (on) => handleToggle(device.name, on) : undefined}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<PowerCard device={null} label="" delay={0} />
|
||||
<PowerCard device={null} label="" delay={60} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import { usePathname, useRouter } from "next/navigation";
|
|||
import {
|
||||
IconHome2, IconMoon, IconSun, IconChevronsLeft, IconChevronsRight,
|
||||
IconMenu2, IconX, IconCode, IconKey, IconLogout, IconUsers, IconChartLine,
|
||||
IconChevronDown, IconBolt, IconChartBar, IconChartCandle,
|
||||
IconChevronDown, IconBolt, IconChartBar, IconChartCandle, IconHelpCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { useSetTheme } from "@/stores/useThemeStore";
|
||||
import { useHelpMode, useToggleHelpMode } from "@/stores/helpModeStore";
|
||||
import HelpTooltip from "./HelpTooltip";
|
||||
import { useFocusedWindowState, requestViewChange } from "@/stores/windowStore";
|
||||
import { PANEL_SECTIONS, type PanelId } from "@/app/components/windows/types";
|
||||
import { SideNavWidgets } from "./SideNavWidgets";
|
||||
|
|
@ -51,7 +53,6 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP
|
|||
if (collapsed) {
|
||||
return (
|
||||
<div className="flex flex-col gap-[2px] px-[8px] mt-1">
|
||||
{/* Dashboard icon */}
|
||||
<button
|
||||
onClick={() => requestViewChange("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" />
|
||||
</button>
|
||||
{/* Section items */}
|
||||
{PANEL_SECTIONS.flatMap((s) =>
|
||||
s.items.map(({ panelId, label }) => {
|
||||
const Icon = ANALYTICS_ICONS[panelId];
|
||||
|
|
@ -92,39 +92,41 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-[1px] px-[8px] mt-1">
|
||||
{/* Dashboard standalone */}
|
||||
<button
|
||||
onClick={() => requestViewChange("dashboard")}
|
||||
className={[
|
||||
"w-full flex items-center gap-[8px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer font-medium",
|
||||
focusedPanelId === "dashboard"
|
||||
? "bg-blue/10 text-blue font-semibold"
|
||||
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
<IconHome2 size={14} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
|
||||
Dashboard
|
||||
</button>
|
||||
<HelpTooltip text="Switch to the main dashboard showing live system stats and power usage." block>
|
||||
<button
|
||||
onClick={() => requestViewChange("dashboard")}
|
||||
className={[
|
||||
"w-full flex items-center gap-[8px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer font-medium",
|
||||
focusedPanelId === "dashboard"
|
||||
? "bg-blue/10 text-blue font-semibold"
|
||||
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
<IconHome2 size={14} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
|
||||
Dashboard
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
|
||||
{/* Power Analytics section */}
|
||||
{PANEL_SECTIONS.map((section) => {
|
||||
const SectionIcon = SECTION_ICONS[section.id] ?? IconBolt;
|
||||
const isOpen = openSections.has(section.id);
|
||||
|
||||
return (
|
||||
<div key={section.id}>
|
||||
<button
|
||||
onClick={() => toggleSection(section.id)}
|
||||
className="w-full flex items-center gap-[8px] px-[10px] py-[5px] rounded-[8px] text-[11px] font-semibold text-foreground-sec hover:bg-secondary/40 hover:text-foreground transition-colors cursor-pointer select-none"
|
||||
>
|
||||
<SectionIcon size={13} className="shrink-0" />
|
||||
<span className="flex-1 text-left">{section.label}</span>
|
||||
<IconChevronDown
|
||||
size={11}
|
||||
className="shrink-0 transition-transform duration-200"
|
||||
style={{ transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)" }}
|
||||
/>
|
||||
</button>
|
||||
<HelpTooltip text={`Expand or collapse the ${section.label} section.`} block>
|
||||
<button
|
||||
onClick={() => toggleSection(section.id)}
|
||||
className="w-full flex items-center gap-[8px] px-[10px] py-[5px] rounded-[8px] text-[11px] font-semibold text-foreground-sec hover:bg-secondary/40 hover:text-foreground transition-colors cursor-pointer select-none"
|
||||
>
|
||||
<SectionIcon size={13} className="shrink-0" />
|
||||
<span className="flex-1 text-left">{section.label}</span>
|
||||
<IconChevronDown
|
||||
size={11}
|
||||
className="shrink-0 transition-transform duration-200"
|
||||
style={{ transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)" }}
|
||||
/>
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
|
||||
{isOpen && (
|
||||
<div className="flex flex-col gap-[1px] pl-[6px]">
|
||||
|
|
@ -132,19 +134,20 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP
|
|||
const Icon = ANALYTICS_ICONS[panelId];
|
||||
const active = focusedPanelId === panelId;
|
||||
return (
|
||||
<button
|
||||
key={panelId}
|
||||
onClick={() => requestViewChange(panelId)}
|
||||
className={[
|
||||
"w-full flex items-center gap-[8px] px-[10px] py-[6px] text-[12px] rounded-[8px] transition-colors cursor-pointer",
|
||||
active
|
||||
? "bg-blue/10 text-blue font-semibold"
|
||||
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon size={13} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
<HelpTooltip key={panelId} text={`Open the ${label} analytics chart.`} block>
|
||||
<button
|
||||
onClick={() => requestViewChange(panelId)}
|
||||
className={[
|
||||
"w-full flex items-center gap-[8px] px-[10px] py-[6px] text-[12px] rounded-[8px] transition-colors cursor-pointer",
|
||||
active
|
||||
? "bg-blue/10 text-blue font-semibold"
|
||||
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon size={13} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -162,6 +165,8 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
|
|||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const setTheme = useSetTheme();
|
||||
const helpMode = useHelpMode();
|
||||
const toggleHelp = useToggleHelpMode();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(220);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
|
@ -282,51 +287,72 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
|
|||
{/* Dev console */}
|
||||
{auth && (
|
||||
<div className="px-[8px] shrink-0">
|
||||
<button onClick={onToggleDevConsole} title={collapsed ? "Dev Console" : undefined}
|
||||
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium " +
|
||||
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") +
|
||||
(devConsoleOpen ? "bg-blue/10 text-blue" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground")}>
|
||||
<IconCode size={16} className="shrink-0" />
|
||||
{!collapsed && "Dev Console"}
|
||||
</button>
|
||||
<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}
|
||||
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium " +
|
||||
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") +
|
||||
(devConsoleOpen ? "bg-blue/10 text-blue" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground")}>
|
||||
<IconCode size={16} className="shrink-0" />
|
||||
{!collapsed && "Dev Console"}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logout */}
|
||||
{auth && (
|
||||
<div className="px-[8px] shrink-0">
|
||||
<button onClick={handleLogout} title={collapsed ? "Log out" : undefined}
|
||||
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium text-red-400 hover:bg-red-500/10 " +
|
||||
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
|
||||
<IconLogout size={16} className="shrink-0" />
|
||||
{!collapsed && "Log out"}
|
||||
</button>
|
||||
<HelpTooltip text="Sign out of your account and return to the login screen." block hidden={collapsed}>
|
||||
<button onClick={handleLogout} title={collapsed ? "Log out" : undefined}
|
||||
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium text-red-400 hover:bg-red-500/10 " +
|
||||
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
|
||||
<IconLogout size={16} className="shrink-0" />
|
||||
{!collapsed && "Log out"}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme toggle */}
|
||||
<div className="px-[8px] mt-[4px] shrink-0">
|
||||
<button onClick={setTheme} title={collapsed ? "Toggle theme" : undefined}
|
||||
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " +
|
||||
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
|
||||
<IconMoon size={16} className="shrink-0 dark-theme:hidden" />
|
||||
<IconSun size={16} className="shrink-0 hidden dark-theme:block" />
|
||||
{!collapsed && <><span className="dark-theme:hidden">Dark mode</span><span className="hidden dark-theme:block">Light mode</span></>}
|
||||
</button>
|
||||
<HelpTooltip text="Switch between light and dark color scheme." block hidden={collapsed}>
|
||||
<button onClick={setTheme} title={collapsed ? "Toggle theme" : undefined}
|
||||
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " +
|
||||
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
|
||||
<IconMoon size={16} className="shrink-0 dark-theme:hidden" />
|
||||
<IconSun size={16} className="shrink-0 hidden dark-theme:block" />
|
||||
{!collapsed && <><span className="dark-theme:hidden">Dark mode</span><span className="hidden dark-theme:block">Light mode</span></>}
|
||||
</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>
|
||||
|
||||
{/* Divider + collapse */}
|
||||
<div className="mx-[8px] my-[8px] border-t border-secondary shrink-0" />
|
||||
<div className="px-[8px] shrink-0">
|
||||
<button onClick={() => setCollapsed((c) => !c)}
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " +
|
||||
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
|
||||
{collapsed
|
||||
? <IconChevronsRight size={16} className="shrink-0" />
|
||||
: <IconChevronsLeft size={16} className="shrink-0" />}
|
||||
{!collapsed && "Collapse"}
|
||||
</button>
|
||||
<HelpTooltip text="Collapse the sidebar to icons only to give more space to the main content." block hidden={collapsed}>
|
||||
<button onClick={() => setCollapsed((c) => !c)}
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " +
|
||||
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
|
||||
{collapsed
|
||||
? <IconChevronsRight size={16} className="shrink-0" />
|
||||
: <IconChevronsLeft size={16} className="shrink-0" />}
|
||||
{!collapsed && "Collapse"}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -344,10 +370,12 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
|
|||
<Link href="/" onClick={() => setMenuOpen(false)}>
|
||||
<img src="/logo.svg" alt="logo" className="max-h-[22px]" />
|
||||
</Link>
|
||||
<button onClick={() => setMenuOpen((o) => !o)}
|
||||
className="ml-auto p-[7px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer">
|
||||
{menuOpen ? <IconX size={18} /> : <IconMenu2 size={18} />}
|
||||
</button>
|
||||
<HelpTooltip text="Open or close the navigation menu.">
|
||||
<button onClick={() => setMenuOpen((o) => !o)}
|
||||
className="ml-auto p-[7px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer">
|
||||
{menuOpen ? <IconX size={18} /> : <IconMenu2 size={18} />}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
|
||||
{/* Mobile dropdown menu */}
|
||||
|
|
@ -383,13 +411,15 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
|
|||
Views
|
||||
</p>
|
||||
{/* Dashboard */}
|
||||
<button
|
||||
onClick={() => { requestViewChange("dashboard"); setMenuOpen(false); }}
|
||||
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
|
||||
(focusedPanelId === "dashboard" ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
|
||||
<IconHome2 size={15} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
|
||||
Dashboard
|
||||
</button>
|
||||
<HelpTooltip text="Switch to the main dashboard showing live system stats and power usage." block>
|
||||
<button
|
||||
onClick={() => { requestViewChange("dashboard"); setMenuOpen(false); }}
|
||||
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
|
||||
(focusedPanelId === "dashboard" ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
|
||||
<IconHome2 size={15} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
|
||||
Dashboard
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
{/* Sections */}
|
||||
{PANEL_SECTIONS.map((section) => (
|
||||
<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 active = focusedPanelId === panelId;
|
||||
return (
|
||||
<button key={panelId}
|
||||
onClick={() => { requestViewChange(panelId); setMenuOpen(false); }}
|
||||
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
|
||||
(active ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
|
||||
<Icon size={15} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
<HelpTooltip key={panelId} text={`Open the ${label} analytics chart.`} block>
|
||||
<button
|
||||
onClick={() => { requestViewChange(panelId); setMenuOpen(false); }}
|
||||
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
|
||||
(active ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
|
||||
<Icon size={15} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -418,27 +450,41 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
|
|||
<div className="mx-[8px] border-t border-secondary" />
|
||||
<div className="p-[8px] flex flex-col gap-[2px]">
|
||||
{auth && (
|
||||
<button onClick={() => { onToggleDevConsole(); setMenuOpen(false); }}
|
||||
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
|
||||
(devConsoleOpen ? "bg-blue/10 text-blue font-medium" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
|
||||
<IconCode size={16} className="shrink-0" />
|
||||
Dev Console
|
||||
</button>
|
||||
<HelpTooltip text="Open the dev console to inspect live API requests and send test requests." block>
|
||||
<button onClick={() => { onToggleDevConsole(); setMenuOpen(false); }}
|
||||
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
|
||||
(devConsoleOpen ? "bg-blue/10 text-blue font-medium" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
|
||||
<IconCode size={16} className="shrink-0" />
|
||||
Dev Console
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
)}
|
||||
{auth && (
|
||||
<button onClick={handleLogout}
|
||||
className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer font-medium">
|
||||
<IconLogout size={16} className="shrink-0" />
|
||||
Log out
|
||||
</button>
|
||||
<HelpTooltip text="Sign out of your account and return to the login screen." block>
|
||||
<button onClick={handleLogout}
|
||||
className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer font-medium">
|
||||
<IconLogout size={16} className="shrink-0" />
|
||||
Log out
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
)}
|
||||
<button onClick={setTheme}
|
||||
className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer font-medium">
|
||||
<IconMoon size={16} className="shrink-0 dark-theme:hidden" />
|
||||
<IconSun size={16} className="shrink-0 hidden dark-theme:block" />
|
||||
<span className="dark-theme:hidden">Dark mode</span>
|
||||
<span className="hidden dark-theme:block">Light mode</span>
|
||||
</button>
|
||||
<HelpTooltip text="Switch between light and dark color scheme." block>
|
||||
<button onClick={setTheme}
|
||||
className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer font-medium">
|
||||
<IconMoon size={16} className="shrink-0 dark-theme:hidden" />
|
||||
<IconSun size={16} className="shrink-0 hidden dark-theme:block" />
|
||||
<span className="dark-theme:hidden">Dark mode</span>
|
||||
<span className="hidden dark-theme:block">Light mode</span>
|
||||
</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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,28 +2,24 @@
|
|||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
import HelpTooltip from "./HelpTooltip";
|
||||
import { type Stats, type NetworkInterface } from "../lib/getStats";
|
||||
import { type PowerData } from "../lib/getPower";
|
||||
import { formatBytes, statColor } from "../lib/utils";
|
||||
|
||||
export type WidgetId =
|
||||
| "cpu" | "memory" | "disk" | "temp"
|
||||
| "power-server" | "power-desktop"
|
||||
| "network" | "uptime" | "empty";
|
||||
export type WidgetId = string;
|
||||
|
||||
const WIDGET_OPTIONS: { id: WidgetId; label: string }[] = [
|
||||
const STATIC_WIDGET_OPTIONS: { id: WidgetId; label: string }[] = [
|
||||
{ id: "empty", label: "Empty" },
|
||||
{ id: "cpu", label: "CPU" },
|
||||
{ id: "memory", label: "Memory" },
|
||||
{ id: "disk", label: "Disk" },
|
||||
{ id: "temp", label: "Temp" },
|
||||
{ id: "power-server", label: "Server Power" },
|
||||
{ id: "power-desktop", label: "Desktop Power" },
|
||||
{ id: "network", label: "Network" },
|
||||
{ id: "uptime", label: "Uptime" },
|
||||
];
|
||||
|
||||
const DEFAULT_WIDGETS: WidgetId[] = ["power-server", "power-desktop", "cpu", "memory"];
|
||||
const DEFAULT_WIDGETS: WidgetId[] = ["cpu", "memory", "network", "uptime"];
|
||||
const STORAGE_KEY = "sidenav-widgets";
|
||||
|
||||
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;
|
||||
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) {
|
||||
return (
|
||||
<select
|
||||
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"
|
||||
>
|
||||
{WIDGET_OPTIONS.map((o) => (
|
||||
{allOptions.map((o) => (
|
||||
<option key={o.id} value={o.id}>{o.label}</option>
|
||||
))}
|
||||
</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 === "disk") return <MiniStatWidget label="Disk" value={stats ? `${stats.disk.percent}%` : "—"} percent={stats?.disk.percent} />;
|
||||
if (id === "temp") return <MiniStatWidget label="Temp" value={stats?.temperature != null ? `${stats.temperature}°C` : "—"} />;
|
||||
if (id === "power-server") return <MiniPowerWidget label="Server" device={power?.devices.find((d) => d.name === "server") ?? null} />;
|
||||
if (id === "power-desktop") return <MiniPowerWidget label="Desktop" device={power?.devices.find((d) => d.name === "desktop") ?? null} />;
|
||||
if (id === "network") return <MiniNetworkWidget speed={netSpeed} />;
|
||||
if (id === "uptime") return <MiniUptimeWidget uptime={stats?.uptime ?? null} />;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +202,7 @@ export function SideNavWidgets() {
|
|||
|
||||
function updateWidget(index: number, id: WidgetId) {
|
||||
setWidgets((prev) => {
|
||||
const next = [...prev] as WidgetId[];
|
||||
const next = [...prev];
|
||||
next[index] = id;
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
|
|
@ -208,13 +215,15 @@ export function SideNavWidgets() {
|
|||
<div className="px-[8px] mb-[4px] shrink-0">
|
||||
<div className="flex items-center justify-between mb-2 px-[2px]">
|
||||
<span className="text-xs text-foreground-sec font-medium">Widgets</span>
|
||||
<button
|
||||
onClick={() => setEditing((e) => !e)}
|
||||
title={editing ? "Done" : "Customize widgets"}
|
||||
className={`p-1 rounded-md transition-colors cursor-pointer ${editing ? "text-blue bg-blue/10" : "text-foreground-sec hover:text-foreground hover:bg-secondary/50"}`}
|
||||
>
|
||||
<IconLayoutGrid size={13} />
|
||||
</button>
|
||||
<HelpTooltip text="Customize which stats appear in the sidebar widgets. Click each slot to swap it for a different metric.">
|
||||
<button
|
||||
onClick={() => setEditing((e) => !e)}
|
||||
title={editing ? "Done" : "Customize widgets"}
|
||||
className={`p-1 rounded-md transition-colors cursor-pointer ${editing ? "text-blue bg-blue/10" : "text-foreground-sec hover:text-foreground hover:bg-secondary/50"}`}
|
||||
>
|
||||
<IconLayoutGrid size={13} />
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{widgets.map((id, i) => (
|
||||
|
|
|
|||
|
|
@ -4,8 +4,14 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
|||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
IconRefresh, IconBolt, IconClock, IconCalendarStats,
|
||||
IconEye, IconChevronDown, IconChartBar,
|
||||
IconEye, IconChevronDown, IconChartBar, IconArrowsHorizontal,
|
||||
} from "@tabler/icons-react";
|
||||
import HelpTooltip from "../HelpTooltip";
|
||||
|
||||
const ctrlLabelStyle: React.CSSProperties = {
|
||||
fontSize: 10, fontWeight: 500,
|
||||
color: "var(--color-foreground-sec)", paddingLeft: 2,
|
||||
};
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -16,7 +22,9 @@ type ChartType = "line" | "bar" | "candle";
|
|||
type Metric = "watts" | "energy" | "cost";
|
||||
type GroupBy = "auto" | "hour" | "day" | "month" | "year";
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -58,6 +66,26 @@ const CANDLE_INTERVALS: { id: CandleInterval; label: string; ms: number }[] = [
|
|||
{ 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 = [
|
||||
"#60a5fa", "#f87171", "#34d399", "#fbbf24", "#a78bfa",
|
||||
"#f472b6", "#22d3ee", "#a3e635", "#fb923c", "#818cf8",
|
||||
|
|
@ -66,6 +94,17 @@ function chartColor(i: number) { return CHART_PALETTE[i % CHART_PALETTE.length];
|
|||
|
||||
// ── 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 {
|
||||
const n = new Date(d);
|
||||
if (groupBy === "hour") { n.setMinutes(0, 0, 0); }
|
||||
|
|
@ -411,7 +450,7 @@ function LineChart({ readings, deviceNames, colors, visible, hours, metric, grou
|
|||
{visEntries.map(([n]) => {
|
||||
const c = colors.get(n) ?? "#888";
|
||||
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="100%" stopColor={c} stopOpacity="0" />
|
||||
</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`;
|
||||
return (
|
||||
<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" />
|
||||
</g>
|
||||
);
|
||||
|
|
@ -491,17 +530,21 @@ function LineChart({ readings, deviceNames, colors, visible, hours, metric, grou
|
|||
// ── Bar Chart ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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>;
|
||||
visible: Set<string>; metric: Metric;
|
||||
visible: Set<string>; metric: Metric; xAxis: BarXAxis;
|
||||
xAxisCustomN: number; xAxisCustomUnit: BarXAxisUnit;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [uid] = useState(() => `bc${Math.random().toString(36).slice(2, 7)}`);
|
||||
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 });
|
||||
|
||||
// Always render the container div so ResizeObserver fires on mount
|
||||
useEffect(() => {
|
||||
const el = containerRef.current; if (!el) return;
|
||||
const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }));
|
||||
|
|
@ -509,44 +552,130 @@ function BarChart({ readings, deviceNames, colors, visible, metric }: {
|
|||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const deviceVals = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const name of deviceNames) {
|
||||
const pts = readings
|
||||
.map(r => { const d = r.devices.find(x => x.name === name); return d ? { ts: new Date(r.ts).getTime(), w: d.watts } : null; })
|
||||
.filter(Boolean).sort((a, b) => a!.ts - b!.ts) as { ts: number; w: number }[];
|
||||
let wh = 0;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
wh += (pts[i].w + pts[i - 1].w) / 2 * (pts[i].ts - pts[i - 1].ts) / 3_600_000;
|
||||
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>();
|
||||
for (const name of deviceNames) {
|
||||
const pts = readings
|
||||
.map(r => { const d = r.devices.find(x => x.name === name); return d ? { ts: new Date(r.ts).getTime(), w: d.watts } : null; })
|
||||
.filter(Boolean).sort((a, b) => a!.ts - b!.ts) as { ts: number; w: number }[];
|
||||
let wh = 0;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
wh += (pts[i].w + pts[i - 1].w) / 2 * (pts[i].ts - pts[i - 1].ts) / 3_600_000;
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
return vis.map(name => ({ label: name, bars: [{ name, val: map.get(name) ?? 0 }] }));
|
||||
}
|
||||
return map;
|
||||
}, [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));
|
||||
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 iW = Math.max(0, w - BM.left - BM.right);
|
||||
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 yMax = yTicks[yTicks.length - 1];
|
||||
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 (
|
||||
<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" }}>
|
||||
<defs>
|
||||
{visDev.map(n => {
|
||||
const c = colors.get(n) ?? "#888";
|
||||
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="100%" stopColor={c} stopOpacity="0.6" />
|
||||
</linearGradient>
|
||||
|
|
@ -557,34 +686,49 @@ function BarChart({ readings, deviceNames, colors, visible, metric }: {
|
|||
{yTicks.map(v => (
|
||||
<g key={v}>
|
||||
<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>
|
||||
))}
|
||||
{visDev.map((n, i) => {
|
||||
const val = deviceVals.get(n) ?? 0;
|
||||
const x = gap + i * (barW + gap);
|
||||
{groups.map((g, gi) => {
|
||||
const gx = gi * groupW + groupPad / 2;
|
||||
const groupCenter = gi * groupW + groupW / 2;
|
||||
return (
|
||||
<g key={n}
|
||||
onMouseEnter={e => { setHover(n); setMouse({ x: e.clientX, y: e.clientY }); }}
|
||||
onMouseMove={e => setMouse({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
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} />
|
||||
<text transform={`translate(${x + barW / 2},${iH + 8}) rotate(-35)`} textAnchor="end" fill="var(--color-foreground-sec)" fontSize={10} style={{ textTransform: "capitalize" }}>{n}</text>
|
||||
<g key={g.label}>
|
||||
{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 })}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
style={{ cursor: "default" }}
|
||||
>
|
||||
<rect x={bx} y={yS(bar.val)} width={barW} height={bh}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
<line x1={0} x2={iW} y1={iH} y2={iH} stroke="var(--color-secondary)" strokeWidth={1} />
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
) : null}
|
||||
{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 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: "50%", background: colors.get(hover) ?? "#888", flexShrink: 0 }} />
|
||||
<span style={{ color: "var(--color-foreground)", fontWeight: 500, textTransform: "capitalize" }}>{hover}</span>
|
||||
<span style={{ color: colors.get(hover) ?? "#888", fontWeight: 700 }}>{fmtMetricVal(deviceVals.get(hover) ?? 0, metric)}</span>
|
||||
<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.name}</span>
|
||||
<span style={{ color: colors.get(hover.name) ?? "#888", fontWeight: 700 }}>{fmtMetricVal(hoverVal, effectiveMetric)}</span>
|
||||
</div>
|
||||
</ChartTooltip>,
|
||||
document.body
|
||||
|
|
@ -750,11 +894,28 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
|
|||
const [metric, setMetric] = useState<Metric>("watts");
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>("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 hours = Math.max(1, parseInt(hoursInput, 10) || 24);
|
||||
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) => {
|
||||
if (flyout?.id === id) { setFlyout(null); return; }
|
||||
const alignRight = rect.left > window.innerWidth * 0.55;
|
||||
|
|
@ -850,11 +1011,14 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
|
|||
};
|
||||
|
||||
// Derived labels for toolbar buttons
|
||||
const metricLabel = METRICS.find(m => m.id === metric)?.label ?? "Power";
|
||||
const rangeLabel = matchedPreset ? matchedPreset.label : `${hours}h`;
|
||||
const groupLabel = GROUP_BY_OPTIONS.find(g => g.id === groupBy)?.label ?? "Auto";
|
||||
const intervalLabel = CANDLE_INTERVALS.find(c => c.id === candleInterval)?.label ?? "Auto";
|
||||
const devLabel = visible.size === deviceNames.length ? "All" : `${visible.size}`;
|
||||
const metricLabel = METRICS.find(m => m.id === metric)?.label ?? "Power";
|
||||
const rangeLabel = matchedPreset ? matchedPreset.label : `${hours}h`;
|
||||
const groupLabel = GROUP_BY_OPTIONS.find(g => g.id === groupBy)?.label ?? "Auto";
|
||||
const intervalLabel = CANDLE_INTERVALS.find(c => c.id === candleInterval)?.label ?? "Auto";
|
||||
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 = (
|
||||
<button onClick={fetchHistory} disabled={loading} style={{
|
||||
|
|
@ -875,18 +1039,38 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
|
|||
{!readOnly && (chartType === "candle" ? (
|
||||
<>
|
||||
{/* ── 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)" }}>
|
||||
<CtrlBtn icon={<IconClock size={13} />} label={rangeLabel} isOpen={flyout?.id === "range"} onClick={(rect, el) => openFlyout("range", rect, el)} />
|
||||
<CtrlBtn icon={<IconChartBar size={13} />} label={intervalLabel} isOpen={flyout?.id === "interval"} onClick={(rect, el) => openFlyout("interval", rect, el)} />
|
||||
<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)} />
|
||||
</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)} />
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
{deviceNames.length > 0 && (
|
||||
<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}>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" }}>{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>
|
||||
{activeFlyout?.id === "range" && (
|
||||
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Time Range">
|
||||
<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={{
|
||||
padding: "5px 4px", borderRadius: 7, fontSize: 11, fontWeight: 500, cursor: "pointer",
|
||||
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 ── */}
|
||||
<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)" }}>
|
||||
<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)} />
|
||||
<CtrlBtn icon={<IconCalendarStats size={13} />} label={groupLabel} isOpen={flyout?.id === "groupby"} onClick={(rect, el) => openFlyout("groupby", rect, el)} />
|
||||
{deviceNames.length > 0 && (
|
||||
<CtrlBtn icon={<IconEye size={13} />} label={devLabel} isOpen={flyout?.id === "devices"} onClick={(rect, el) => openFlyout("devices", rect, el)} />
|
||||
<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)} />
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
{chartType === "bar" && (
|
||||
<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>
|
||||
{activeFlyout?.id === "metric" && (
|
||||
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Metric">
|
||||
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Y Axis">
|
||||
{METRICS.map(m => (
|
||||
<FlyoutOpt key={m.id} label={m.label} selected={metric === m.id} onClick={() => { setMetric(m.id); closeFlyout(); }} />
|
||||
))}
|
||||
</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" && (
|
||||
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Time Range">
|
||||
<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={{
|
||||
padding: "5px 4px", borderRadius: 7, fontSize: 11, fontWeight: 500, cursor: "pointer",
|
||||
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" ? (
|
||||
<LineChart readings={displayReadings} deviceNames={deviceNames} colors={colors} visible={visible} hours={hours} metric={metric} groupBy={groupBy} />
|
||||
) : 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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { getStats, type Stats, type NetworkInterface } from "../../lib/getStats";
|
||||
import AnalyticsPanel from "./AnalyticsPanel";
|
||||
import HelpTooltip from "../HelpTooltip";
|
||||
|
||||
// ── Types / constants ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -211,19 +212,24 @@ export default function DashboardPanel({ isAuthed }: { isAuthed: boolean }) {
|
|||
<p style={{ fontSize: "10pt", fontWeight: 600, color: "var(--color-foreground)", margin: 0 }}>
|
||||
Power Analytics
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
<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 }}>
|
||||
{PRESETS.map(p => (
|
||||
<button key={p.h} onClick={() => setHours(p.h)} style={{
|
||||
padding: "4px 10px", borderRadius: 7, fontSize: "10pt", fontWeight: 500, cursor: "pointer",
|
||||
border: `1px solid ${hours === p.h ? "var(--color-blue)" : "var(--color-secondary)"}`,
|
||||
background: hours === p.h ? "color-mix(in srgb, var(--color-blue) 14%, transparent)" : "transparent",
|
||||
color: hours === p.h ? "var(--color-blue)" : "var(--color-foreground-sec)",
|
||||
transition: "all 120ms",
|
||||
}}>
|
||||
{p.label}
|
||||
</button>
|
||||
<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",
|
||||
border: `1px solid ${hours === p.h ? "var(--color-blue)" : "var(--color-secondary)"}`,
|
||||
background: hours === p.h ? "color-mix(in srgb, var(--color-blue) 14%, transparent)" : "transparent",
|
||||
color: hours === p.h ? "var(--color-blue)" : "var(--color-foreground-sec)",
|
||||
transition: "all 120ms",
|
||||
}}>
|
||||
{p.label}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import HelpTooltip from "./HelpTooltip";
|
||||
|
||||
const SERVICES = [
|
||||
"syncthing",
|
||||
|
|
@ -140,12 +141,14 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) {
|
|||
Manage services
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<HelpTooltip text="Close this control panel.">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
|
|
@ -165,25 +168,28 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) {
|
|||
</div>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
{["start", "stop", "restart"].map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
onClick={() =>
|
||||
!isLoading(svc, action) && handleService(action, svc)
|
||||
}
|
||||
disabled={isLoading(svc, action)}
|
||||
className={btnClass(svc, action)}
|
||||
>
|
||||
{isLoading(svc, action)
|
||||
? "..."
|
||||
: action.charAt(0).toUpperCase() + action.slice(1)}
|
||||
</button>
|
||||
<HelpTooltip key={action} text={`${action.charAt(0).toUpperCase() + action.slice(1)} the ${svc} service.`}>
|
||||
<button
|
||||
onClick={() =>
|
||||
!isLoading(svc, action) && handleService(action, svc)
|
||||
}
|
||||
disabled={isLoading(svc, action)}
|
||||
className={btnClass(svc, action)}
|
||||
>
|
||||
{isLoading(svc, action)
|
||||
? "..."
|
||||
: action.charAt(0).toUpperCase() + action.slice(1)}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
))}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
<HelpTooltip text={`Fetch and display recent logs for the ${svc} service.`}>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -202,12 +208,14 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) {
|
|||
Immediately restarts the machine
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Reboot
|
||||
</button>
|
||||
<HelpTooltip text="Immediately restart the server. All services will briefly go offline.">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Reboot
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</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">
|
||||
|
|
@ -219,12 +227,14 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) {
|
|||
Powers off the machine
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Shut down
|
||||
</button>
|
||||
<HelpTooltip text="Power off the server completely. You will need physical access to turn it back on.">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Shut down
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { LeafNode, PanelId, PANEL_LABELS, PANEL_SECTIONS } from "./types";
|
|||
import {
|
||||
IconX, IconLayoutColumns, IconLayoutRows, IconRefresh,
|
||||
} from "@tabler/icons-react";
|
||||
import HelpTooltip from "../HelpTooltip";
|
||||
|
||||
const DashboardPanel = lazy(() => import("../panels/DashboardPanel"));
|
||||
const AnalyticsPanel = lazy(() => import("../panels/AnalyticsPanel"));
|
||||
|
|
@ -189,38 +190,46 @@ function WindowControls({
|
|||
onMouseLeave={() => setPillHovered(false)}
|
||||
>
|
||||
{canClose && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose(paneId); }}
|
||||
className={`${BTN} hover:text-red-400 hover:bg-red-500/10`}
|
||||
title="Close"
|
||||
>
|
||||
<IconX size={13} />
|
||||
</button>
|
||||
<HelpTooltip text="Close this panel pane.">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose(paneId); }}
|
||||
className={`${BTN} hover:text-red-400 hover:bg-red-500/10`}
|
||||
title="Close"
|
||||
>
|
||||
<IconX size={13} />
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
)}
|
||||
<button
|
||||
ref={changeRef}
|
||||
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "change" ? null : "change"); }}
|
||||
className={`${BTN} ${menu === "change" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
|
||||
title="Change view"
|
||||
>
|
||||
<IconRefresh size={13} />
|
||||
</button>
|
||||
<button
|
||||
ref={rightRef}
|
||||
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "right" ? null : "right"); }}
|
||||
className={`${BTN} ${menu === "right" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
|
||||
title="Tile right"
|
||||
>
|
||||
<IconLayoutColumns size={13} />
|
||||
</button>
|
||||
<button
|
||||
ref={downRef}
|
||||
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "down" ? null : "down"); }}
|
||||
className={`${BTN} ${menu === "down" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
|
||||
title="Tile down"
|
||||
>
|
||||
<IconLayoutRows size={13} />
|
||||
</button>
|
||||
<HelpTooltip text="Change what this pane displays — pick a different analytics view.">
|
||||
<button
|
||||
ref={changeRef}
|
||||
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "change" ? null : "change"); }}
|
||||
className={`${BTN} ${menu === "change" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
|
||||
title="Change view"
|
||||
>
|
||||
<IconRefresh size={13} />
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
<HelpTooltip text="Split this pane and open a new panel to the right.">
|
||||
<button
|
||||
ref={rightRef}
|
||||
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "right" ? null : "right"); }}
|
||||
className={`${BTN} ${menu === "right" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
|
||||
title="Tile right"
|
||||
>
|
||||
<IconLayoutColumns size={13} />
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
<HelpTooltip text="Split this pane and open a new panel below.">
|
||||
<button
|
||||
ref={downRef}
|
||||
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "down" ? null : "down"); }}
|
||||
className={`${BTN} ${menu === "down" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
|
||||
title="Tile down"
|
||||
>
|
||||
<IconLayoutRows size={13} />
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
|
||||
{menu && (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import HelpTooltip from "../components/HelpTooltip";
|
||||
|
||||
function b64uToBuf(b64u: string): ArrayBuffer {
|
||||
const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
|
@ -187,6 +188,7 @@ export default function EnrollPage() {
|
|||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<HelpTooltip text="Register your YubiKey as an authentication device for this account." block>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
|
|
@ -202,6 +204,7 @@ export default function EnrollPage() {
|
|||
{status === "waiting_yubikey" && "Touch YubiKey…"}
|
||||
{status === "saving" && "Saving…"}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import SideNav from "../components/SideNav";
|
||||
import HelpTooltip from "../components/HelpTooltip";
|
||||
import {
|
||||
IconSearch,
|
||||
IconX,
|
||||
|
|
@ -246,12 +247,14 @@ export default function UsersPage() {
|
|||
className="flex-1 bg-transparent text-[12px] text-foreground placeholder:text-foreground-sec outline-none"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="text-foreground-sec hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
<HelpTooltip text="Clear the search filter.">
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="text-foreground-sec hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -390,6 +393,7 @@ export default function UsersPage() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<HelpTooltip text="Remove this YubiKey credential. The user will no longer be able to log in with it.">
|
||||
<button
|
||||
onClick={() => deleteCredential(cred.id)}
|
||||
disabled={deletingId === cred.id}
|
||||
|
|
@ -402,6 +406,7 @@ export default function UsersPage() {
|
|||
<IconTrash size={13} />
|
||||
)}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -420,12 +425,14 @@ export default function UsersPage() {
|
|||
{enrollStatus === "done" ? (
|
||||
<div className="flex items-center gap-[8px] px-[12px] py-[10px] rounded-xl bg-green/10 text-green text-[12px] font-semibold">
|
||||
{enrollMessage}
|
||||
<button
|
||||
onClick={() => setEnrollStatus("idle")}
|
||||
className="ml-auto text-green/60 hover:text-green cursor-pointer"
|
||||
>
|
||||
<IconX size={13} />
|
||||
</button>
|
||||
<HelpTooltip text="Dismiss this success message.">
|
||||
<button
|
||||
onClick={() => setEnrollStatus("idle")}
|
||||
className="ml-auto text-green/60 hover:text-green cursor-pointer"
|
||||
>
|
||||
<IconX size={13} />
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<HelpTooltip text="Start the YubiKey enrollment flow — you'll be prompted to touch the key to register it.">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={enrollBusy}
|
||||
|
|
@ -473,6 +481,7 @@ export default function UsersPage() {
|
|||
? "Touch YubiKey…"
|
||||
: "Saving…"}
|
||||
</button>
|
||||
</HelpTooltip>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue