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