From ae3eb982c58346804e68b75d89fa6ca9caafdc42 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Thu, 19 Mar 2026 06:28:04 +0300 Subject: [PATCH 1/2] Update version to 0.39.0, add OverviewView component, and adjust navigation paths in Sidebar and ViewTabs components to reflect new project structure. --- package.json | 3 +- src/App.jsx | 4 +- src/components/OverviewView.jsx | 859 ++++++++++++++++++++++++++++++++ src/components/Sidebar.jsx | 4 +- src/components/ViewTabs.jsx | 80 +-- 5 files changed, 911 insertions(+), 39 deletions(-) create mode 100644 src/components/OverviewView.jsx diff --git a/package.json b/package.json index 21f4d11..b59fd5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selfhost-helper", - "version": "0.38.0", + "version": "0.39.0", "description": "Node.js Project Manager", "main": "electron/main.js", "type": "module", @@ -115,6 +115,7 @@ "wait-on": "latest" }, "dependencies": { + "@eleung/react-grid-layout": "^1.8.3", "@hello-pangea/dnd": "^18.0.1", "@monaco-editor/react": "latest", "@radix-ui/react-context-menu": "^2.2.16", diff --git a/src/App.jsx b/src/App.jsx index 7b1f3e1..735b1e0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,6 +13,7 @@ import LogViewer from "./components/LogViewer"; import EditorView from "./components/EditorView"; import TunnelView from "./components/TunnelView"; import ResourcesTab from "./components/ResourcesTab"; +import OverviewView from "./components/OverviewView"; import TitleBar from "./components/TitleBar"; import { HashRouter as Router, Routes, Route, Navigate } from "react-router-dom"; @@ -40,11 +41,12 @@ function App() { }> } /> }> - } /> + } /> } /> } /> } /> } /> + } /> }> diff --git a/src/components/OverviewView.jsx b/src/components/OverviewView.jsx new file mode 100644 index 0000000..510c7f0 --- /dev/null +++ b/src/components/OverviewView.jsx @@ -0,0 +1,859 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useOutletContext, useNavigate } from "react-router-dom"; +import { useAtomValue } from "jotai"; +import GridLayout, { WidthProvider } from "@eleung/react-grid-layout"; +import "@eleung/react-grid-layout/css/styles.css"; +import "@xterm/xterm/css/xterm.css"; +import { Terminal as TerminalIcon, Send, Copy, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { toast } from "react-toastify"; +import { statsAtom, resourceHistoryAtom, tunnelStateAtom, logsAtom } from "@/store/atoms"; +import { formatMemory } from "@/lib/formatMemory"; +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 TunnelLogViewer from "./TunnelLogViewer"; + +const ReactGridLayout = WidthProvider(GridLayout); +const API = window.api; + +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 ( + + + + ); +} + +function TileShell({ title, right, children }) { + return ( +
+
+
+ + {title} + +
+ {right} +
+
{children}
+
+ ); +} + +function useProjectLayout({ projectId, defaultLayout }) { + const [layout, setLayout] = useState(defaultLayout); + const saveTimerRef = useRef(null); + + 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); + }; + + return { layout, onLayoutChange }; +} + +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, + }); + + 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 (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 && ( +
+ +

No output

+
+ )} +
+
+ +
+
+ + $ + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={status === "running" ? "Type command…" : "Project is offline"} + spellCheck={false} + autoComplete="off" + disabled={status !== "running"} + /> +
+ +
+
+ ); +} + +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 = project?.tunnelPort != null ? parseInt(String(project.tunnelPort), 10) : 3000; + const mode = project?.tunnelMode || "quick"; + const token = project?.encryptedTunnelToken || ""; + const cfg = project?.tunnelConfig || {}; + const config = { + protocol: cfg?.protocol || "http2", + loglevel: cfg?.loglevel || "info", + noTLSVerify: cfg?.noTLSVerify || false, + connectTimeout: cfg?.connectTimeout || "30s", + httpHostHeader: cfg?.httpHostHeader || "", + }; + + const startTunnel = async () => { + // Keep validation behavior consistent with TunnelView. + const portToCheck = Number.isFinite(portNum) ? portNum : parseInt(String(portNum), 10); + if (Number.isNaN(portToCheck) || portToCheck < 1 || portToCheck > 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 window.api.startTunnel(projectId, { + mode, + port: portToCheck, + token, + config, + }); + if (!res?.success) toast.error(`Failed to start tunnel: ${res?.message || "Unknown error"}`); + } catch (e) { + toast.error(`Failed to start tunnel`); + } finally { + setIsProcessing(false); + } + }; + + const stopTunnel = async () => { + setIsProcessing(true); + try { + const res = await window.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; + window.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:${Number.isFinite(portNum) ? portNum : 3000}`} +
+
+
+
+ + {isRunning && url ? ( +
+ + +
+ ) : ( +
+ {mode === "authenticated" ? "Authenticated tunnel" : "Quick tunnel"}{" "} + {Number.isFinite(portNum) ? `on :${portNum}` : ""} +
+ )} + +
+ {isOffline ? ( + + ) : ( + + )} + {mode === "authenticated" && isOffline && !token.trim() && ( +
+ Token missing (configure it in the Tunnel tab). +
+ )} +
+
+
+ ); +} + +function TunnelLogsMiniTile({ logs }) { + // Reuse the existing TunnelLogViewer for now to keep ANSI/color handling consistent. + // The overview tile provides a tight container; TunnelLogViewer already manages FitAddon resize. + return ( +
+ +
+ ); +} + +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))}
+ )} +
+ ); + } + + // File + 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. +
+ )} +
+ ); +} + +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.
+
+ ); + } + + const shown = pids.slice(0, 12); + + return ( +
+
+
+ Process IDs +
+
+ {shown.map((pid) => { + const isMain = pid === mainPid; + return ( +
+
+
+ + {pid} + + {isMain && ( + + Main + + )} +
+
+ +
+ ); + })} + {pids.length > shown.length && ( +
+ … showing first {shown.length} +
+ )} +
+
+
+ ); +} + +function CpuTile({ stats, historySamples }) { + const cpu = stats?.cpu ?? 0; + const samples = historySamples || []; + return ( +
+
+
+
+ CPU +
+
{Number(cpu).toFixed(0)}%
+
+ ({ cpu: s.cpu }))} + valueKey="cpu" + maxValue={100} + color="#34d399" + /> +
+
+ {samples.length + ? `Last: ${(samples[samples.length - 1]?.cpu ?? 0).toFixed(0)}%` + : "Collecting…"} +
+
+ ); +} + +function RamTile({ stats, historySamples }) { + const mem = stats?.memory ?? 0; + const samples = historySamples || []; + const samplesForSpark = samples.map((s) => ({ mem: s.memory })); + return ( +
+
+
+
+ RAM +
+
{formatMemory(mem)}
+
+ (s.mem ?? 0) / (1024 * 1024)), 1) * 1024 * 1024 + } + color="#38bdf8" + /> +
+
+ {samples.length + ? `Last: ${formatMemory(samples[samples.length - 1]?.memory ?? 0)}` + : "Collecting…"} +
+
+ ); +} + +export default function OverviewView() { + const context = useOutletContext(); + const project = context?.project ?? null; + const navigate = useNavigate(); + + const fileTree = context?.fileTree ?? []; + const isFileTreeLoading = context?.isFileTreeLoading ?? false; + const handleSendInput = context?.handleSendInput; + const handleEditorFileChange = context?.handleEditorFileChange; + + const stats = useAtomValue(statsAtom); + const resourceHistory = useAtomValue(resourceHistoryAtom); + const tunnelState = useAtomValue(tunnelStateAtom); + const projectTunnelState = project ? tunnelState?.[project.id] : null; + + const historySamples = useMemo(() => { + if (!project) return []; + const bucket = resourceHistory?.[project.id]; + return bucket?.samples ?? []; + }, [resourceHistory, project]); + + const defaultLayout = useMemo( + () => [ + // Tuned to keep the Overview page shorter while still showing usable mini previews. + { i: "console", x: 0, y: 0, w: 8, h: 6, minW: 4, minH: 4 }, + { i: "tunnel", x: 8, y: 0, w: 4, h: 3, minW: 3, minH: 2 }, + { i: "tunnelLogs", x: 8, y: 3, w: 4, h: 6, minW: 3, minH: 4 }, + { i: "files", x: 0, y: 6, w: 6, h: 4, minW: 3, minH: 3 }, + { i: "cpu", x: 6, y: 6, w: 2, h: 2, minW: 2, minH: 2 }, + { i: "ram", x: 6, y: 8, w: 2, h: 2, minW: 2, minH: 2 }, + { i: "pids", x: 8, y: 9, w: 4, h: 3, minW: 3, minH: 2 }, + ], + [] + ); + + const { layout, onLayoutChange } = useProjectLayout({ + projectId: project?.id ?? null, + defaultLayout, + }); + + if (!project) return null; + + const tiles = [ + { + i: "console", + headerRight: ( + + ), + render: ( + + ), + }, + { + i: "tunnel", + headerRight: ( + + ), + render: , + }, + { + i: "tunnelLogs", + render: ( +
+ +
+ ), + }, + { + i: "files", + render: ( + { + handleEditorFileChange?.(project.id, path); + navigate(`/project/${project.id}/editor`); + }} + /> + ), + }, + { + i: "cpu", + render: , + }, + { + i: "ram", + render: , + }, + { + i: "pids", + render: , + }, + ]; + + const shouldAllowDragging = true; + + return ( +
+ onLayoutChange(nextLayout)} + onResizeStop={(nextLayout) => onLayoutChange(nextLayout)} + > + {tiles.map((t) => ( +
+ + {t.render} + +
+ ))} +
+
+ ); +} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 2f00edb..f55e61e 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -680,7 +680,7 @@ const Sidebar = React.memo(({ onProjectsChange }) => {
navigate(`/project/${p.id}/console`)} + onClick={() => navigate(`/project/${p.id}/overview`)} className={cn( "sidebar-item group relative transition-all duration-200 select-none", width < 120 ? "collapsed" : "", @@ -788,7 +788,7 @@ const Sidebar = React.memo(({ onProjectsChange }) => { { - navigate(`/project/${p.id}/console`); + navigate(`/project/${p.id}/overview`); setIsProjectSettingsOpen(true); }} > diff --git a/src/components/ViewTabs.jsx b/src/components/ViewTabs.jsx index 34f4782..c4a75db 100644 --- a/src/components/ViewTabs.jsx +++ b/src/components/ViewTabs.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { Terminal, FileCode, Cloud, Activity } from "lucide-react"; +import { Terminal, FileCode, Cloud, Activity, LayoutDashboard } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; import { cn } from "@/lib/utils"; import { useAtomValue } from "jotai"; @@ -8,7 +8,7 @@ import { useParams } from "react-router-dom"; import { formatMemory } from "@/lib/formatMemory"; import { useSelectedProject } from "@/hooks/useSelectedProject"; -const TAB_PATHS = ["console", "editor", "tunnel", "resources"]; +const TAB_PATHS = ["overview", "console", "editor", "tunnel", "resources"]; // ───────────────────────────────────────────────────────────────────────────── // Tiny inline sparkline (8 points, 36×14 px) @@ -119,49 +119,59 @@ const ViewTabs = React.memo(() => { const { projectId } = useParams(); const project = useSelectedProject(); const history = projectId ? (allHistory[Number(projectId)]?.samples ?? []) : []; - const shouldShowStats = - !!stats && !!project && project.status === "running"; + const shouldShowStats = !!stats && !!project && project.status === "running"; const pathname = location.pathname || ""; const segments = pathname.split("/").filter(Boolean); const currentTab = TAB_PATHS.includes(segments[segments.length - 1]) ? segments[segments.length - 1] - : "console"; + : "overview"; const tabBase = - "px-4 py-2 text-sm font-medium rounded-t-lg transition-all flex items-center focus:outline-none cursor-pointer border-t border-x border-transparent"; + "px-4 py-2 text-sm font-medium transition-all flex items-center focus:outline-none cursor-pointer border border-transparent border-b-0 rounded-t-lg flex-shrink-0"; const activeStyle = "bg-muted/40 text-primary border-white/10 backdrop-blur-md shadow-none"; - const inactiveStyle = "text-muted-foreground hover:text-foreground hover:bg-white/5"; + const inactiveStyle = + "text-muted-foreground hover:text-foreground hover:bg-white/5 hover:border-white/10"; return ( -
- - - - - -
- {shouldShowStats && } +
+
+
+ + + + + +
+ +
+ {shouldShowStats && } +
); From 38633af8b75b381305d0284857436b4745f14225 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Thu, 19 Mar 2026 07:06:15 +0300 Subject: [PATCH 2/2] Refactor OverviewView to utilize OverviewGrid for improved layout management and integrate reset layout functionality in ViewTabs. Introduce overviewLayoutResetSignalAtom for state management of the grid layout. --- src/components/OverviewView.jsx | 146 ++----------- src/components/ViewTabs.jsx | 78 ++++++- src/components/overview/OverviewGrid.jsx | 168 +++++++++++++++ src/components/overview/TileShell.jsx | 18 ++ .../overview/charts/CompactStatCard.jsx | 18 ++ .../overview/charts/MiniSparkline.jsx | 32 +++ .../overview/charts/PremiumSparklineChart.jsx | 86 ++++++++ .../overview/tiles/ConsoleMiniTile.jsx | 201 ++++++++++++++++++ src/components/overview/tiles/CpuTile.jsx | 34 +++ .../overview/tiles/MiniFileExhibitorTile.jsx | 83 ++++++++ .../overview/tiles/ProcessIdsTile.jsx | 103 +++++++++ src/components/overview/tiles/RamTile.jsx | 41 ++++ .../overview/tiles/TunnelLogsMiniTile.jsx | 11 + .../overview/tiles/TunnelMiniTile.jsx | 184 ++++++++++++++++ src/components/overview/useProjectLayout.js | 69 ++++++ src/store/atoms.js | 4 + 16 files changed, 1135 insertions(+), 141 deletions(-) create mode 100644 src/components/overview/OverviewGrid.jsx create mode 100644 src/components/overview/TileShell.jsx create mode 100644 src/components/overview/charts/CompactStatCard.jsx create mode 100644 src/components/overview/charts/MiniSparkline.jsx create mode 100644 src/components/overview/charts/PremiumSparklineChart.jsx create mode 100644 src/components/overview/tiles/ConsoleMiniTile.jsx create mode 100644 src/components/overview/tiles/CpuTile.jsx create mode 100644 src/components/overview/tiles/MiniFileExhibitorTile.jsx create mode 100644 src/components/overview/tiles/ProcessIdsTile.jsx create mode 100644 src/components/overview/tiles/RamTile.jsx create mode 100644 src/components/overview/tiles/TunnelLogsMiniTile.jsx create mode 100644 src/components/overview/tiles/TunnelMiniTile.jsx create mode 100644 src/components/overview/useProjectLayout.js diff --git a/src/components/OverviewView.jsx b/src/components/OverviewView.jsx index 510c7f0..940d92f 100644 --- a/src/components/OverviewView.jsx +++ b/src/components/OverviewView.jsx @@ -14,6 +14,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { WebLinksAddon } from "@xterm/addon-web-links"; import TunnelLogViewer from "./TunnelLogViewer"; +import OverviewGrid from "./overview/OverviewGrid"; const ReactGridLayout = WidthProvider(GridLayout); const API = window.api; @@ -713,7 +714,6 @@ export default function OverviewView() { const stats = useAtomValue(statsAtom); const resourceHistory = useAtomValue(resourceHistoryAtom); const tunnelState = useAtomValue(tunnelStateAtom); - const projectTunnelState = project ? tunnelState?.[project.id] : null; const historySamples = useMemo(() => { if (!project) return []; @@ -721,139 +721,23 @@ export default function OverviewView() { return bucket?.samples ?? []; }, [resourceHistory, project]); - const defaultLayout = useMemo( - () => [ - // Tuned to keep the Overview page shorter while still showing usable mini previews. - { i: "console", x: 0, y: 0, w: 8, h: 6, minW: 4, minH: 4 }, - { i: "tunnel", x: 8, y: 0, w: 4, h: 3, minW: 3, minH: 2 }, - { i: "tunnelLogs", x: 8, y: 3, w: 4, h: 6, minW: 3, minH: 4 }, - { i: "files", x: 0, y: 6, w: 6, h: 4, minW: 3, minH: 3 }, - { i: "cpu", x: 6, y: 6, w: 2, h: 2, minW: 2, minH: 2 }, - { i: "ram", x: 6, y: 8, w: 2, h: 2, minW: 2, minH: 2 }, - { i: "pids", x: 8, y: 9, w: 4, h: 3, minW: 3, minH: 2 }, - ], - [] - ); - - const { layout, onLayoutChange } = useProjectLayout({ - projectId: project?.id ?? null, - defaultLayout, - }); + const onOpenFile = (path) => { + handleEditorFileChange?.(project.id, path); + navigate(`/project/${project.id}/editor`); + }; if (!project) return null; - const tiles = [ - { - i: "console", - headerRight: ( - - ), - render: ( - - ), - }, - { - i: "tunnel", - headerRight: ( - - ), - render: , - }, - { - i: "tunnelLogs", - render: ( -
- -
- ), - }, - { - i: "files", - render: ( - { - handleEditorFileChange?.(project.id, path); - navigate(`/project/${project.id}/editor`); - }} - /> - ), - }, - { - i: "cpu", - render: , - }, - { - i: "ram", - render: , - }, - { - i: "pids", - render: , - }, - ]; - - const shouldAllowDragging = true; - return ( -
- onLayoutChange(nextLayout)} - onResizeStop={(nextLayout) => onLayoutChange(nextLayout)} - > - {tiles.map((t) => ( -
- - {t.render} - -
- ))} -
-
+ ); } diff --git a/src/components/ViewTabs.jsx b/src/components/ViewTabs.jsx index c4a75db..b23329e 100644 --- a/src/components/ViewTabs.jsx +++ b/src/components/ViewTabs.jsx @@ -1,12 +1,21 @@ -import React from "react"; -import { Terminal, FileCode, Cloud, Activity, LayoutDashboard } from "lucide-react"; +import React, { useState } from "react"; +import { Terminal, FileCode, Cloud, Activity, LayoutDashboard, RefreshCw } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; import { cn } from "@/lib/utils"; -import { useAtomValue } from "jotai"; -import { statsAtom, resourceHistoryAtom } from "@/store/atoms"; +import { useAtomValue, useSetAtom } from "jotai"; +import { statsAtom, resourceHistoryAtom, overviewLayoutResetSignalAtom } from "@/store/atoms"; import { useParams } from "react-router-dom"; import { formatMemory } from "@/lib/formatMemory"; import { useSelectedProject } from "@/hooks/useSelectedProject"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; const TAB_PATHS = ["overview", "console", "editor", "tunnel", "resources"]; @@ -133,46 +142,95 @@ const ViewTabs = React.memo(() => { const inactiveStyle = "text-muted-foreground hover:text-foreground hover:bg-white/5 hover:border-white/10"; + const projectBasePath = projectId ? `/project/${projectId}` : null; + const navigateToTab = (tab) => { + if (!projectBasePath) { + navigate(tab); + return; + } + navigate(`${projectBasePath}/${tab}`); + }; + + const setResetSignal = useSetAtom(overviewLayoutResetSignalAtom); + const [resetDialogOpen, setResetDialogOpen] = useState(false); + const openResetDialog = () => setResetDialogOpen(true); + const confirmReset = () => { + setResetDialogOpen(false); + setResetSignal((prev) => prev + 1); + }; + return (
-
+
{shouldShowStats && } + {currentTab === "overview" && projectId && ( + + )}
+ + + + + Reset overview layout? + + This will clear the saved Overview grid layout for the current project and restore the + default tile positions. + + + + + + + +
); }); 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} +
+ + + + + + + + + {gridLines.map(({ y, label: gl }, i) => ( + + + + {gl} + + + ))} + + + + + + {samples.length > 0 && (() => { + const last = samples[samples.length - 1]; + const lx = toX(samples.length - 1); + const ly = toY(last[valueKey] ?? 0); + return ; + })()} + +
+ ); +} + 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 && ( +
+ +

No output

+
+ )} +
+
+ +
+
+ $ + 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 ( +
+
+
+ + + + + + + + + + {pids.map((pid) => { + const isMain = pid === mainPid; + return ( + + + + + + ); + })} + +
+ PID + + Role + + Copy +
+ + {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);