Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
8c0b4d0
Add system diagnostics and process management features
jbingham17 Mar 20, 2026
cf08407
fix: replace dangerouslySetInnerHTML with safe text rendering in LogV…
github-actions[bot] Mar 20, 2026
274d7c2
fix: prevent path traversal in /api/logs endpoint
github-actions[bot] Mar 20, 2026
ead7acf
fix: debounce log path input and remove dangerouslySetInnerHTML in Lo…
github-actions[bot] Mar 20, 2026
6336c85
fix: restrict /api/debug to development and redact sensitive env vars
github-actions[bot] Mar 20, 2026
993fdcf
fix: prevent overlapping kill requests and timer leaks in ProcessTable
github-actions[bot] Mar 20, 2026
4a75f5c
fix: prevent arbitrary file read and RCE in /api/config endpoint
github-actions[bot] Mar 20, 2026
2eaf828
fix: prevent shell injection in /api/process/kill endpoint
github-actions[bot] Mar 20, 2026
1a8b630
fix: use relative filename for logPath initial state in LogViewer
github-actions[bot] Mar 20, 2026
26d4357
fix: use POST with JSON body for /api/process/kill instead of GET wit…
github-actions[bot] Mar 20, 2026
1364a78
fix: use relative path and encodeURIComponent for log file fetch URL
github-actions[bot] Mar 20, 2026
85a1f71
fix: use relative path for /api/process/kill, configurable via REACT_…
github-actions[bot] Mar 20, 2026
802d43d
fix: type catch error as unknown and narrow before accessing .message…
github-actions[bot] Mar 20, 2026
5cf3c48
fix: call fetchLogs directly in applyPath when logPathDraft equals lo…
github-actions[bot] Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 138 additions & 1 deletion server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

Expand Down Expand Up @@ -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 }), {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}

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), {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 });
},
});
Expand Down
10 changes: 10 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -69,6 +71,14 @@ function App() {
<ProcessTable processes={metrics.processes} filter={filter} />
</div>

<div className="diagnostics-toggle">
<button onClick={() => setShowLogs(!showLogs)}>
{showLogs ? 'Hide Logs' : 'Show System Logs'}
</button>
</div>

<LogViewer visible={showLogs} />

<EnvironmentPanel filter={filter} />

<StatusBar
Expand Down
79 changes: 79 additions & 0 deletions src/components/LogViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useState, useEffect } from 'react';

interface LogViewerProps {
visible: boolean;
}

export function LogViewer({ visible }: LogViewerProps) {
const [logPath, setLogPath] = useState('system.log');
const [logPathDraft, setLogPathDraft] = useState('system.log');
const [logContent, setLogContent] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!visible) return null;

return (
<div className="log-viewer">
<div className="log-header">
<span className="log-title">System Logs</span>
<div className="log-controls">
<input
type="text"
className="log-path-input"
value={logPathDraft}
onChange={(e) => setLogPathDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') applyPath(); }}
placeholder="Log file path..."
/>
<button className="log-refresh-btn" onClick={applyPath}>
Refresh
</button>
</div>
</div>
<div className="log-content">
{loading && <div className="log-loading">Loading logs...</div>}
{error && <div className="log-error">Error: {error}</div>}
{!loading && !error && (
<pre className="log-lines">{logContent}</pre>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
</div>
);
}
49 changes: 48 additions & 1 deletion src/components/ProcessTable.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -10,6 +10,41 @@ export function ProcessTable({ processes, filter }: ProcessTableProps) {
const [sortField, setSortField] = useState<SortField>('cpu');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [selectedPid, setSelectedPid] = useState<number | null>(null);
const [killStatus, setKillStatus] = useState<string>('');
const [pendingKillPid, setPendingKillPid] = useState<number | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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);
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleSort = (field: SortField) => {
if (sortField === field) {
Expand Down Expand Up @@ -133,6 +168,7 @@ export function ProcessTable({ processes, filter }: ProcessTableProps) {
COMMAND{getSortIndicator('command')}
</span>
</div>
{killStatus && <div className="kill-status">{killStatus}</div>}
<div className="table-body">
{filteredAndSortedProcesses.map((process) => (
<div
Expand All @@ -153,6 +189,17 @@ export function ProcessTable({ processes, filter }: ProcessTableProps) {
<span className="col-state">{process.stat.charAt(0)}</span>
<span className="col-time">{process.time}</span>
<span className="col-command">{process.command}</span>
<span className="col-actions">
{selectedPid === process.pid && (
<button
className="kill-btn"
onClick={(e) => { e.stopPropagation(); handleKillProcess(process.pid); }}
disabled={pendingKillPid === process.pid}
>
Kill
</button>
)}
</span>
</div>
))}
</div>
Expand Down