mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 10:05:18 +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}
|
||||
agentsLoading={agentsLoading}
|
||||
agentsError={agentsError}
|
||||
baseUrl={endpoint}
|
||||
token={token}
|
||||
/>
|
||||
</main>
|
||||
</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 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue