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..940d92f --- /dev/null +++ b/src/components/OverviewView.jsx @@ -0,0 +1,743 @@ +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"; +import OverviewGrid from "./overview/OverviewGrid"; + +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 historySamples = useMemo(() => { + if (!project) return []; + const bucket = resourceHistory?.[project.id]; + return bucket?.samples ?? []; + }, [resourceHistory, project]); + + const onOpenFile = (path) => { + handleEditorFileChange?.(project.id, path); + navigate(`/project/${project.id}/editor`); + }; + + if (!project) return null; + + return ( + + ); +} 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..b23329e 100644 --- a/src/components/ViewTabs.jsx +++ b/src/components/ViewTabs.jsx @@ -1,14 +1,23 @@ -import React from "react"; -import { Terminal, FileCode, Cloud, Activity } 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"; - -const TAB_PATHS = ["console", "editor", "tunnel", "resources"]; +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"]; // ───────────────────────────────────────────────────────────────────────────── // Tiny inline sparkline (8 points, 36×14 px) @@ -119,50 +128,109 @@ 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"; + + 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 && } +
+
+
+ + + + + +
+ +
+ {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);