diff --git a/frontend/src/lib/api/capsules.ts b/frontend/src/lib/api/capsules.ts index c51737a..cc4ad79 100644 --- a/frontend/src/lib/api/capsules.ts +++ b/frontend/src/lib/api/capsules.ts @@ -20,6 +20,10 @@ export async function listCapsules(): Promise> { return apiFetch('GET', '/api/v1/sandboxes'); } +export async function getCapsule(id: string): Promise> { + return apiFetch('GET', `/api/v1/sandboxes/${id}`); +} + export type CreateCapsuleParams = { template?: string; vcpus?: number; diff --git a/frontend/src/lib/api/metrics.ts b/frontend/src/lib/api/metrics.ts new file mode 100644 index 0000000..baf9f11 --- /dev/null +++ b/frontend/src/lib/api/metrics.ts @@ -0,0 +1,25 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; + +export type MetricRange = '5m' | '10m' | '1h' | '6h' | '24h'; + +export type MetricPoint = { + timestamp_unix: number; + cpu_pct: number; + mem_bytes: number; + disk_bytes: number; +}; + +export type MetricsResponse = { + sandbox_id: string; + range: MetricRange; + points: MetricPoint[]; +}; + +export async function fetchSandboxMetrics(id: string, range: MetricRange): Promise> { + return apiFetch('GET', `/api/v1/sandboxes/${id}/metrics?range=${range}`); +} + +export const METRIC_RANGES: MetricRange[] = ['5m', '10m', '1h', '6h', '24h']; + +// All ranges poll every 10 seconds. +export const METRIC_POLL_INTERVAL = 10_000; diff --git a/frontend/src/routes/dashboard/capsules/+layout.svelte b/frontend/src/routes/dashboard/capsules/+layout.svelte index 8551513..d9f93f8 100644 --- a/frontend/src/routes/dashboard/capsules/+layout.svelte +++ b/frontend/src/routes/dashboard/capsules/+layout.svelte @@ -1,4 +1,5 @@ + + + Wrenn — {sandboxId} + + + + +{#if capsuleLoading} +
+
+ + + + Loading capsule... +
+
+{:else if capsuleError} +
+
+ + + + {capsuleError} +
+
+{:else if capsule} +
+ + +
+ + + +
+ + + {#if activeTab === 'metrics'} +
+ + +
+ {#if metricsAvailable && !metricsLoading} + + + Live + + {:else} +
+ {/if} + + {#if metricsAvailable} +
+ {#each METRIC_RANGES as r, i} + + {/each} +
+ {/if} +
+ + +
+
+ + +
+
Status
+ + {#if capsule.status === 'running'} + + + + + {/if} + {capsule.status} + +
+ + +
+
Template
+ {capsule.template} +
+ + +
+
CPU
+
+ {capsule.vcpus} + vCPU{capsule.vcpus !== 1 ? 's' : ''} +
+
+ + +
+
Memory
+
+ {capsule.memory_mb} + MB +
+
+ + +
+
Disk
+ +
+ + +
+
Started
+ {fmtDate(capsule.started_at)} +
+ + +
+
Idle Timeout
+ {fmtTimeout(capsule.timeout_sec)} +
+ +
+
+ + {#if metricsError} +
+ + + + Failed to load metrics: {metricsError} +
+ {/if} + + {#if metricsAvailable} + +
+ + +
+
+
+ + CPU Usage +
+ {#if latestCpu !== null} +
+ {latestCpu.toFixed(1)} + % +
+ {:else if metricsLoading} + + {/if} +
+
+ +
+
+ + +
+
+
+ + RAM Usage +
+ {#if latestRamMB !== null} +
+ {latestRamMB.toFixed(0)} + MB +
+ {:else if metricsLoading} + + {/if} +
+
+ +
+
+ +
+ {:else} + +
+ + + Live stats are only available for running or paused capsules — + current status: {capsule.status} + +
+ {/if} + +
+ {/if} +
+{/if} diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 2da0944..9a795b5 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -1226,31 +1226,22 @@ func warnErr(msg string, id string, err error) { } } -// startSampler resolves the Firecracker child PID and starts a background -// goroutine that samples CPU/mem/disk at 500ms intervals into the ring buffer. +// startSampler resolves the Firecracker PID and starts a background goroutine +// that samples CPU/mem/disk at 500ms intervals into the ring buffer. // Must be called after the sandbox is registered in m.boxes. func (m *Manager) startSampler(sb *sandboxState) { - // Resolve the Firecracker PID (child of unshare wrapper). v, ok := m.vm.Get(sb.ID) if !ok { slog.Warn("metrics: VM not found, skipping sampler", "id", sb.ID) return } - unshPID := v.PID() - var fcPID int - for attempt := 0; attempt < 5; attempt++ { - var err error - fcPID, err = findChildPID(unshPID) - if err == nil { - break - } - if attempt == 4 { - slog.Warn("metrics: could not resolve FC PID, skipping sampler", "id", sb.ID, "error", err) - return - } - time.Sleep(50 * time.Millisecond) - } + // v.PID() is the cmd.Process.Pid of the "unshare -m -- bash -c script" + // invocation. Because unshare(2) modifies the current process's namespace + // before exec-replacing itself with bash, and bash exec-replaces itself + // with ip-netns-exec, which exec-replaces itself with firecracker, the + // entire exec chain occupies the same PID. v.PID() IS the Firecracker PID. + fcPID := v.PID() sb.fcPID = fcPID sb.ring = newMetricsRing() diff --git a/internal/sandbox/proc.go b/internal/sandbox/proc.go index eb9a78f..855d3c1 100644 --- a/internal/sandbox/proc.go +++ b/internal/sandbox/proc.go @@ -8,28 +8,6 @@ import ( "syscall" ) -// findChildPID reads the direct child PID of a given parent process. -// The Firecracker process is a direct child of the unshare wrapper because -// the init script uses `exec ip netns exec ... firecracker`, which replaces -// bash with ip-netns-exec, which in turn execs firecracker — same PID, -// direct child of unshare. -func findChildPID(parentPID int) (int, error) { - path := fmt.Sprintf("/proc/%d/task/%d/children", parentPID, parentPID) - data, err := os.ReadFile(path) - if err != nil { - return 0, fmt.Errorf("read children: %w", err) - } - fields := strings.Fields(string(data)) - if len(fields) == 0 { - return 0, fmt.Errorf("no child processes found for PID %d", parentPID) - } - pid, err := strconv.Atoi(fields[0]) - if err != nil { - return 0, fmt.Errorf("parse child PID %q: %w", fields[0], err) - } - return pid, nil -} - // cpuStat holds raw CPU jiffies read from /proc/{pid}/stat. type cpuStat struct { utime uint64