- {shouldShowStats &&
}
+
+
+
+
+
+
+
+
+
+
+
+ {shouldShowStats && }
+ {currentTab === "overview" && projectId && (
+
+ )}
+
+
+
);
});
diff --git a/src/components/overview/OverviewGrid.jsx b/src/components/overview/OverviewGrid.jsx
new file mode 100644
index 0000000..aa59e70
--- /dev/null
+++ b/src/components/overview/OverviewGrid.jsx
@@ -0,0 +1,168 @@
+import React, { useEffect, useMemo, useRef } from "react";
+import GridLayout, { WidthProvider } from "@eleung/react-grid-layout";
+import { useAtomValue } from "jotai";
+
+import TileShell from "./TileShell";
+import useProjectLayout from "./useProjectLayout";
+
+import { overviewLayoutResetSignalAtom } from "@/store/atoms";
+
+import ConsoleMiniTile from "./tiles/ConsoleMiniTile";
+import TunnelMiniTile from "./tiles/TunnelMiniTile";
+import TunnelLogsMiniTile from "./tiles/TunnelLogsMiniTile";
+import MiniFileExhibitorTile from "./tiles/MiniFileExhibitorTile";
+import CpuTile from "./tiles/CpuTile";
+import RamTile from "./tiles/RamTile";
+import ProcessIdsTile from "./tiles/ProcessIdsTile";
+
+const ReactGridLayout = WidthProvider(GridLayout);
+
+export default function OverviewGrid({
+ project,
+ fileTree,
+ isFileTreeLoading,
+ stats,
+ historySamples,
+ tunnelState,
+ onSendInput,
+ onOpenFile,
+}) {
+ const projectTunnelState = project ? tunnelState?.[project.id] : null;
+
+ const defaultLayout = useMemo(
+ () => [
+ // Balanced "first impression" layout.
+ // Gives the PIDs tile enough height for internal scrolling.
+ // Matches the saved "perfect" layout from localStorage for projectId=1.
+ { i: "console", x: 0, y: 0, w: 8, h: 6, minW: 5, minH: 4 },
+ { i: "tunnelLogs", x: 8, y: 0, w: 4, h: 10, minW: 3, minH: 3 },
+ { i: "cpu", x: 0, y: 6, w: 4, h: 6, minW: 2, minH: 2 },
+ { i: "ram", x: 4, y: 6, w: 4, h: 6, minW: 2, minH: 2 },
+ { i: "tunnel", x: 8, y: 10, w: 4, h: 6, minW: 3, minH: 2 },
+ { i: "pids", x: 0, y: 12, w: 5, h: 4, minW: 3, minH: 3 },
+ { i: "files", x: 5, y: 12, w: 3, h: 4, minW: 3, minH: 3 },
+ ],
+ []
+ );
+
+ const { layout, onLayoutChange, resetLayout } = useProjectLayout({
+ projectId: project?.id ?? null,
+ defaultLayout,
+ });
+
+ const resetSignal = useAtomValue(overviewLayoutResetSignalAtom);
+ const didMountRef = useRef(false);
+
+ useEffect(() => {
+ // Avoid resetting layout on initial mount.
+ if (!didMountRef.current) {
+ didMountRef.current = true;
+ return;
+ }
+ resetLayout();
+ }, [resetSignal, resetLayout]);
+
+ const tiles = [
+ {
+ i: "console",
+ headerRight: (
+
+ ),
+ render: (
+
+ ),
+ },
+ {
+ i: "tunnel",
+ headerRight: (
+
+ ),
+ render:
,
+ },
+ {
+ i: "tunnelLogs",
+ render:
,
+ },
+ {
+ i: "files",
+ render: (
+
+ ),
+ },
+ {
+ i: "cpu",
+ render:
,
+ },
+ {
+ i: "ram",
+ render:
,
+ },
+ {
+ i: "pids",
+ render:
,
+ },
+ ];
+
+ return (
+
+
+
onLayoutChange(nextLayout)}
+ onResizeStop={(nextLayout) => onLayoutChange(nextLayout)}
+ >
+ {tiles.map((t) => (
+
+
+ {t.render}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/overview/TileShell.jsx b/src/components/overview/TileShell.jsx
new file mode 100644
index 0000000..6a025f1
--- /dev/null
+++ b/src/components/overview/TileShell.jsx
@@ -0,0 +1,18 @@
+import React from "react";
+
+export default function TileShell({ title, right, children }) {
+ return (
+
+
+
+
+ {title}
+
+
+ {right}
+
+
{children}
+
+ );
+}
+
diff --git a/src/components/overview/charts/CompactStatCard.jsx b/src/components/overview/charts/CompactStatCard.jsx
new file mode 100644
index 0000000..119fcb8
--- /dev/null
+++ b/src/components/overview/charts/CompactStatCard.jsx
@@ -0,0 +1,18 @@
+import React from "react";
+import { cn } from "@/lib/utils";
+
+export default function CompactStatCard({ label, value, subtitle, accent }) {
+ return (
+
+
+
+
+ {label}
+
+
+
{value}
+ {subtitle ?
{subtitle}
: null}
+
+ );
+}
+
diff --git a/src/components/overview/charts/MiniSparkline.jsx b/src/components/overview/charts/MiniSparkline.jsx
new file mode 100644
index 0000000..93283f5
--- /dev/null
+++ b/src/components/overview/charts/MiniSparkline.jsx
@@ -0,0 +1,32 @@
+import React from "react";
+
+export default function MiniSparkline({ samples, valueKey, maxValue, color }) {
+ if (!samples || samples.length < 3) return null;
+
+ const W = 72;
+ const H = 20;
+ const recent = samples.slice(-30);
+ const effectiveMax = maxValue > 0 ? maxValue : Math.max(...recent.map((s) => s[valueKey] ?? 0), 1);
+
+ const pts = recent
+ .map((s, i) => {
+ const x = (i / (recent.length - 1)) * W;
+ const y = H - ((s[valueKey] ?? 0) / effectiveMax) * H;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ })
+ .join(" ");
+
+ return (
+
+ );
+}
+
diff --git a/src/components/overview/charts/PremiumSparklineChart.jsx b/src/components/overview/charts/PremiumSparklineChart.jsx
new file mode 100644
index 0000000..5ef0b33
--- /dev/null
+++ b/src/components/overview/charts/PremiumSparklineChart.jsx
@@ -0,0 +1,86 @@
+import React from "react";
+
+export default function PremiumSparklineChart({
+ samples,
+ valueKey,
+ maxValue,
+ accentColor,
+ label,
+ unit = "",
+ variant = "compact",
+}) {
+ const size = variant === "compact" ? { W: 320, H: 86 } : { W: 600, H: 160 };
+ const W = size.W;
+ const H = size.H;
+
+ const PADDING = variant === "compact" ? { top: 10, right: 14, bottom: 20, left: 34 } : { top: 14, right: 16, bottom: 28, left: 44 };
+ const chartW = W - PADDING.left - PADDING.right;
+ const chartH = H - PADDING.top - PADDING.bottom;
+
+ if (!samples || samples.length < 2) {
+ return (
+
+ Collecting data…
+
+ );
+ }
+
+ const values = samples.map((s) => s[valueKey] ?? 0);
+ const effectiveMax = maxValue > 0 ? maxValue : Math.max(...values, 1);
+
+ const toX = (i) => PADDING.left + (i / (samples.length - 1)) * chartW;
+ const toY = (v) => PADDING.top + chartH - (Math.min(v, effectiveMax) / effectiveMax) * chartH;
+
+ const points = samples.map((s, i) => `${toX(i)},${toY(s[valueKey] ?? 0)}`).join(" ");
+ const fillPoints = `${PADDING.left},${PADDING.top + chartH} ${points} ${toX(samples.length - 1)},${PADDING.top + chartH}`;
+
+ const gridLines = [0, 0.25, 0.5, 0.75, 1].map((frac) => ({
+ y: PADDING.top + (1 - frac) * chartH,
+ label:
+ maxValue > 0
+ ? (frac * effectiveMax).toFixed(0) + unit
+ : (frac * effectiveMax).toFixed(1) + unit,
+ }));
+
+ const gradId = `overview-grad-${label.replace(/\s/g, "")}-${variant}`;
+
+ return (
+
+
+ {label}
+
+
+
+ );
+}
+
diff --git a/src/components/overview/tiles/ConsoleMiniTile.jsx b/src/components/overview/tiles/ConsoleMiniTile.jsx
new file mode 100644
index 0000000..0672435
--- /dev/null
+++ b/src/components/overview/tiles/ConsoleMiniTile.jsx
@@ -0,0 +1,201 @@
+import React, { useEffect, useRef, useState } from "react";
+import { useAtomValue } from "jotai";
+import { Terminal as TerminalIcon, Send } from "lucide-react";
+import { toast } from "react-toastify";
+import { Terminal } from "@xterm/xterm";
+import { FitAddon } from "@xterm/addon-fit";
+import { ClipboardAddon } from "@xterm/addon-clipboard";
+import { WebLinksAddon } from "@xterm/addon-web-links";
+
+import { Button } from "@/components/ui/button";
+import { logsAtom } from "@/store/atoms";
+
+const API = window.api;
+
+export default function ConsoleMiniTile({ projectId, status, onSendInput }) {
+ const allLogs = useAtomValue(logsAtom);
+ const logs = allLogs?.[projectId] || [];
+
+ const terminalContainerRef = useRef(null);
+ const xtermRef = useRef(null);
+ const fitAddonRef = useRef(null);
+ const lastLogIndexRef = useRef(0);
+
+ const [input, setInput] = useState("");
+ const [history, setHistory] = useState([]);
+ const [historyIndex, setHistoryIndex] = useState(-1);
+ const [isMounted, setIsMounted] = useState(false);
+
+ useEffect(() => {
+ lastLogIndexRef.current = 0;
+
+ const term = new Terminal({
+ fontFamily: "monospace",
+ scrollOnUserInput: true,
+ smoothScrollDuration: 0,
+ fontSize: 13,
+ convertEol: true,
+ scrollback: 2000,
+ theme: {
+ background: "#0a0a0c",
+ foreground: "#e5e7eb",
+ cursor: "#22c55e",
+ },
+ disableStdin: true, // allow native selection/copy behavior
+ });
+
+ const fitAddon = new FitAddon();
+ term.loadAddon(fitAddon);
+ term.loadAddon(new ClipboardAddon());
+
+ const webLinksAddon = new WebLinksAddon((event, uri) => {
+ event.preventDefault();
+ API.openExternal(uri);
+ });
+ term.loadAddon(webLinksAddon);
+
+ term.attachCustomKeyEventHandler((arg) => {
+ if (arg.ctrlKey && (arg.code === "KeyC" || arg.code === "KeyV")) return false;
+ return true;
+ });
+
+ term.open(terminalContainerRef.current);
+
+ requestAnimationFrame(() => {
+ try {
+ fitAddon.fit();
+ } catch (e) {
+ console.warn("Overview ConsoleTerm fit error", e);
+ }
+ });
+
+ const resizeObserver = new ResizeObserver(() => {
+ if (terminalContainerRef.current && terminalContainerRef.current.clientWidth > 0) {
+ try {
+ fitAddon.fit();
+ } catch (e) {
+ console.warn("Overview ConsoleTerm resize fit error", e);
+ }
+ }
+ });
+ resizeObserver.observe(terminalContainerRef.current);
+
+ xtermRef.current = term;
+ fitAddonRef.current = fitAddon;
+ setIsMounted(true);
+
+ return () => {
+ resizeObserver.disconnect();
+ term.dispose();
+ };
+ }, [projectId]);
+
+ useEffect(() => {
+ if (!xtermRef.current) return;
+
+ const term = xtermRef.current;
+
+ // If logs have shrunk (cleared), clear terminal and reset index
+ if (lastLogIndexRef.current > logs.length) {
+ term.clear();
+ lastLogIndexRef.current = 0;
+ }
+
+ for (let i = lastLogIndexRef.current; i < logs.length; i++) {
+ const log = logs[i];
+ if (!log?.data) continue;
+
+ if (log.type === "stdin") term.write(`\x1b[36m${log.data}\x1b[0m`);
+ else term.write(log.data);
+ }
+
+ lastLogIndexRef.current = logs.length;
+ }, [logs]);
+
+ const handleSend = async () => {
+ if (!input.trim() || status !== "running") return;
+ const dataToSend = input;
+
+ setHistory((prev) => [...prev, dataToSend]);
+ setHistoryIndex(-1);
+ setInput("");
+
+ try {
+ await onSendInput(projectId, dataToSend);
+ } catch {
+ toast.error("Failed to send command");
+ }
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === "Enter") {
+ void handleSend();
+ return;
+ }
+
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ if (history.length === 0) return;
+ const newIndex = historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1);
+ setHistoryIndex(newIndex);
+ setInput(history[newIndex]);
+ return;
+ }
+
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ if (history.length === 0 || historyIndex === -1) return;
+ const newIndex = historyIndex + 1;
+ if (newIndex >= history.length) {
+ setHistoryIndex(-1);
+ setInput("");
+ } else {
+ setHistoryIndex(newIndex);
+ setInput(history[newIndex]);
+ }
+ }
+ };
+
+ return (
+
+
+ {!isMounted &&
Loading…
}
+ {logs.length === 0 && isMounted && (
+
+ )}
+
+
+
+
+
+ $
+ setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={status === "running" ? "Type command…" : "Project is offline"}
+ spellCheck={false}
+ autoComplete="off"
+ disabled={status !== "running"}
+ />
+
+
+
+
+
+ );
+}
+
diff --git a/src/components/overview/tiles/CpuTile.jsx b/src/components/overview/tiles/CpuTile.jsx
new file mode 100644
index 0000000..4c2cd8a
--- /dev/null
+++ b/src/components/overview/tiles/CpuTile.jsx
@@ -0,0 +1,34 @@
+import React, { useMemo } from "react";
+import CompactStatCard from "../charts/CompactStatCard";
+import PremiumSparklineChart from "../charts/PremiumSparklineChart";
+
+export default function CpuTile({ stats, historySamples }) {
+ const cpu = stats?.cpu ?? 0;
+ const samples = historySamples || [];
+
+ const cpuValue = useMemo(() => Number(cpu).toFixed(1), [cpu]);
+
+ return (
+
+ );
+}
+
diff --git a/src/components/overview/tiles/MiniFileExhibitorTile.jsx b/src/components/overview/tiles/MiniFileExhibitorTile.jsx
new file mode 100644
index 0000000..2f9d2fe
--- /dev/null
+++ b/src/components/overview/tiles/MiniFileExhibitorTile.jsx
@@ -0,0 +1,83 @@
+import React, { useEffect, useState } from "react";
+
+export default function MiniFileExhibitorTile({ fileTree, onOpenFile, isLoading }) {
+ const [expanded, setExpanded] = useState({});
+
+ useEffect(() => {
+ setExpanded({});
+ }, [fileTree]);
+
+ const maxDepth = 2;
+ const maxShown = 60;
+ let shownCount = 0;
+
+ const renderNode = (node, level) => {
+ if (shownCount >= maxShown) return null;
+ shownCount += 1;
+
+ const isDir = node.type === "directory";
+ const path = node.path;
+ const isOpen = expanded[path] || false;
+
+ const indent = level * 10;
+ if (isDir) {
+ if (level >= maxDepth) return null;
+ const children = Array.isArray(node.children) ? node.children : [];
+ return (
+
+
setExpanded((prev) => ({ ...prev, [path]: !prev[path] }))}
+ >
+ {isOpen ? "▾" : "▸"}
+ {node.name}
+
+ {isOpen && children.length > 0 && (
+
{children.map((child) => renderNode(child, level + 1))}
+ )}
+
+ );
+ }
+
+ if (level > maxDepth) return null;
+ return (
+
+
onOpenFile(node.path)}
+ title={node.path}
+ >
+ •
+ {node.name}
+
+
+ );
+ };
+
+ return (
+
+ {isLoading ? (
+
+ Loading files…
+
+ ) : fileTree && fileTree.length > 0 ? (
+
+
+ Files (preview)
+
+
{fileTree.map((n) => renderNode(n, 0))}
+ {shownCount >= maxShown && (
+
… truncated
+ )}
+
+ ) : (
+
+ No files found.
+
+ )}
+
+ );
+}
+
diff --git a/src/components/overview/tiles/ProcessIdsTile.jsx b/src/components/overview/tiles/ProcessIdsTile.jsx
new file mode 100644
index 0000000..d1906ff
--- /dev/null
+++ b/src/components/overview/tiles/ProcessIdsTile.jsx
@@ -0,0 +1,103 @@
+import React, { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { toast } from "react-toastify";
+
+export default function ProcessIdsTile({ stats }) {
+ const [isCopyingId, setIsCopyingId] = useState(null);
+
+ const mainPid = stats?.mainPid ?? null;
+ const pids = Array.isArray(stats?.pids) ? stats.pids : [];
+ const activeProcesses = stats?.activeProcesses ?? stats?.processCount ?? 0;
+
+ const copyPid = async (pid) => {
+ try {
+ setIsCopyingId(pid);
+ await navigator.clipboard.writeText(String(pid));
+ toast.success(`Copied PID ${pid}`);
+ } catch {
+ toast.error(`Failed to copy PID ${pid}`);
+ } finally {
+ setIsCopyingId(null);
+ }
+ };
+
+ if (pids.length === 0 && (stats?.activeProcesses ?? 0) > 0) {
+ return (
+
+
+ PID enumeration not available for this project (recovered Job Object). Active processes:{" "}
+ {activeProcesses}
+
+
+ );
+ }
+
+ if (pids.length === 0) {
+ return (
+
+
No processes detected.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ |
+ PID
+ |
+
+ Role
+ |
+
+ Copy
+ |
+
+
+
+ {pids.map((pid) => {
+ const isMain = pid === mainPid;
+ return (
+
+ |
+
+ {pid}
+
+ |
+
+ {isMain ? (
+
+ Main
+
+ ) : (
+ Child
+ )}
+ |
+
+
+ |
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
diff --git a/src/components/overview/tiles/RamTile.jsx b/src/components/overview/tiles/RamTile.jsx
new file mode 100644
index 0000000..eed5aa7
--- /dev/null
+++ b/src/components/overview/tiles/RamTile.jsx
@@ -0,0 +1,41 @@
+import React, { useMemo } from "react";
+import { formatMemory } from "@/lib/formatMemory";
+import CompactStatCard from "../charts/CompactStatCard";
+import PremiumSparklineChart from "../charts/PremiumSparklineChart";
+
+export default function RamTile({ stats, historySamples }) {
+ const mem = stats?.memory ?? 0;
+
+ const historyForMem = useMemo(
+ () => (historySamples ?? []).map((s) => ({ memoryMB: s.memory / (1024 * 1024) })),
+ [historySamples]
+ );
+
+ const maxMemMB = useMemo(() => {
+ if (historyForMem.length === 0) return 1;
+ return Math.max(...historyForMem.map((s) => s.memoryMB ?? 0), 1);
+ }, [historyForMem]);
+
+ return (
+
+ );
+}
diff --git a/src/components/overview/tiles/TunnelLogsMiniTile.jsx b/src/components/overview/tiles/TunnelLogsMiniTile.jsx
new file mode 100644
index 0000000..a81dca7
--- /dev/null
+++ b/src/components/overview/tiles/TunnelLogsMiniTile.jsx
@@ -0,0 +1,11 @@
+import React from "react";
+import TunnelLogViewer from "@/components/TunnelLogViewer";
+
+export default function TunnelLogsMiniTile({ logs }) {
+ return (
+
+
+
+ );
+}
+
diff --git a/src/components/overview/tiles/TunnelMiniTile.jsx b/src/components/overview/tiles/TunnelMiniTile.jsx
new file mode 100644
index 0000000..b100cb4
--- /dev/null
+++ b/src/components/overview/tiles/TunnelMiniTile.jsx
@@ -0,0 +1,184 @@
+import React, { useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Copy, ExternalLink } from "lucide-react";
+import { toast } from "react-toastify";
+
+const API = window.api;
+
+export default function TunnelMiniTile({ project, tunnelState }) {
+ const projectId = project.id;
+ const state =
+ tunnelState?.[projectId] || { status: "stopped", url: null, logs: [], error: null };
+
+ const status = state.status || "stopped";
+ const url = state.url || null;
+ const error = state.error || null;
+
+ const [isProcessing, setIsProcessing] = useState(false);
+
+ const portNum = useMemo(() => {
+ if (project?.tunnelPort == null) return 3000;
+ const parsed = parseInt(String(project.tunnelPort), 10);
+ return Number.isFinite(parsed) ? parsed : 3000;
+ }, [project?.tunnelPort]);
+
+ const mode = project?.tunnelMode || "quick";
+ const token = project?.encryptedTunnelToken || "";
+
+ const config = useMemo(() => {
+ const cfg = project?.tunnelConfig || {};
+ return {
+ protocol: cfg?.protocol || "http2",
+ loglevel: cfg?.loglevel || "info",
+ noTLSVerify: cfg?.noTLSVerify || false,
+ connectTimeout: cfg?.connectTimeout || "30s",
+ httpHostHeader: cfg?.httpHostHeader || "",
+ };
+ }, [project?.tunnelConfig]);
+
+ const startTunnel = async () => {
+ // Validation consistent with TunnelView logic.
+ if (!Number.isFinite(portNum) || portNum < 1 || portNum > 65535) {
+ toast.error("Please enter a valid tunnel port number (1-65535)");
+ return;
+ }
+
+ if (mode === "authenticated" && !token.trim()) {
+ toast.error("Cloudflare Tunnel Token is required for authenticated mode");
+ return;
+ }
+
+ setIsProcessing(true);
+ try {
+ const res = await API.startTunnel(projectId, {
+ mode,
+ port: portNum,
+ token,
+ config,
+ });
+ if (!res?.success) toast.error(`Failed to start tunnel: ${res?.message || "Unknown error"}`);
+ } catch {
+ toast.error("Failed to start tunnel");
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const stopTunnel = async () => {
+ setIsProcessing(true);
+ try {
+ const res = await API.stopTunnel(projectId);
+ if (!res?.success) toast.error(`Failed to stop tunnel: ${res?.message || "Unknown error"}`);
+ } catch {
+ toast.error("Failed to stop tunnel");
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const openUrl = () => {
+ if (!url) return;
+ API.openExternal(url);
+ };
+
+ const copyUrl = async () => {
+ if (!url) return;
+ try {
+ await navigator.clipboard.writeText(url);
+ toast.success("Tunnel URL copied");
+ } catch {
+ toast.error("Failed to copy URL");
+ }
+ };
+
+ const isRunning = status === "running";
+ const isConnecting = status === "connecting";
+ const isOffline = status === "stopped" || status === "error";
+
+ return (
+
+
+
+
+
+
+
+ {isRunning
+ ? "Running"
+ : isConnecting
+ ? "Connecting"
+ : status === "error"
+ ? "Error"
+ : "Offline"}
+
+
+ {isRunning && url
+ ? url
+ : error
+ ? String(error)
+ : `: localhost:${portNum}`}
+
+
+
+
+
+ {isRunning && url ? (
+
+
+
+
+ ) : (
+
+ {mode === "authenticated" ? "Authenticated tunnel" : "Quick tunnel"} on :{portNum}
+
+ )}
+
+
+ {isOffline ? (
+
+ ) : (
+
+ )}
+
+ {mode === "authenticated" && isOffline && !token.trim() && (
+
Token missing (configure it in Tunnel tab).
+ )}
+
+
+
+ );
+}
+
diff --git a/src/components/overview/useProjectLayout.js b/src/components/overview/useProjectLayout.js
new file mode 100644
index 0000000..0b6ddf5
--- /dev/null
+++ b/src/components/overview/useProjectLayout.js
@@ -0,0 +1,69 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export default function useProjectLayout({ projectId, defaultLayout }) {
+ const [layout, setLayout] = useState(defaultLayout);
+ const saveTimerRef = useRef(null);
+ const defaultLayoutRef = useRef(defaultLayout);
+ const projectIdRef = useRef(projectId);
+
+ useEffect(() => {
+ defaultLayoutRef.current = defaultLayout;
+ }, [defaultLayout]);
+
+ useEffect(() => {
+ projectIdRef.current = projectId;
+ }, [projectId]);
+
+ useEffect(() => {
+ if (projectId == null) return;
+
+ const key = `selfhost-overview-grid:${projectId}`;
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) {
+ setLayout(defaultLayout);
+ return;
+ }
+
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed) && parsed.length > 0) setLayout(parsed);
+ else setLayout(defaultLayout);
+ } catch {
+ setLayout(defaultLayout);
+ }
+ }, [projectId, defaultLayout]);
+
+ const onLayoutChange = (nextLayout) => {
+ setLayout(nextLayout);
+
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ saveTimerRef.current = setTimeout(() => {
+ if (projectId == null) return;
+ const key = `selfhost-overview-grid:${projectId}`;
+ try {
+ localStorage.setItem(key, JSON.stringify(nextLayout));
+ } catch {
+ // Ignore storage errors (private mode / quota).
+ }
+ }, 250);
+ };
+
+ const resetLayout = useCallback(() => {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ saveTimerRef.current = null;
+
+ // Reset immediately in the UI.
+ setLayout(defaultLayoutRef.current);
+
+ // Clear persisted layout for the current project.
+ const key = `selfhost-overview-grid:${projectIdRef.current}`;
+ if (projectIdRef.current == null) return;
+ try {
+ localStorage.removeItem(key);
+ } catch {
+ // Ignore storage errors (private mode / quota).
+ }
+ }, []);
+
+ return { layout, onLayoutChange, resetLayout };
+}
diff --git a/src/store/atoms.js b/src/store/atoms.js
index 035e99e..5504e29 100644
--- a/src/store/atoms.js
+++ b/src/store/atoms.js
@@ -51,3 +51,7 @@ export const tunnelStateAtom = atom({});
// Window control buttons position: "left" | "right" (used for RTL / dev testing)
export const windowButtonsSideAtom = atom("right");
+
+// Signals the Overview tiles grid to reset its saved layout.
+// Used so the reset button can live in `ViewTabs` header while the grid lives in `OverviewGrid`.
+export const overviewLayoutResetSignalAtom = atom(0);