feat: add timestamps to process logs and inspector UI

Backend changes:
- Add timestamps to log lines: [2026-01-30T12:32:45.123Z] <line>
- stdout.log and stderr.log get timestamps per line
- combined.log includes [stdout]/[stderr] prefix after timestamp
- Add strip_timestamps query param to GET /process/{id}/logs
- Use time crate with RFC3339 format for timestamps

Frontend changes:
- Add Processes tab to inspector debug panel
- Show list of processes with status badges (running/stopped/killed)
- Click to expand and view logs
- Log viewer options:
  - Select stream: combined, stdout, stderr
  - Toggle strip_timestamps
  - Refresh logs button
- Action buttons: stop (SIGTERM), kill (SIGKILL), delete
- Auto-refresh process list every 5 seconds
This commit is contained in:
Nathan Flurry 2026-01-30 12:43:51 -08:00
parent afb2c74eea
commit db0268b88f
5 changed files with 464 additions and 15 deletions

View file

@ -977,6 +977,8 @@ export default function App() {
onInstallAgent={installAgent}
agentsLoading={agentsLoading}
agentsError={agentsError}
baseUrl={endpoint}
token={token}
/>
</main>
</div>

View file

@ -1,11 +1,12 @@
import { Cloud, PlayCircle, Terminal } from "lucide-react";
import { Cloud, PlayCircle, Terminal, Cpu } from "lucide-react";
import type { AgentInfo, AgentModeInfo, UniversalEvent } from "sandbox-agent";
import AgentsTab from "./AgentsTab";
import EventsTab from "./EventsTab";
import ProcessesTab from "./ProcessesTab";
import RequestLogTab from "./RequestLogTab";
import type { RequestLog } from "../../types/requestLog";
export type DebugTab = "log" | "events" | "agents";
export type DebugTab = "log" | "events" | "agents" | "processes";
const DebugPanel = ({
debugTab,
@ -24,7 +25,9 @@ const DebugPanel = ({
onRefreshAgents,
onInstallAgent,
agentsLoading,
agentsError
agentsError,
baseUrl,
token
}: {
debugTab: DebugTab;
onDebugTabChange: (tab: DebugTab) => void;
@ -43,6 +46,8 @@ const DebugPanel = ({
onInstallAgent: (agentId: string, reinstall: boolean) => void;
agentsLoading: boolean;
agentsError: string | null;
baseUrl: string;
token?: string;
}) => {
return (
<div className="debug-panel">
@ -60,6 +65,10 @@ const DebugPanel = ({
<Cloud className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
Agents
</button>
<button className={`debug-tab ${debugTab === "processes" ? "active" : ""}`} onClick={() => onDebugTabChange("processes")}>
<Cpu className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
Processes
</button>
</div>
<div className="debug-content">
@ -92,6 +101,13 @@ const DebugPanel = ({
error={agentsError}
/>
)}
{debugTab === "processes" && (
<ProcessesTab
baseUrl={baseUrl}
token={token}
/>
)}
</div>
</div>
);

View file

@ -0,0 +1,388 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Play, Square, Skull, Trash2, RefreshCw, ChevronDown, ChevronRight, Terminal } from "lucide-react";
export interface ProcessInfo {
id: string;
command: string;
args: string[];
status: "starting" | "running" | "stopped" | "killed";
exitCode?: number | null;
logPaths: {
stdout: string;
stderr: string;
combined: string;
};
startedAt: number;
stoppedAt?: number | null;
cwd?: string | null;
}
export interface ProcessListResponse {
processes: ProcessInfo[];
}
export interface LogsResponse {
content: string;
lines: number;
}
interface ProcessesTabProps {
baseUrl: string;
token?: string;
}
const formatTimestamp = (ts: number) => {
return new Date(ts * 1000).toLocaleString();
};
const formatDuration = (startedAt: number, stoppedAt?: number | null) => {
const end = stoppedAt ?? Math.floor(Date.now() / 1000);
const duration = end - startedAt;
if (duration < 60) return `${duration}s`;
if (duration < 3600) return `${Math.floor(duration / 60)}m ${duration % 60}s`;
return `${Math.floor(duration / 3600)}h ${Math.floor((duration % 3600) / 60)}m`;
};
const StatusBadge = ({ status, exitCode }: { status: string; exitCode?: number | null }) => {
const colors: Record<string, string> = {
starting: "var(--color-warning)",
running: "var(--color-success)",
stopped: exitCode === 0 ? "var(--color-muted)" : "var(--color-error)",
killed: "var(--color-error)"
};
return (
<span
className="status-badge"
style={{
background: colors[status] ?? "var(--color-muted)",
color: "white",
padding: "2px 8px",
borderRadius: "4px",
fontSize: "11px",
fontWeight: 500
}}
>
{status}
{status === "stopped" && exitCode !== undefined && exitCode !== null && ` (${exitCode})`}
</span>
);
};
const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
const [processes, setProcesses] = useState<ProcessInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [logs, setLogs] = useState<Record<string, string>>({});
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
const [stripTimestamps, setStripTimestamps] = useState(false);
const [logStream, setLogStream] = useState<"combined" | "stdout" | "stderr">("combined");
const refreshTimerRef = useRef<number | null>(null);
const fetchWithAuth = useCallback(async (url: string, options: RequestInit = {}) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string> || {})
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
return fetch(url, { ...options, headers });
}, [token]);
const fetchProcesses = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`${baseUrl}/v1/process`);
if (!response.ok) {
throw new Error(`Failed to fetch processes: ${response.status}`);
}
const data: ProcessListResponse = await response.json();
setProcesses(data.processes);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch processes");
} finally {
setLoading(false);
}
}, [baseUrl, fetchWithAuth]);
const fetchLogs = useCallback(async (id: string) => {
setLogsLoading(prev => ({ ...prev, [id]: true }));
try {
const params = new URLSearchParams({
stream: logStream,
tail: "100"
});
if (stripTimestamps) {
params.set("strip_timestamps", "true");
}
const response = await fetchWithAuth(`${baseUrl}/v1/process/${id}/logs?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch logs: ${response.status}`);
}
const data: LogsResponse = await response.json();
setLogs(prev => ({ ...prev, [id]: data.content }));
} catch (err) {
setLogs(prev => ({ ...prev, [id]: `Error: ${err instanceof Error ? err.message : "Failed to fetch logs"}` }));
} finally {
setLogsLoading(prev => ({ ...prev, [id]: false }));
}
}, [baseUrl, fetchWithAuth, logStream, stripTimestamps]);
const stopProcess = useCallback(async (id: string) => {
try {
const response = await fetchWithAuth(`${baseUrl}/v1/process/${id}/stop`, { method: "POST" });
if (!response.ok) {
throw new Error(`Failed to stop process: ${response.status}`);
}
await fetchProcesses();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to stop process");
}
}, [baseUrl, fetchWithAuth, fetchProcesses]);
const killProcess = useCallback(async (id: string) => {
try {
const response = await fetchWithAuth(`${baseUrl}/v1/process/${id}/kill`, { method: "POST" });
if (!response.ok) {
throw new Error(`Failed to kill process: ${response.status}`);
}
await fetchProcesses();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to kill process");
}
}, [baseUrl, fetchWithAuth, fetchProcesses]);
const deleteProcess = useCallback(async (id: string) => {
try {
const response = await fetchWithAuth(`${baseUrl}/v1/process/${id}`, { method: "DELETE" });
if (!response.ok) {
throw new Error(`Failed to delete process: ${response.status}`);
}
if (expandedId === id) {
setExpandedId(null);
}
await fetchProcesses();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete process");
}
}, [baseUrl, fetchWithAuth, fetchProcesses, expandedId]);
const toggleExpand = useCallback((id: string) => {
if (expandedId === id) {
setExpandedId(null);
} else {
setExpandedId(id);
fetchLogs(id);
}
}, [expandedId, fetchLogs]);
// Initial fetch and auto-refresh
useEffect(() => {
fetchProcesses();
// Auto-refresh every 5 seconds
refreshTimerRef.current = window.setInterval(fetchProcesses, 5000);
return () => {
if (refreshTimerRef.current) {
window.clearInterval(refreshTimerRef.current);
}
};
}, [fetchProcesses]);
// Refresh logs when options change
useEffect(() => {
if (expandedId) {
fetchLogs(expandedId);
}
}, [stripTimestamps, logStream]);
const runningCount = processes.filter(p => p.status === "running").length;
return (
<div className="processes-tab">
<div className="processes-header" style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<Terminal style={{ width: 16, height: 16 }} />
<span style={{ fontWeight: 600 }}>Processes</span>
{runningCount > 0 && (
<span className="running-badge" style={{
background: "var(--color-success)",
color: "white",
padding: "2px 6px",
borderRadius: "10px",
fontSize: "11px"
}}>
{runningCount} running
</span>
)}
<div style={{ flex: 1 }} />
<button
className="button ghost small"
onClick={() => fetchProcesses()}
disabled={loading}
title="Refresh"
>
<RefreshCw style={{ width: 14, height: 14 }} className={loading ? "spinning" : ""} />
</button>
</div>
{error && (
<div className="error-message" style={{ color: "var(--color-error)", marginBottom: 12, fontSize: 13 }}>
{error}
</div>
)}
{processes.length === 0 && !loading && (
<div className="empty-state" style={{ textAlign: "center", padding: "24px 16px", color: "var(--color-muted)" }}>
<Terminal style={{ width: 32, height: 32, marginBottom: 8, opacity: 0.5 }} />
<p>No processes found</p>
<p style={{ fontSize: 12 }}>Start a process using the API</p>
</div>
)}
<div className="processes-list" style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{processes.map(process => (
<div
key={process.id}
className="process-item"
style={{
border: "1px solid var(--border-color)",
borderRadius: 6,
overflow: "hidden"
}}
>
<div
className="process-row"
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
background: expandedId === process.id ? "var(--bg-secondary)" : "transparent",
cursor: "pointer"
}}
onClick={() => toggleExpand(process.id)}
>
{expandedId === process.id ? (
<ChevronDown style={{ width: 14, height: 14, flexShrink: 0 }} />
) : (
<ChevronRight style={{ width: 14, height: 14, flexShrink: 0 }} />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<code style={{ fontSize: 12, fontWeight: 500 }}>
{process.command} {process.args.join(" ")}
</code>
</div>
<div style={{ fontSize: 11, color: "var(--color-muted)", marginTop: 2 }}>
ID: {process.id} Started: {formatTimestamp(process.startedAt)} Duration: {formatDuration(process.startedAt, process.stoppedAt)}
</div>
</div>
<StatusBadge status={process.status} exitCode={process.exitCode} />
<div style={{ display: "flex", gap: 4 }} onClick={e => e.stopPropagation()}>
{(process.status === "running" || process.status === "starting") && (
<>
<button
className="button ghost small"
onClick={() => stopProcess(process.id)}
title="Stop (SIGTERM)"
>
<Square style={{ width: 12, height: 12 }} />
</button>
<button
className="button ghost small"
onClick={() => killProcess(process.id)}
title="Kill (SIGKILL)"
>
<Skull style={{ width: 12, height: 12 }} />
</button>
</>
)}
{(process.status === "stopped" || process.status === "killed") && (
<button
className="button ghost small"
onClick={() => deleteProcess(process.id)}
title="Delete process and logs"
>
<Trash2 style={{ width: 12, height: 12 }} />
</button>
)}
</div>
</div>
{expandedId === process.id && (
<div className="process-logs" style={{ borderTop: "1px solid var(--border-color)" }}>
<div style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "8px 12px",
background: "var(--bg-tertiary)",
fontSize: 12
}}>
<select
value={logStream}
onChange={e => setLogStream(e.target.value as typeof logStream)}
style={{ fontSize: 11, padding: "2px 4px" }}
>
<option value="combined">Combined</option>
<option value="stdout">stdout</option>
<option value="stderr">stderr</option>
</select>
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
<input
type="checkbox"
checked={stripTimestamps}
onChange={e => setStripTimestamps(e.target.checked)}
/>
Strip timestamps
</label>
<button
className="button ghost small"
onClick={() => fetchLogs(process.id)}
disabled={logsLoading[process.id]}
>
<RefreshCw style={{ width: 12, height: 12 }} className={logsLoading[process.id] ? "spinning" : ""} />
Refresh
</button>
</div>
<pre style={{
margin: 0,
padding: 12,
fontSize: 11,
lineHeight: 1.5,
maxHeight: 300,
overflow: "auto",
background: "var(--bg-code)",
color: "var(--color-code)",
whiteSpace: "pre-wrap",
wordBreak: "break-all"
}}>
{logsLoading[process.id] ? "Loading..." : (logs[process.id] || "(no logs)")}
</pre>
</div>
)}
</div>
))}
</div>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinning {
animation: spin 1s linear infinite;
}
`}</style>
</div>
);
};
export default ProcessesTab;

View file

@ -11,6 +11,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use tokio::io::{AsyncBufReadExt, BufReader as TokioBufReader};
use tokio::process::{Child, Command};
use tokio::sync::{broadcast, Mutex, RwLock};
@ -103,6 +105,9 @@ pub struct LogsQuery {
/// Which log stream to read: "stdout", "stderr", or "combined" (default)
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<String>,
/// Strip timestamp prefixes from log lines
#[serde(default)]
pub strip_timestamps: bool,
}
/// Response with log content
@ -356,13 +361,14 @@ impl ProcessManager {
};
while let Ok(Some(line)) = lines.next_line().await {
let log_line = format!("[stdout] {}\n", line);
let _ = file.write_all(line.as_bytes());
let _ = file.write_all(b"\n");
let timestamp = format_timestamp();
let timestamped_line = format!("[{}] {}\n", timestamp, line);
let combined_line = format!("[{}] [stdout] {}\n", timestamp, line);
let _ = file.write_all(timestamped_line.as_bytes());
if let Ok(mut combined) = combined.lock() {
let _ = combined.write_all(log_line.as_bytes());
let _ = combined.write_all(combined_line.as_bytes());
}
let _ = log_tx.send(log_line);
let _ = log_tx.send(combined_line);
}
});
}
@ -380,13 +386,14 @@ impl ProcessManager {
};
while let Ok(Some(line)) = lines.next_line().await {
let log_line = format!("[stderr] {}\n", line);
let _ = file.write_all(line.as_bytes());
let _ = file.write_all(b"\n");
let timestamp = format_timestamp();
let timestamped_line = format!("[{}] {}\n", timestamp, line);
let combined_line = format!("[{}] [stderr] {}\n", timestamp, line);
let _ = file.write_all(timestamped_line.as_bytes());
if let Ok(mut combined) = combined.lock() {
let _ = combined.write_all(log_line.as_bytes());
let _ = combined.write_all(combined_line.as_bytes());
}
let _ = log_tx.send(log_line);
let _ = log_tx.send(combined_line);
}
});
}
@ -588,7 +595,7 @@ impl ProcessManager {
let content = fs::read_to_string(log_path).unwrap_or_default();
let lines: Vec<&str> = content.lines().collect();
let (content, line_count) = if let Some(tail) = query.tail {
let (mut content, line_count) = if let Some(tail) = query.tail {
let start = lines.len().saturating_sub(tail);
let tail_lines: Vec<&str> = lines[start..].to_vec();
(tail_lines.join("\n"), tail_lines.len())
@ -596,6 +603,11 @@ impl ProcessManager {
(content.clone(), lines.len())
};
// Strip timestamps if requested
if query.strip_timestamps {
content = strip_timestamps(&content);
}
Ok(LogsResponse {
content,
lines: line_count,
@ -628,6 +640,35 @@ fn process_data_dir() -> PathBuf {
.join("processes")
}
/// Format the current time as an ISO 8601 timestamp
fn format_timestamp() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "unknown".to_string())
}
/// Strip timestamp prefixes from log lines
/// Timestamps are in format: [2026-01-30T12:32:45.123Z] or [2026-01-30T12:32:45Z]
fn strip_timestamps(content: &str) -> String {
content
.lines()
.map(|line| {
// Match pattern: [YYYY-MM-DDTHH:MM:SS...Z] at start of line
if line.starts_with('[') {
if let Some(end) = line.find("] ") {
// Check if it looks like a timestamp (starts with digit after [)
let potential_ts = &line[1..end];
if potential_ts.len() >= 19 && potential_ts.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
return &line[end + 2..];
}
}
}
line
})
.collect::<Vec<&str>>()
.join("\n")
}
/// Helper to save state from within a spawned task (simplified version)
async fn save_state_to_file(base_dir: &PathBuf) -> Result<(), std::io::Error> {
// This is a no-op for now - the state will be saved on the next explicit save_state call

View file

@ -4040,7 +4040,8 @@ async fn delete_process(
("id" = String, Path, description = "Process ID"),
("tail" = Option<usize>, Query, description = "Number of lines from end"),
("follow" = Option<bool>, Query, description = "Stream logs via SSE"),
("stream" = Option<String>, Query, description = "Log stream: stdout, stderr, or combined")
("stream" = Option<String>, Query, description = "Log stream: stdout, stderr, or combined"),
("strip_timestamps" = Option<bool>, Query, description = "Strip timestamp prefixes from log lines")
),
responses(
(status = 200, body = LogsResponse, description = "Log content"),
@ -4059,6 +4060,7 @@ async fn get_process_logs(
tail: query.tail,
follow: false,
stream: query.stream.clone(),
strip_timestamps: query.strip_timestamps,
}).await?;
let receiver = state.process_manager.subscribe_logs(&id).await?;