mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-18 18:03:48 +00:00
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:
parent
afb2c74eea
commit
db0268b88f
5 changed files with 464 additions and 15 deletions
|
|
@ -977,6 +977,8 @@ export default function App() {
|
||||||
onInstallAgent={installAgent}
|
onInstallAgent={installAgent}
|
||||||
agentsLoading={agentsLoading}
|
agentsLoading={agentsLoading}
|
||||||
agentsError={agentsError}
|
agentsError={agentsError}
|
||||||
|
baseUrl={endpoint}
|
||||||
|
token={token}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 type { AgentInfo, AgentModeInfo, UniversalEvent } from "sandbox-agent";
|
||||||
import AgentsTab from "./AgentsTab";
|
import AgentsTab from "./AgentsTab";
|
||||||
import EventsTab from "./EventsTab";
|
import EventsTab from "./EventsTab";
|
||||||
|
import ProcessesTab from "./ProcessesTab";
|
||||||
import RequestLogTab from "./RequestLogTab";
|
import RequestLogTab from "./RequestLogTab";
|
||||||
import type { RequestLog } from "../../types/requestLog";
|
import type { RequestLog } from "../../types/requestLog";
|
||||||
|
|
||||||
export type DebugTab = "log" | "events" | "agents";
|
export type DebugTab = "log" | "events" | "agents" | "processes";
|
||||||
|
|
||||||
const DebugPanel = ({
|
const DebugPanel = ({
|
||||||
debugTab,
|
debugTab,
|
||||||
|
|
@ -24,7 +25,9 @@ const DebugPanel = ({
|
||||||
onRefreshAgents,
|
onRefreshAgents,
|
||||||
onInstallAgent,
|
onInstallAgent,
|
||||||
agentsLoading,
|
agentsLoading,
|
||||||
agentsError
|
agentsError,
|
||||||
|
baseUrl,
|
||||||
|
token
|
||||||
}: {
|
}: {
|
||||||
debugTab: DebugTab;
|
debugTab: DebugTab;
|
||||||
onDebugTabChange: (tab: DebugTab) => void;
|
onDebugTabChange: (tab: DebugTab) => void;
|
||||||
|
|
@ -43,6 +46,8 @@ const DebugPanel = ({
|
||||||
onInstallAgent: (agentId: string, reinstall: boolean) => void;
|
onInstallAgent: (agentId: string, reinstall: boolean) => void;
|
||||||
agentsLoading: boolean;
|
agentsLoading: boolean;
|
||||||
agentsError: string | null;
|
agentsError: string | null;
|
||||||
|
baseUrl: string;
|
||||||
|
token?: string;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="debug-panel">
|
<div className="debug-panel">
|
||||||
|
|
@ -60,6 +65,10 @@ const DebugPanel = ({
|
||||||
<Cloud className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
|
<Cloud className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
|
||||||
Agents
|
Agents
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="debug-content">
|
<div className="debug-content">
|
||||||
|
|
@ -92,6 +101,13 @@ const DebugPanel = ({
|
||||||
error={agentsError}
|
error={agentsError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{debugTab === "processes" && (
|
||||||
|
<ProcessesTab
|
||||||
|
baseUrl={baseUrl}
|
||||||
|
token={token}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -11,6 +11,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
use time::OffsetDateTime;
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader as TokioBufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader as TokioBufReader};
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
use tokio::sync::{broadcast, Mutex, RwLock};
|
use tokio::sync::{broadcast, Mutex, RwLock};
|
||||||
|
|
@ -103,6 +105,9 @@ pub struct LogsQuery {
|
||||||
/// Which log stream to read: "stdout", "stderr", or "combined" (default)
|
/// Which log stream to read: "stdout", "stderr", or "combined" (default)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub stream: Option<String>,
|
pub stream: Option<String>,
|
||||||
|
/// Strip timestamp prefixes from log lines
|
||||||
|
#[serde(default)]
|
||||||
|
pub strip_timestamps: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response with log content
|
/// Response with log content
|
||||||
|
|
@ -356,13 +361,14 @@ impl ProcessManager {
|
||||||
};
|
};
|
||||||
|
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
let log_line = format!("[stdout] {}\n", line);
|
let timestamp = format_timestamp();
|
||||||
let _ = file.write_all(line.as_bytes());
|
let timestamped_line = format!("[{}] {}\n", timestamp, line);
|
||||||
let _ = file.write_all(b"\n");
|
let combined_line = format!("[{}] [stdout] {}\n", timestamp, line);
|
||||||
|
let _ = file.write_all(timestamped_line.as_bytes());
|
||||||
if let Ok(mut combined) = combined.lock() {
|
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 {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
let log_line = format!("[stderr] {}\n", line);
|
let timestamp = format_timestamp();
|
||||||
let _ = file.write_all(line.as_bytes());
|
let timestamped_line = format!("[{}] {}\n", timestamp, line);
|
||||||
let _ = file.write_all(b"\n");
|
let combined_line = format!("[{}] [stderr] {}\n", timestamp, line);
|
||||||
|
let _ = file.write_all(timestamped_line.as_bytes());
|
||||||
if let Ok(mut combined) = combined.lock() {
|
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 content = fs::read_to_string(log_path).unwrap_or_default();
|
||||||
|
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
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 start = lines.len().saturating_sub(tail);
|
||||||
let tail_lines: Vec<&str> = lines[start..].to_vec();
|
let tail_lines: Vec<&str> = lines[start..].to_vec();
|
||||||
(tail_lines.join("\n"), tail_lines.len())
|
(tail_lines.join("\n"), tail_lines.len())
|
||||||
|
|
@ -596,6 +603,11 @@ impl ProcessManager {
|
||||||
(content.clone(), lines.len())
|
(content.clone(), lines.len())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Strip timestamps if requested
|
||||||
|
if query.strip_timestamps {
|
||||||
|
content = strip_timestamps(&content);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(LogsResponse {
|
Ok(LogsResponse {
|
||||||
content,
|
content,
|
||||||
lines: line_count,
|
lines: line_count,
|
||||||
|
|
@ -628,6 +640,35 @@ fn process_data_dir() -> PathBuf {
|
||||||
.join("processes")
|
.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)
|
/// Helper to save state from within a spawned task (simplified version)
|
||||||
async fn save_state_to_file(base_dir: &PathBuf) -> Result<(), std::io::Error> {
|
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
|
// This is a no-op for now - the state will be saved on the next explicit save_state call
|
||||||
|
|
|
||||||
|
|
@ -4040,7 +4040,8 @@ async fn delete_process(
|
||||||
("id" = String, Path, description = "Process ID"),
|
("id" = String, Path, description = "Process ID"),
|
||||||
("tail" = Option<usize>, Query, description = "Number of lines from end"),
|
("tail" = Option<usize>, Query, description = "Number of lines from end"),
|
||||||
("follow" = Option<bool>, Query, description = "Stream logs via SSE"),
|
("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(
|
responses(
|
||||||
(status = 200, body = LogsResponse, description = "Log content"),
|
(status = 200, body = LogsResponse, description = "Log content"),
|
||||||
|
|
@ -4059,6 +4060,7 @@ async fn get_process_logs(
|
||||||
tail: query.tail,
|
tail: query.tail,
|
||||||
follow: false,
|
follow: false,
|
||||||
stream: query.stream.clone(),
|
stream: query.stream.clone(),
|
||||||
|
strip_timestamps: query.strip_timestamps,
|
||||||
}).await?;
|
}).await?;
|
||||||
|
|
||||||
let receiver = state.process_manager.subscribe_logs(&id).await?;
|
let receiver = state.process_manager.subscribe_logs(&id).await?;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue