diff --git a/server/index.ts b/server/index.ts index 963f9b8..96893a8 100644 --- a/server/index.ts +++ b/server/index.ts @@ -260,7 +260,7 @@ const server = Bun.serve({ // CORS headers const corsHeaders = { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; @@ -322,6 +322,143 @@ const server = Bun.serve({ }); } + if (url.pathname === "/api/logs") { + // Serve log files for diagnostics panel + const DIAGNOSTICS_DIR = "/var/log"; + const requestedFile = url.searchParams.get("file") || "system.log"; + + // Reject absolute paths and path traversal attempts + if (requestedFile.startsWith("/") || requestedFile.includes("..")) { + return new Response(JSON.stringify({ error: "Invalid file path" }), { + status: 400, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + // Resolve to a canonical path and ensure it stays within the diagnostics directory + const resolvedPath = require("path").resolve(DIAGNOSTICS_DIR, requestedFile); + if (!resolvedPath.startsWith(DIAGNOSTICS_DIR + "/") && resolvedPath !== DIAGNOSTICS_DIR) { + return new Response(JSON.stringify({ error: "Invalid file path" }), { + status: 400, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + const logFile = resolvedPath; + try { + const file = Bun.file(logFile); + const text = await file.text(); + // Return last 100 lines + const lines = text.split("\n").slice(-100).join("\n"); + return new Response(JSON.stringify({ file: logFile, lines }), { + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + } + + if (url.pathname === "/api/process/kill" && req.method === "POST") { + // Allow killing processes from the UI + const ALLOWED_SIGNALS = ["SIGTERM", "SIGKILL", "SIGINT"] as const; + type AllowedSignal = (typeof ALLOWED_SIGNALS)[number]; + + let body: any = {}; + try { + body = await req.json(); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + const { pid: rawPid, signal: rawSignal } = body; + const signal: AllowedSignal = ALLOWED_SIGNALS.includes(rawSignal) ? rawSignal : "SIGTERM"; + const pid = Number(rawPid); + + if (!rawPid || !Number.isInteger(pid) || pid <= 0) { + return new Response(JSON.stringify({ error: "Invalid or missing pid parameter" }), { + status: 400, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + try { + process.kill(pid, signal); + return new Response(JSON.stringify({ success: true, pid, signal }), { + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + } + + if (url.pathname === "/api/debug") { + // Debug endpoint for development troubleshooting only + if (process.env.NODE_ENV !== "development") { + return new Response(JSON.stringify({ error: "Not Found" }), { + status: 404, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + const sensitivePatterns = [ + "KEY", "SECRET", "TOKEN", "PASSWORD", "CREDENTIAL", + "AUTH", "PRIVATE", "API_KEY", "ACCESS_KEY", + ]; + const isSensitive = (name: string): boolean => + sensitivePatterns.some(pattern => name.includes(pattern)); + + const filteredEnv = Object.fromEntries( + Object.entries(process.env).map(([k, v]) => [k, isSensitive(k) ? "[REDACTED]" : v]) + ); + + const debug = { + env: filteredEnv, + versions: process.versions, + memoryUsage: process.memoryUsage(), + cpuUsage: process.cpuUsage(), + uptime: process.uptime(), + pid: process.pid, + }; + return new Response(JSON.stringify(debug), { + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + if (url.pathname === "/api/config") { + // Enforce authentication before returning config + const expectedToken = process.env.CONFIG_API_TOKEN; + const authHeader = req.headers.get("Authorization"); + if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + // Load server configuration from a fixed, server-side path only + try { + const file = Bun.file("./config.json"); + const raw = await file.text(); + const config = JSON.parse(raw); + return new Response(JSON.stringify(config), { + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + } + return new Response("Not Found", { status: 404, headers: corsHeaders }); }, }); diff --git a/src/App.tsx b/src/App.tsx index 60c8dc0..51be878 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,12 +5,14 @@ import { MemoryGraph } from './components/MemoryGraph'; import { ProcessTable } from './components/ProcessTable'; import { StatusBar } from './components/StatusBar'; import { EnvironmentPanel } from './components/EnvironmentPanel'; +import { LogViewer } from './components/LogViewer'; import { useSystemMetrics } from './hooks/useSystemMetrics'; import './App.css'; function App() { const [filter, setFilter] = useState(''); const [refreshRate, setRefreshRate] = useState(1000); + const [showLogs, setShowLogs] = useState(false); const { metrics, error, loading } = useSystemMetrics(refreshRate); if (loading && !metrics) { @@ -69,6 +71,14 @@ function App() { +
+ +
+ + + (null); + + const fetchLogs = async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/logs?file=${encodeURIComponent(logPath)}`); + const data = await res.json(); + if (data.error) { + setError(data.error); + } else { + setLogContent(data.lines); + } + } catch (err: unknown) { + if (err instanceof Error) setError(err.message); + else if (typeof err === 'string') setError(err); + else setError('An unexpected error occurred'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (visible) { + fetchLogs(); + const interval = setInterval(fetchLogs, 5000); + return () => clearInterval(interval); + } + }, [visible, logPath]); + + const applyPath = () => { + if (logPathDraft === logPath) { + fetchLogs(); + } else { + setLogPath(logPathDraft); + } + }; + + if (!visible) return null; + + return ( +
+
+ System Logs +
+ setLogPathDraft(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') applyPath(); }} + placeholder="Log file path..." + /> + +
+
+
+ {loading &&
Loading logs...
} + {error &&
Error: {error}
} + {!loading && !error && ( +
{logContent}
+ )} +
+
+ ); +} diff --git a/src/components/ProcessTable.tsx b/src/components/ProcessTable.tsx index 0c30ee9..c5c74ef 100644 --- a/src/components/ProcessTable.tsx +++ b/src/components/ProcessTable.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useRef, useEffect } from 'react'; import type { ProcessInfo, SortField, SortDirection } from '../types'; interface ProcessTableProps { @@ -10,6 +10,41 @@ export function ProcessTable({ processes, filter }: ProcessTableProps) { const [sortField, setSortField] = useState('cpu'); const [sortDirection, setSortDirection] = useState('desc'); const [selectedPid, setSelectedPid] = useState(null); + const [killStatus, setKillStatus] = useState(''); + const [pendingKillPid, setPendingKillPid] = useState(null); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timeoutRef.current !== null) clearTimeout(timeoutRef.current); + }; + }, []); + + const handleKillProcess = async (pid: number) => { + if (pendingKillPid !== null && pendingKillPid !== pid) return; + setPendingKillPid(pid); + setKillStatus(`Sending SIGTERM to ${pid}...`); + try { + const apiBase = process.env.REACT_APP_API_BASE ?? ''; + const res = await fetch(`${apiBase}/api/process/kill`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pid, signal: 'SIGTERM' }), + }); + const data = await res.json(); + if (data.success) { + setKillStatus(`Process ${pid} terminated`); + } else { + setKillStatus(`Failed: ${data.error}`); + } + } catch (err: any) { + setKillStatus(`Error: ${err.message}`); + } finally { + setPendingKillPid(null); + clearTimeout(timeoutRef.current!); + timeoutRef.current = setTimeout(() => setKillStatus(''), 3000); + } + }; const handleSort = (field: SortField) => { if (sortField === field) { @@ -133,6 +168,7 @@ export function ProcessTable({ processes, filter }: ProcessTableProps) { COMMAND{getSortIndicator('command')} + {killStatus &&
{killStatus}
}
{filteredAndSortedProcesses.map((process) => (
{process.stat.charAt(0)} {process.time} {process.command} + + {selectedPid === process.pid && ( + + )} +
))}