forked from wrenn/wrenn
Merge pull request 'Added metrics' (#9) from metrics into dev
Reviewed-on: wrenn/sandbox#9
This commit is contained in:
@ -94,6 +94,10 @@ func main() {
|
|||||||
monitor := api.NewHostMonitor(queries, hostPool, audit.New(queries), 30*time.Second)
|
monitor := api.NewHostMonitor(queries, hostPool, audit.New(queries), 30*time.Second)
|
||||||
monitor.Start(ctx)
|
monitor.Start(ctx)
|
||||||
|
|
||||||
|
// Start metrics sampler (records per-team sandbox stats every 10s).
|
||||||
|
sampler := api.NewMetricsSampler(queries, 10*time.Second)
|
||||||
|
sampler.Start(ctx)
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.ListenAddr,
|
Addr: cfg.ListenAddr,
|
||||||
Handler: srv.Handler(),
|
Handler: srv.Handler(),
|
||||||
|
|||||||
18
db/migrations/20260325074949_metrics_snapshots.sql
Normal file
18
db/migrations/20260325074949_metrics_snapshots.sql
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
CREATE TABLE sandbox_metrics_snapshots (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
running_count INTEGER NOT NULL,
|
||||||
|
vcpus_reserved INTEGER NOT NULL,
|
||||||
|
memory_mb_reserved INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- All queries filter on team_id first then range-scan sampled_at.
|
||||||
|
CREATE INDEX idx_metrics_snapshots_team_time
|
||||||
|
ON sandbox_metrics_snapshots (team_id, sampled_at DESC);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
DROP TABLE sandbox_metrics_snapshots;
|
||||||
16
db/migrations/20260325135035_add_sandbox_metric_points.sql
Normal file
16
db/migrations/20260325135035_add_sandbox_metric_points.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE sandbox_metric_points (
|
||||||
|
sandbox_id TEXT NOT NULL,
|
||||||
|
tier TEXT NOT NULL CHECK (tier IN ('10m', '2h', '24h')),
|
||||||
|
ts BIGINT NOT NULL,
|
||||||
|
cpu_pct FLOAT8 NOT NULL DEFAULT 0,
|
||||||
|
mem_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
disk_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (sandbox_id, tier, ts)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sandbox_metric_points_sandbox_tier
|
||||||
|
ON sandbox_metric_points (sandbox_id, tier);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS sandbox_metric_points;
|
||||||
68
db/queries/metrics.sql
Normal file
68
db/queries/metrics.sql
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
-- name: InsertMetricsSnapshot :exec
|
||||||
|
INSERT INTO sandbox_metrics_snapshots (team_id, running_count, vcpus_reserved, memory_mb_reserved)
|
||||||
|
VALUES ($1, $2, $3, $4);
|
||||||
|
|
||||||
|
-- name: GetLiveMetrics :one
|
||||||
|
-- Reads directly from sandboxes for accurate real-time current values.
|
||||||
|
-- CPU reserved = running + starting only (paused VMs release CPU).
|
||||||
|
-- RAM reserved = running + starting + sum(ceil(each_paused/2)) (per-VM ceiling).
|
||||||
|
SELECT
|
||||||
|
(COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count,
|
||||||
|
(COALESCE(SUM(vcpus) FILTER (WHERE status IN ('running', 'starting')), 0))::INTEGER AS vcpus_reserved,
|
||||||
|
(COALESCE(SUM(memory_mb) FILTER (WHERE status IN ('running', 'starting')), 0)
|
||||||
|
+ COALESCE(SUM(CEIL(memory_mb::NUMERIC / 2)) FILTER (WHERE status = 'paused'), 0))::INTEGER AS memory_mb_reserved
|
||||||
|
FROM sandboxes
|
||||||
|
WHERE team_id = $1;
|
||||||
|
|
||||||
|
-- name: GetPeakMetrics :one
|
||||||
|
SELECT
|
||||||
|
COALESCE(MAX(running_count), 0)::INTEGER AS peak_running_count,
|
||||||
|
COALESCE(MAX(vcpus_reserved), 0)::INTEGER AS peak_vcpus,
|
||||||
|
COALESCE(MAX(memory_mb_reserved), 0)::INTEGER AS peak_memory_mb
|
||||||
|
FROM sandbox_metrics_snapshots
|
||||||
|
WHERE team_id = $1
|
||||||
|
AND sampled_at > NOW() - INTERVAL '30 days';
|
||||||
|
|
||||||
|
-- name: PruneOldMetrics :exec
|
||||||
|
DELETE FROM sandbox_metrics_snapshots
|
||||||
|
WHERE sampled_at < NOW() - INTERVAL '60 days';
|
||||||
|
|
||||||
|
-- name: InsertSandboxMetricPoint :exec
|
||||||
|
INSERT INTO sandbox_metric_points (sandbox_id, tier, ts, cpu_pct, mem_bytes, disk_bytes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (sandbox_id, tier, ts) DO NOTHING;
|
||||||
|
|
||||||
|
-- name: GetSandboxMetricPoints :many
|
||||||
|
SELECT ts, cpu_pct, mem_bytes, disk_bytes
|
||||||
|
FROM sandbox_metric_points
|
||||||
|
WHERE sandbox_id = $1 AND tier = $2 AND ts >= $3
|
||||||
|
ORDER BY ts ASC;
|
||||||
|
|
||||||
|
-- name: DeleteSandboxMetricPoints :exec
|
||||||
|
DELETE FROM sandbox_metric_points
|
||||||
|
WHERE sandbox_id = $1;
|
||||||
|
|
||||||
|
-- name: DeleteSandboxMetricPointsByTier :exec
|
||||||
|
DELETE FROM sandbox_metric_points
|
||||||
|
WHERE sandbox_id = $1 AND tier = $2;
|
||||||
|
|
||||||
|
-- name: PruneSandboxMetricPoints :exec
|
||||||
|
-- Remove metric points older than 30 days for destroyed sandboxes.
|
||||||
|
DELETE FROM sandbox_metric_points
|
||||||
|
WHERE ts < EXTRACT(EPOCH FROM NOW() - INTERVAL '30 days')::BIGINT;
|
||||||
|
|
||||||
|
-- name: SampleSandboxMetrics :many
|
||||||
|
-- Aggregates per-team resource usage from the live sandboxes table.
|
||||||
|
-- Groups by all teams that have any sandbox row (including stopped) so that
|
||||||
|
-- zero-value snapshots are recorded when all capsules are stopped, keeping the
|
||||||
|
-- time-series charts continuous rather than trailing off into empty space.
|
||||||
|
-- CPU reserved = running + starting only (paused VMs release CPU).
|
||||||
|
-- RAM reserved = running + starting + sum(ceil(each_paused/2)) (per-VM ceiling).
|
||||||
|
SELECT
|
||||||
|
team_id,
|
||||||
|
(COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count,
|
||||||
|
(COALESCE(SUM(vcpus) FILTER (WHERE status IN ('running', 'starting')), 0))::INTEGER AS vcpus_reserved,
|
||||||
|
(COALESCE(SUM(memory_mb) FILTER (WHERE status IN ('running', 'starting')), 0)
|
||||||
|
+ COALESCE(SUM(CEIL(memory_mb::NUMERIC / 2)) FILTER (WHERE status = 'paused'), 0))::INTEGER AS memory_mb_reserved
|
||||||
|
FROM sandboxes
|
||||||
|
GROUP BY team_id;
|
||||||
@ -26,5 +26,8 @@
|
|||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
frontend/pnpm-lock.yaml
generated
17
frontend/pnpm-lock.yaml
generated
@ -7,6 +7,10 @@ settings:
|
|||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
dependencies:
|
||||||
|
chart.js:
|
||||||
|
specifier: ^4.5.1
|
||||||
|
version: 4.5.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@fontsource-variable/jetbrains-mono':
|
'@fontsource-variable/jetbrains-mono':
|
||||||
specifier: ^5.2.8
|
specifier: ^5.2.8
|
||||||
@ -249,6 +253,9 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@kurkle/color@0.3.4':
|
||||||
|
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29':
|
'@polka/url@1.0.0-next.29':
|
||||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||||
|
|
||||||
@ -547,6 +554,10 @@ packages:
|
|||||||
'@internationalized/date': ^3.8.1
|
'@internationalized/date': ^3.8.1
|
||||||
svelte: ^5.33.0
|
svelte: ^5.33.0
|
||||||
|
|
||||||
|
chart.js@4.5.1:
|
||||||
|
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
|
||||||
|
engines: {pnpm: '>=8'}
|
||||||
|
|
||||||
chokidar@4.0.3:
|
chokidar@4.0.3:
|
||||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||||
engines: {node: '>= 14.16.0'}
|
engines: {node: '>= 14.16.0'}
|
||||||
@ -980,6 +991,8 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@kurkle/color@0.3.4': {}
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29': {}
|
'@polka/url@1.0.0-next.29': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||||
@ -1203,6 +1216,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@sveltejs/kit'
|
- '@sveltejs/kit'
|
||||||
|
|
||||||
|
chart.js@4.5.1:
|
||||||
|
dependencies:
|
||||||
|
'@kurkle/color': 0.3.4
|
||||||
|
|
||||||
chokidar@4.0.3:
|
chokidar@4.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
readdirp: 4.1.2
|
readdirp: 4.1.2
|
||||||
|
|||||||
@ -69,8 +69,10 @@
|
|||||||
--radius-avatar: 5px;
|
--radius-avatar: 5px;
|
||||||
--radius-logo: 6px;
|
--radius-logo: 6px;
|
||||||
|
|
||||||
/* Shadows — flat aesthetic */
|
/* Shadows */
|
||||||
--shadow-sm: 0 0 #0000;
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||||
|
--shadow-dialog: 0 16px 48px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base styles */
|
/* Base styles */
|
||||||
@ -131,6 +133,24 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Outward ring ripple — for live/running status dots; more delightful than opacity-only */
|
||||||
|
@keyframes status-ping {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: scale(2.8);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-status-ping {
|
||||||
|
animation: status-ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fade-up entrance animation */
|
/* Fade-up entrance animation */
|
||||||
@keyframes fadeUp {
|
@keyframes fadeUp {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@ -20,6 +20,10 @@ export async function listCapsules(): Promise<ApiResult<Capsule[]>> {
|
|||||||
return apiFetch('GET', '/api/v1/sandboxes');
|
return apiFetch('GET', '/api/v1/sandboxes');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCapsule(id: string): Promise<ApiResult<Capsule>> {
|
||||||
|
return apiFetch('GET', `/api/v1/sandboxes/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
export type CreateCapsuleParams = {
|
export type CreateCapsuleParams = {
|
||||||
template?: string;
|
template?: string;
|
||||||
vcpus?: number;
|
vcpus?: number;
|
||||||
|
|||||||
25
frontend/src/lib/api/metrics.ts
Normal file
25
frontend/src/lib/api/metrics.ts
Normal file
@ -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<ApiResult<MetricsResponse>> {
|
||||||
|
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;
|
||||||
44
frontend/src/lib/api/stats.ts
Normal file
44
frontend/src/lib/api/stats.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { apiFetch, type ApiResult } from '$lib/api/client';
|
||||||
|
|
||||||
|
export type TimeRange = '5m' | '1h' | '6h' | '24h' | '30d';
|
||||||
|
|
||||||
|
export type StatsResponse = {
|
||||||
|
range: TimeRange;
|
||||||
|
current: {
|
||||||
|
running_count: number;
|
||||||
|
vcpus_reserved: number;
|
||||||
|
memory_mb_reserved: number;
|
||||||
|
sampled_at?: string;
|
||||||
|
};
|
||||||
|
peaks: {
|
||||||
|
running_count: number;
|
||||||
|
vcpus: number;
|
||||||
|
memory_mb: number;
|
||||||
|
};
|
||||||
|
series: {
|
||||||
|
labels: string[];
|
||||||
|
running: number[];
|
||||||
|
vcpus: number[];
|
||||||
|
memory_mb: number[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchStats(range: TimeRange): Promise<ApiResult<StatsResponse>> {
|
||||||
|
return apiFetch('GET', `/api/v1/sandboxes/stats?range=${range}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POLL_INTERVALS: Record<TimeRange, number> = {
|
||||||
|
'5m': 15_000,
|
||||||
|
'1h': 30_000,
|
||||||
|
'6h': 60_000,
|
||||||
|
'24h': 120_000,
|
||||||
|
'30d': 300_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RANGE_LABELS: Record<TimeRange, string> = {
|
||||||
|
'5m': '5m',
|
||||||
|
'1h': '1h',
|
||||||
|
'6h': '6h',
|
||||||
|
'24h': '24h',
|
||||||
|
'30d': '30d',
|
||||||
|
};
|
||||||
3
frontend/src/lib/capsule-store.svelte.ts
Normal file
3
frontend/src/lib/capsule-store.svelte.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// Shared state written by the list page and read by the capsules layout
|
||||||
|
// for the running count badge in the header.
|
||||||
|
export const capsuleRunningCount = $state({ value: 0 });
|
||||||
124
frontend/src/lib/components/CreateCapsuleDialog.svelte
Normal file
124
frontend/src/lib/components/CreateCapsuleDialog.svelte
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createCapsule, type Capsule, type CreateCapsuleParams } from '$lib/api/capsules';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onclose: () => void;
|
||||||
|
oncreated?: (capsule: Capsule) => void;
|
||||||
|
};
|
||||||
|
let { open, onclose, oncreated }: Props = $props();
|
||||||
|
|
||||||
|
let createForm = $state<CreateCapsuleParams>({ template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 });
|
||||||
|
let creating = $state(false);
|
||||||
|
let createError = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
creating = true;
|
||||||
|
createError = null;
|
||||||
|
const result = await createCapsule(createForm);
|
||||||
|
if (result.ok) {
|
||||||
|
createForm = { template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 };
|
||||||
|
oncreated?.(result.data);
|
||||||
|
onclose();
|
||||||
|
} else {
|
||||||
|
createError = result.error;
|
||||||
|
}
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/60"
|
||||||
|
onclick={() => { if (!creating) onclose(); }}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape' && !creating) onclose(); }}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
|
||||||
|
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Launch Capsule</h2>
|
||||||
|
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">Configure resources and launch. The VM will be ready in under a second.</p>
|
||||||
|
|
||||||
|
{#if createError}
|
||||||
|
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-template">Template</label>
|
||||||
|
<input
|
||||||
|
id="create-template"
|
||||||
|
type="text"
|
||||||
|
bind:value={createForm.template}
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)]"
|
||||||
|
placeholder="minimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-vcpus">vCPUs</label>
|
||||||
|
<input
|
||||||
|
id="create-vcpus"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="8"
|
||||||
|
bind:value={createForm.vcpus}
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-memory">Memory (MB)</label>
|
||||||
|
<input
|
||||||
|
id="create-memory"
|
||||||
|
type="number"
|
||||||
|
min="128"
|
||||||
|
max="8192"
|
||||||
|
step="128"
|
||||||
|
bind:value={createForm.memory_mb}
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-timeout">Idle timeout (seconds — 0 = never pause)</label>
|
||||||
|
<input
|
||||||
|
id="create-timeout"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
bind:value={createForm.timeout_sec}
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onclick={onclose}
|
||||||
|
disabled={creating}
|
||||||
|
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleCreate}
|
||||||
|
disabled={creating}
|
||||||
|
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||||
|
>
|
||||||
|
{#if creating}
|
||||||
|
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
Launching...
|
||||||
|
{:else}
|
||||||
|
Launch
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@ -21,7 +21,8 @@
|
|||||||
IconDocs,
|
IconDocs,
|
||||||
IconAudit,
|
IconAudit,
|
||||||
IconServer,
|
IconServer,
|
||||||
IconShield
|
IconShield,
|
||||||
|
IconMetrics
|
||||||
} from './icons';
|
} from './icons';
|
||||||
|
|
||||||
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
|
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
|
||||||
@ -47,7 +48,8 @@
|
|||||||
|
|
||||||
const platformItems: NavItem[] = [
|
const platformItems: NavItem[] = [
|
||||||
{ label: 'Capsules', icon: IconMonitor, href: '/dashboard/capsules' },
|
{ label: 'Capsules', icon: IconMonitor, href: '/dashboard/capsules' },
|
||||||
{ label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' }
|
{ label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' },
|
||||||
|
{ label: 'Metrics', icon: IconMetrics, href: '/dashboard/metrics' }
|
||||||
];
|
];
|
||||||
|
|
||||||
let currentTeamIsByoc = $derived(
|
let currentTeamIsByoc = $derived(
|
||||||
@ -342,19 +344,19 @@
|
|||||||
{:else if isActive(item.href)}
|
{:else if isActive(item.href)}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="group relative flex items-center rounded-[var(--radius-input)] bg-[var(--color-accent-glow-mid)] px-2.5 py-2.5 transition-colors duration-150 {collapsed
|
class="group relative flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 {collapsed
|
||||||
? 'justify-center px-2'
|
? 'justify-center px-2 bg-[var(--color-accent-glow-mid)]'
|
||||||
: 'gap-3'}"
|
: 'gap-3 bg-[var(--color-accent)]/[0.12]'}"
|
||||||
title={collapsed ? item.label : undefined}
|
title={collapsed ? item.label : undefined}
|
||||||
>
|
>
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"
|
class="absolute left-0 top-1/2 h-6 w-1 -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"
|
||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
<item.icon size={16} class="shrink-0 text-[var(--color-accent-bright)]" />
|
<item.icon size={16} class="shrink-0 text-[var(--color-accent-bright)]" />
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<span class="text-ui font-medium text-[var(--color-accent-bright)]">
|
<span class="text-ui font-semibold text-[var(--color-accent-bright)]">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@ -396,7 +398,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
|
class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
|
||||||
style="animation: fadeUp 0.2s ease both"
|
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
|
||||||
>
|
>
|
||||||
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">
|
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">
|
||||||
Create Team
|
Create Team
|
||||||
|
|||||||
427
frontend/src/lib/components/StatsPanel.svelte
Normal file
427
frontend/src/lib/components/StatsPanel.svelte
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { fetchStats, POLL_INTERVALS, type TimeRange, type StatsResponse } from '$lib/api/stats';
|
||||||
|
|
||||||
|
const RANGES: TimeRange[] = ['5m', '1h', '6h', '24h', '30d'];
|
||||||
|
|
||||||
|
type Props = { onlaunch?: () => void; launchDisabled?: boolean };
|
||||||
|
let { onlaunch, launchDisabled = false }: Props = $props();
|
||||||
|
|
||||||
|
let range = $state<TimeRange>('1h');
|
||||||
|
let stats = $state<StatsResponse | null>(null);
|
||||||
|
// loading is only true before the very first successful fetch; subsequent
|
||||||
|
// polls update data silently to avoid blanking the cards and charts.
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
let canvasRunning: HTMLCanvasElement;
|
||||||
|
let canvasCpu: HTMLCanvasElement;
|
||||||
|
let canvasRam: HTMLCanvasElement;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let chartRunning: any = null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let chartCpu: any = null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let chartRam: any = null;
|
||||||
|
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const result = await fetchStats(range);
|
||||||
|
if (result.ok) {
|
||||||
|
stats = result.data;
|
||||||
|
error = null;
|
||||||
|
} else {
|
||||||
|
error = result.error;
|
||||||
|
}
|
||||||
|
// Set loading=false before updateCharts so cards always render even if
|
||||||
|
// chart update throws (e.g. Chart.js not yet initialised on first tick).
|
||||||
|
loading = false;
|
||||||
|
updateCharts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCharts() {
|
||||||
|
if (!stats) return;
|
||||||
|
// Use Array.from to pass plain JS arrays to Chart.js — Svelte 5 $state
|
||||||
|
// wraps arrays in reactive proxies which Chart.js can't iterate reliably.
|
||||||
|
const labels = formatLabels(Array.from(stats.series.labels), range);
|
||||||
|
if (chartRunning) {
|
||||||
|
chartRunning.data.labels = labels;
|
||||||
|
chartRunning.data.datasets[0].data = Array.from(stats.series.running);
|
||||||
|
chartRunning.update();
|
||||||
|
}
|
||||||
|
if (chartCpu) {
|
||||||
|
chartCpu.data.labels = labels;
|
||||||
|
chartCpu.data.datasets[0].data = Array.from(stats.series.vcpus);
|
||||||
|
chartCpu.update();
|
||||||
|
}
|
||||||
|
if (chartRam) {
|
||||||
|
chartRam.data.labels = labels;
|
||||||
|
chartRam.data.datasets[0].data = Array.from(stats.series.memory_mb).map((mb) => +(mb / 1024).toFixed(2));
|
||||||
|
chartRam.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabels(labels: string[], r: TimeRange): string[] {
|
||||||
|
return labels.map((iso) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (r === '5m' || r === '1h') {
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: r === '5m' ? '2-digit' : undefined });
|
||||||
|
}
|
||||||
|
if (r === '6h' || r === '24h') {
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
// 30d
|
||||||
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartPolling() {
|
||||||
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
load();
|
||||||
|
pollInterval = setInterval(load, POLL_INTERVALS[range]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRange(r: TimeRange) {
|
||||||
|
range = r;
|
||||||
|
goto(`?range=${r}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||||
|
restartPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart colors (resolved from CSS vars, must match app.css)
|
||||||
|
const C_ACCENT = '#5e8c58';
|
||||||
|
const C_ACCENT_FILL = 'rgba(94,140,88,0.13)';
|
||||||
|
const C_BLUE = '#5a9fd4';
|
||||||
|
const C_BLUE_FILL = 'rgba(90,159,212,0.11)';
|
||||||
|
const C_AMBER = '#d4a73c';
|
||||||
|
const C_AMBER_FILL = 'rgba(212,167,60,0.11)';
|
||||||
|
const C_GRID = 'rgba(255,255,255,0.05)';
|
||||||
|
const C_TICK = '#635f5c';
|
||||||
|
const FONT_MONO = "'JetBrains Mono', monospace";
|
||||||
|
|
||||||
|
const BASE_CHART_OPTIONS = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false as const,
|
||||||
|
interaction: { mode: 'index' as const, intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: '#141817',
|
||||||
|
borderColor: '#1f2321',
|
||||||
|
borderWidth: 1,
|
||||||
|
titleColor: '#454340',
|
||||||
|
bodyColor: '#d4cfc8',
|
||||||
|
titleFont: { family: FONT_MONO, size: 10 },
|
||||||
|
bodyFont: { family: FONT_MONO, size: 11 },
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: C_GRID },
|
||||||
|
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 }, maxTicksLimit: 6, maxRotation: 0 },
|
||||||
|
border: { color: C_GRID },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: C_GRID },
|
||||||
|
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 }, precision: 0 },
|
||||||
|
border: { color: C_GRID },
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Read range from URL query param; fall back to '1h'.
|
||||||
|
const urlRange = new URLSearchParams(window.location.search).get('range');
|
||||||
|
if (urlRange && RANGES.includes(urlRange as TimeRange)) {
|
||||||
|
range = urlRange as TimeRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Chart } = await import('chart.js/auto');
|
||||||
|
|
||||||
|
chartRunning = new Chart(canvasRunning, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
data: [],
|
||||||
|
borderColor: C_ACCENT,
|
||||||
|
backgroundColor: C_ACCENT_FILL,
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHoverBackgroundColor: C_ACCENT,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: BASE_CHART_OPTIONS,
|
||||||
|
});
|
||||||
|
|
||||||
|
chartCpu = new Chart(canvasCpu, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
data: [],
|
||||||
|
borderColor: C_BLUE,
|
||||||
|
backgroundColor: C_BLUE_FILL,
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHoverBackgroundColor: C_BLUE,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...BASE_CHART_OPTIONS,
|
||||||
|
scales: {
|
||||||
|
...BASE_CHART_OPTIONS.scales,
|
||||||
|
y: {
|
||||||
|
...BASE_CHART_OPTIONS.scales.y,
|
||||||
|
ticks: {
|
||||||
|
...BASE_CHART_OPTIONS.scales.y.ticks,
|
||||||
|
callback: (v: number) => `${v}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
chartRam = new Chart(canvasRam, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
data: [],
|
||||||
|
borderColor: C_AMBER,
|
||||||
|
backgroundColor: C_AMBER_FILL,
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHoverBackgroundColor: C_AMBER,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...BASE_CHART_OPTIONS,
|
||||||
|
plugins: {
|
||||||
|
...BASE_CHART_OPTIONS.plugins,
|
||||||
|
tooltip: {
|
||||||
|
...BASE_CHART_OPTIONS.plugins.tooltip,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx: { parsed: { y: number } }) => ` ${ctx.parsed.y.toFixed(1)} GB`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
...BASE_CHART_OPTIONS.scales,
|
||||||
|
y: {
|
||||||
|
...BASE_CHART_OPTIONS.scales.y,
|
||||||
|
ticks: {
|
||||||
|
...BASE_CHART_OPTIONS.scales.y.ticks,
|
||||||
|
callback: (v: number) => `${(+v).toFixed(1)} GB`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply any data already loaded before charts were ready.
|
||||||
|
updateCharts();
|
||||||
|
|
||||||
|
restartPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
chartRunning?.destroy();
|
||||||
|
chartCpu?.destroy();
|
||||||
|
chartRam?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
function fmtGB(mb: number): string {
|
||||||
|
return (mb / 1024).toFixed(1) + ' GB';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8 px-8 pb-10 pt-6" style="min-height: calc(100dvh - 200px); animation: fadeUp 0.35s ease both">
|
||||||
|
|
||||||
|
<!-- Controls row -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
{#if !loading}
|
||||||
|
<span class="flex items-center gap-1 rounded-[3px] border border-[var(--color-accent)]/25 bg-[var(--color-accent-glow-mid)] px-1.5 py-0.5 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-accent-mid)]">
|
||||||
|
<span class="h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]" style="animation: wrenn-glow 2.5s ease-in-out infinite"></span>
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<div></div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Range selector -->
|
||||||
|
<div class="flex overflow-hidden rounded-[var(--radius-button)] border border-[var(--color-border)]">
|
||||||
|
{#each RANGES as r, i}
|
||||||
|
<button
|
||||||
|
onclick={() => setRange(r)}
|
||||||
|
class="px-3 py-1.5 font-mono text-label transition-colors duration-150
|
||||||
|
{range === r
|
||||||
|
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
|
||||||
|
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-secondary)]'}
|
||||||
|
{i > 0 ? 'border-l border-[var(--color-border)]' : ''}"
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if onlaunch}
|
||||||
|
<button
|
||||||
|
onclick={onlaunch}
|
||||||
|
disabled={launchDisabled}
|
||||||
|
title={launchDisabled ? 'No active team — re-authenticate to create capsules' : undefined}
|
||||||
|
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:pointer-events-none disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Launch Capsule
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stat cards: 3 paired cards (now / 30d peak) -->
|
||||||
|
<div class="grid grid-cols-3 overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]">
|
||||||
|
|
||||||
|
<!-- Running capsules -->
|
||||||
|
<div class="border-r border-[var(--color-border)]" style="box-shadow: inset 5px 0 0 var(--color-accent)">
|
||||||
|
<div class="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-3">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-[var(--color-accent)]"></span>
|
||||||
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Running Capsules</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 divide-x divide-[var(--color-border)]">
|
||||||
|
<div class="bg-[var(--color-bg-3)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
|
||||||
|
<div class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Now</div>
|
||||||
|
<div class="mt-2 font-serif text-[2.571rem] leading-none tracking-[-0.04em] {(!loading && (stats?.current.running_count ?? 0) > 0) ? 'text-[var(--color-accent-bright)]' : 'text-[var(--color-text-bright)]'}">
|
||||||
|
{loading ? '—' : (stats?.current.running_count ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--color-bg-2)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
||||||
|
<div class="text-label text-[var(--color-text-muted)]">Peak · 30d</div>
|
||||||
|
<div class="mt-2 font-serif text-[1.714rem] leading-none tracking-[-0.03em] text-[var(--color-text-secondary)]">
|
||||||
|
{loading ? '—' : (stats?.peaks.running_count ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reserved CPU -->
|
||||||
|
<div class="border-r border-[var(--color-border)]" style="box-shadow: inset 5px 0 0 #5a9fd4">
|
||||||
|
<div class="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-3">
|
||||||
|
<span class="h-2 w-2 rounded-full" style="background: #5a9fd4"></span>
|
||||||
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">CPU · vCPUs</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 divide-x divide-[var(--color-border)]">
|
||||||
|
<div class="bg-[var(--color-bg-3)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
|
||||||
|
<div class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Reserved now</div>
|
||||||
|
<div class="mt-2 font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
|
||||||
|
{loading ? '—' : (stats?.current.vcpus_reserved ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--color-bg-2)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
||||||
|
<div class="text-label text-[var(--color-text-muted)]">Peak · 30d</div>
|
||||||
|
<div class="mt-2 font-serif text-[1.714rem] leading-none tracking-[-0.03em] text-[var(--color-text-secondary)]">
|
||||||
|
{loading ? '—' : (stats?.peaks.vcpus ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reserved RAM -->
|
||||||
|
<div style="box-shadow: inset 5px 0 0 #d4a73c">
|
||||||
|
<div class="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-3">
|
||||||
|
<span class="h-2 w-2 rounded-full" style="background: #d4a73c"></span>
|
||||||
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">RAM</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 divide-x divide-[var(--color-border)]">
|
||||||
|
<div class="bg-[var(--color-bg-3)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
|
||||||
|
<div class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Reserved now</div>
|
||||||
|
<div class="mt-2 font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
|
||||||
|
{loading ? '—' : fmtGB(stats?.current.memory_mb_reserved ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--color-bg-2)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
||||||
|
<div class="text-label text-[var(--color-text-muted)]">Peak · 30d</div>
|
||||||
|
<div class="mt-2 font-serif text-[1.714rem] leading-none tracking-[-0.03em] text-[var(--color-text-secondary)]">
|
||||||
|
{loading ? '—' : fmtGB(stats?.peaks.memory_mb ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
{#if error}
|
||||||
|
<div class="flex items-center gap-3 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/8 px-4 py-3">
|
||||||
|
<svg class="shrink-0 text-[var(--color-red)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-ui text-[var(--color-red)]">Failed to load stats: {error}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Charts -->
|
||||||
|
<div class="flex flex-1 flex-col gap-5">
|
||||||
|
|
||||||
|
<!-- Running Capsules -->
|
||||||
|
<div class="flex flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||||
|
<div class="border-b border-[var(--color-border)] px-6 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-[var(--color-accent)]"></span>
|
||||||
|
<div class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Running Capsules</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 260px">
|
||||||
|
<canvas bind:this={canvasRunning}></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CPU & RAM side by side -->
|
||||||
|
<div class="grid grid-cols-2 gap-5">
|
||||||
|
|
||||||
|
<!-- CPU -->
|
||||||
|
<div class="flex flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||||
|
<div class="border-b border-[var(--color-border)] px-6 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="h-2 w-2 rounded-full" style="background: #5a9fd4"></span>
|
||||||
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">CPU · vCPUs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 220px">
|
||||||
|
<canvas bind:this={canvasCpu}></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RAM -->
|
||||||
|
<div class="flex flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||||
|
<div class="border-b border-[var(--color-border)] px-6 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="h-2 w-2 rounded-full" style="background: #d4a73c"></span>
|
||||||
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">RAM · GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 220px">
|
||||||
|
<canvas bind:this={canvasRam}></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
20
frontend/src/lib/components/icons/IconMetrics.svelte
Normal file
20
frontend/src/lib/components/icons/IconMetrics.svelte
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10" />
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14" />
|
||||||
|
</svg>
|
||||||
@ -26,3 +26,4 @@ export { default as IconBox } from './IconBox.svelte';
|
|||||||
export { default as IconServer } from './IconServer.svelte';
|
export { default as IconServer } from './IconServer.svelte';
|
||||||
export { default as IconGear } from './IconGear.svelte';
|
export { default as IconGear } from './IconGear.svelte';
|
||||||
export { default as IconShield } from './IconShield.svelte';
|
export { default as IconShield } from './IconShield.svelte';
|
||||||
|
export { default as IconMetrics } from './IconMetrics.svelte';
|
||||||
|
|||||||
@ -556,15 +556,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@keyframes fadeUp {
|
/* fadeUp and iconFloat are defined globally in app.css — no need to redeclare them here */
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes iconFloat {
|
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
50% { transform: translateY(-6px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes stripePulse {
|
@keyframes stripePulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
|
|||||||
88
frontend/src/routes/dashboard/capsules/+layout.svelte
Normal file
88
frontend/src/routes/dashboard/capsules/+layout.svelte
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import { capsuleRunningCount } from '$lib/capsule-store.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
let collapsed = $state(
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem('wrenn_sidebar_collapsed') === 'true'
|
||||||
|
: false
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Wrenn — Capsules</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
<Sidebar bind:collapsed />
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<main class="flex flex-1 flex-col overflow-y-auto bg-[var(--color-bg-0)]">
|
||||||
|
<!-- Header area -->
|
||||||
|
{#if $page.params.id}
|
||||||
|
<!-- Breadcrumb header for capsule detail (no border-b — tabs provide it) -->
|
||||||
|
<div class="px-7 pt-8">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<a
|
||||||
|
href="/dashboard/capsules"
|
||||||
|
class="font-serif text-page leading-none tracking-[-0.02em] text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-text-bright)]"
|
||||||
|
>
|
||||||
|
Capsules
|
||||||
|
</a>
|
||||||
|
<span class="text-[var(--color-text-muted)] select-none" style="font-size: 1.1rem">›</span>
|
||||||
|
<span class="font-mono text-[1.1rem] leading-none text-[var(--color-text-bright)]">
|
||||||
|
{$page.params.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Default list header -->
|
||||||
|
<div class="px-7 pt-8 pb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-serif text-page tracking-[-0.02em] text-[var(--color-text-bright)]">
|
||||||
|
Capsules
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
|
||||||
|
Isolated VMs. Start cold in under a second — pause, snapshot, or destroy at will.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2.5 rounded-[var(--radius-card)] border border-[var(--color-accent)]/20 bg-[var(--color-bg-2)] px-3.5 py-2"
|
||||||
|
>
|
||||||
|
<span class="relative flex h-[8px] w-[8px]">
|
||||||
|
<span
|
||||||
|
class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"
|
||||||
|
></span>
|
||||||
|
<span class="relative inline-flex h-[8px] w-[8px] rounded-full bg-[var(--color-accent)]"></span>
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-body font-semibold text-[var(--color-accent-bright)]">{capsuleRunningCount.value}</span>
|
||||||
|
<span class="text-ui text-[var(--color-text-secondary)]">running now</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Status bar -->
|
||||||
|
<footer
|
||||||
|
class="flex h-7 shrink-0 items-center justify-end border-t border-[var(--color-border)] bg-[var(--color-bg-1)] px-7"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="relative flex h-[5px] w-[5px]">
|
||||||
|
<span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"></span>
|
||||||
|
<span class="relative inline-flex h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]"></span>
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-label uppercase tracking-[0.04em] text-[var(--color-text-secondary)]">All systems operational</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -1,28 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import CreateCapsuleDialog from '$lib/components/CreateCapsuleDialog.svelte';
|
||||||
|
import { capsuleRunningCount } from '$lib/capsule-store.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import { auth } from '$lib/auth.svelte';
|
import { auth } from '$lib/auth.svelte';
|
||||||
import {
|
import {
|
||||||
listCapsules,
|
listCapsules,
|
||||||
createCapsule,
|
|
||||||
pauseCapsule,
|
pauseCapsule,
|
||||||
resumeCapsule,
|
resumeCapsule,
|
||||||
destroyCapsule,
|
destroyCapsule,
|
||||||
createSnapshot,
|
createSnapshot,
|
||||||
type Capsule,
|
type Capsule
|
||||||
type CreateCapsuleParams
|
|
||||||
} from '$lib/api/capsules';
|
} from '$lib/api/capsules';
|
||||||
|
|
||||||
const REFRESH_INTERVAL = 30;
|
const REFRESH_INTERVAL = 30;
|
||||||
const SPIN_DURATION = 600; // ms — minimum full rotation time
|
const SPIN_DURATION = 600;
|
||||||
|
|
||||||
let collapsed = $state(
|
|
||||||
typeof window !== 'undefined'
|
|
||||||
? localStorage.getItem('wrenn_sidebar_collapsed') === 'true'
|
|
||||||
: false
|
|
||||||
);
|
|
||||||
let activeTab: 'list' | 'stats' = $state('list');
|
|
||||||
|
|
||||||
// Capsule list state
|
// Capsule list state
|
||||||
let capsules = $state<Capsule[]>([]);
|
let capsules = $state<Capsule[]>([]);
|
||||||
@ -49,9 +41,6 @@
|
|||||||
|
|
||||||
// Create dialog state
|
// Create dialog state
|
||||||
let showCreateDialog = $state(false);
|
let showCreateDialog = $state(false);
|
||||||
let createForm = $state<CreateCapsuleParams>({ template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 });
|
|
||||||
let creating = $state(false);
|
|
||||||
let createError = $state<string | null>(null);
|
|
||||||
|
|
||||||
// Snapshot dialog state
|
// Snapshot dialog state
|
||||||
let snapshotTarget = $state<{ capsule: Capsule; pauseFirst: boolean } | null>(null);
|
let snapshotTarget = $state<{ capsule: Capsule; pauseFirst: boolean } | null>(null);
|
||||||
@ -64,9 +53,12 @@
|
|||||||
let destroying = $state(false);
|
let destroying = $state(false);
|
||||||
let destroyError = $state<string | null>(null);
|
let destroyError = $state<string | null>(null);
|
||||||
|
|
||||||
// Delight: briefly highlight a newly created capsule row
|
// Briefly highlight a newly created capsule row
|
||||||
let newCapsuleId = $state<string | null>(null);
|
let newCapsuleId = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Track whether initial load animation has played (suppress on poll refreshes)
|
||||||
|
let initialAnimationDone = $state(false);
|
||||||
|
|
||||||
let filteredCapsules = $derived.by(() => {
|
let filteredCapsules = $derived.by(() => {
|
||||||
let list = searchQuery
|
let list = searchQuery
|
||||||
? capsules.filter((c) => c.id.toLowerCase().includes(searchQuery.toLowerCase()))
|
? capsules.filter((c) => c.id.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
@ -93,7 +85,9 @@
|
|||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
|
|
||||||
let runningCount = $derived(capsules.filter((c) => c.status === 'running').length);
|
$effect(() => {
|
||||||
|
capsuleRunningCount.value = capsules.filter((c) => c.status === 'running').length;
|
||||||
|
});
|
||||||
|
|
||||||
function toggleSort(key: SortKey) {
|
function toggleSort(key: SortKey) {
|
||||||
if (sortKey === key) {
|
if (sortKey === key) {
|
||||||
@ -130,29 +124,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCapsules() {
|
async function fetchCapsules(manual = false) {
|
||||||
const wasEmpty = capsules.length === 0;
|
const wasEmpty = capsules.length === 0;
|
||||||
if (wasEmpty) loading = true;
|
if (wasEmpty) loading = true;
|
||||||
|
|
||||||
// Spin for at least SPIN_DURATION ms
|
if (manual) {
|
||||||
spinning = true;
|
spinning = true;
|
||||||
const spinTimer = new Promise<void>((resolve) => setTimeout(resolve, SPIN_DURATION));
|
var spinTimer = new Promise<void>((resolve) => setTimeout(resolve, SPIN_DURATION));
|
||||||
|
}
|
||||||
|
|
||||||
error = null;
|
|
||||||
const result = await listCapsules();
|
const result = await listCapsules();
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
capsules = result.data;
|
capsules = result.data;
|
||||||
} else {
|
|
||||||
error = result.error;
|
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
// Reset countdown on manual or auto refresh
|
// Mark initial entrance animation as done after first successful fetch
|
||||||
|
if (!initialAnimationDone) {
|
||||||
|
setTimeout(() => { initialAnimationDone = true; }, 400 + (capsules.length * 40));
|
||||||
|
}
|
||||||
|
|
||||||
if (autoRefresh) countdown = REFRESH_INTERVAL;
|
if (autoRefresh) countdown = REFRESH_INTERVAL;
|
||||||
|
|
||||||
await spinTimer;
|
if (manual) {
|
||||||
|
await spinTimer!;
|
||||||
spinning = false;
|
spinning = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePause(id: string) {
|
async function handlePause(id: string) {
|
||||||
openMenuId = null;
|
openMenuId = null;
|
||||||
@ -221,21 +219,10 @@
|
|||||||
destroying = false;
|
destroying = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
function handleCapsuleCreated(capsule: Capsule) {
|
||||||
creating = true;
|
capsules = [capsule, ...capsules];
|
||||||
createError = null;
|
newCapsuleId = capsule.id;
|
||||||
const result = await createCapsule(createForm);
|
|
||||||
if (result.ok) {
|
|
||||||
capsules = [result.data, ...capsules];
|
|
||||||
showCreateDialog = false;
|
|
||||||
createForm = { template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 };
|
|
||||||
// Flash the new row briefly
|
|
||||||
newCapsuleId = result.data.id;
|
|
||||||
setTimeout(() => { newCapsuleId = null; }, 1600);
|
setTimeout(() => { newCapsuleId = null; }, 1600);
|
||||||
} else {
|
|
||||||
createError = result.error;
|
|
||||||
}
|
|
||||||
creating = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(iso: string | undefined): string {
|
function formatTime(iso: string | undefined): string {
|
||||||
@ -266,7 +253,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial fetch + auto-refresh setup
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
fetchCapsules();
|
fetchCapsules();
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
@ -279,7 +265,6 @@
|
|||||||
animation: spin-once 0.6s ease-in-out;
|
animation: spin-once 0.6s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Row born flash — new capsule appears with a brief accent glow */
|
|
||||||
@keyframes capsule-born {
|
@keyframes capsule-born {
|
||||||
0%, 25% { background-color: rgba(94, 140, 88, 0.1); }
|
0%, 25% { background-color: rgba(94, 140, 88, 0.1); }
|
||||||
100% { background-color: transparent; }
|
100% { background-color: transparent; }
|
||||||
@ -288,7 +273,6 @@
|
|||||||
animation: capsule-born 1.6s ease-out forwards;
|
animation: capsule-born 1.6s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Left accent stripe — slides in on row hover */
|
|
||||||
.row-stripe {
|
.row-stripe {
|
||||||
transform: scaleY(0);
|
transform: scaleY(0);
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
@ -302,88 +286,7 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<svelte:window onclick={handleClickOutside} onkeydown={(e) => { if (e.key === 'Escape') openMenuId = null; }} />
|
<svelte:window onclick={handleClickOutside} onkeydown={(e) => { if (e.key === 'Escape') openMenuId = null; }} />
|
||||||
|
|
||||||
<svelte:head>
|
<div class="p-8" style="animation: fadeUp 0.35s ease both">
|
||||||
<title>Wrenn — Capsules</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="flex h-screen overflow-hidden">
|
|
||||||
<Sidebar bind:collapsed />
|
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<main class="flex-1 overflow-y-auto bg-[var(--color-bg-0)]">
|
|
||||||
<!-- Header area -->
|
|
||||||
<div class="px-7 pt-8">
|
|
||||||
<!-- Top row: title + status chip -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="font-serif text-page tracking-[-0.02em] text-[var(--color-text-bright)]">
|
|
||||||
Capsules
|
|
||||||
</h1>
|
|
||||||
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
|
|
||||||
Isolated VMs. Start cold in under a second — pause, snapshot, or destroy at will.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<!-- Status chip -->
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2.5 rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] px-3.5 py-2"
|
|
||||||
>
|
|
||||||
<span class="relative flex h-[7px] w-[7px]">
|
|
||||||
<span
|
|
||||||
class="absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"
|
|
||||||
style="animation: wrenn-glow 2.5s ease-in-out infinite"
|
|
||||||
></span>
|
|
||||||
<span class="relative inline-flex h-[7px] w-[7px] rounded-full bg-[var(--color-accent)]"></span>
|
|
||||||
</span>
|
|
||||||
<span class="font-mono text-body font-semibold text-[var(--color-accent-bright)]">{runningCount}</span>
|
|
||||||
<span class="text-ui text-[var(--color-text-secondary)]">running now</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab bar -->
|
|
||||||
<div class="mt-5 flex gap-1 border-b border-[var(--color-border)]">
|
|
||||||
<button
|
|
||||||
onclick={() => (activeTab = 'list')}
|
|
||||||
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150 {activeTab === 'list'
|
|
||||||
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
|
|
||||||
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
|
|
||||||
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
|
|
||||||
</svg>
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (activeTab = 'stats')}
|
|
||||||
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150 {activeTab === 'stats'
|
|
||||||
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
|
|
||||||
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
|
||||||
</svg>
|
|
||||||
Stats
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab content -->
|
|
||||||
{#if activeTab === 'stats'}
|
|
||||||
<div class="p-8 space-y-5" style="animation: fadeUp 0.35s ease both">
|
|
||||||
<div class="flex overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]">
|
|
||||||
{@render metricCell('Concurrent Capsules', String(runningCount), '5-sec avg', 'limit: 20', true)}
|
|
||||||
{@render metricCell('Start Rate / Second', '0.000', '5-sec avg', null, true)}
|
|
||||||
{@render metricCell('Peak Concurrent', String(runningCount), '30-day max', 'limit: 20', false)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{@render chartCard('Concurrent Capsules', String(runningCount), 'average')}
|
|
||||||
{@render chartCard('Start Rate Per Second', '0.000', 'average')}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="p-8" style="animation: fadeUp 0.35s ease both">
|
|
||||||
<!-- Search bar + controls -->
|
<!-- Search bar + controls -->
|
||||||
<div class="mb-4 flex items-center gap-3">
|
<div class="mb-4 flex items-center gap-3">
|
||||||
<div class="relative flex-1 max-w-[300px]">
|
<div class="relative flex-1 max-w-[300px]">
|
||||||
@ -403,7 +306,7 @@
|
|||||||
|
|
||||||
<!-- Refresh button -->
|
<!-- Refresh button -->
|
||||||
<button
|
<button
|
||||||
onclick={fetchCapsules}
|
onclick={() => fetchCapsules(true)}
|
||||||
disabled={spinning}
|
disabled={spinning}
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-[var(--radius-button)] border border-[var(--color-border)] text-[var(--color-text-tertiary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-secondary)] disabled:opacity-50"
|
class="flex h-8 w-8 items-center justify-center rounded-[var(--radius-button)] border border-[var(--color-border)] text-[var(--color-text-tertiary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-secondary)] disabled:opacity-50"
|
||||||
title="Refresh"
|
title="Refresh"
|
||||||
@ -427,7 +330,6 @@
|
|||||||
title={autoRefresh ? 'Click to disable auto-refresh' : 'Click to enable auto-refresh (30s)'}
|
title={autoRefresh ? 'Click to disable auto-refresh' : 'Click to enable auto-refresh (30s)'}
|
||||||
>
|
>
|
||||||
{#if autoRefresh}
|
{#if autoRefresh}
|
||||||
<!-- Radial progress ring — drains as countdown ticks down -->
|
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<circle cx="8" cy="8" r="5" stroke="var(--color-accent-glow-mid)" stroke-width="1.5" />
|
<circle cx="8" cy="8" r="5" stroke="var(--color-accent-glow-mid)" stroke-width="1.5" />
|
||||||
<circle
|
<circle
|
||||||
@ -448,7 +350,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => { showCreateDialog = true; createError = null; }}
|
onclick={() => { showCreateDialog = true; }}
|
||||||
disabled={!auth.teamId}
|
disabled={!auth.teamId}
|
||||||
title={!auth.teamId ? 'No active team — re-authenticate to create capsules' : undefined}
|
title={!auth.teamId ? 'No active team — re-authenticate to create capsules' : undefined}
|
||||||
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:pointer-events-none disabled:opacity-40"
|
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:pointer-events-none disabled:opacity-40"
|
||||||
@ -507,7 +409,7 @@
|
|||||||
Each capsule is an isolated VM. Launch one to get started.
|
Each capsule is an isolated VM. Launch one to get started.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={() => { showCreateDialog = true; createError = null; }}
|
onclick={() => { showCreateDialog = true; }}
|
||||||
disabled={!auth.teamId}
|
disabled={!auth.teamId}
|
||||||
class="mt-6 flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:pointer-events-none disabled:opacity-40"
|
class="mt-6 flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:pointer-events-none disabled:opacity-40"
|
||||||
>
|
>
|
||||||
@ -522,16 +424,16 @@
|
|||||||
{@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : 'bg-[var(--color-text-muted)]'}
|
{@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : 'bg-[var(--color-text-muted)]'}
|
||||||
<div
|
<div
|
||||||
class="capsule-row relative grid grid-cols-[1.6fr_0.8fr_0.5fr_0.5fr_0.6fr_1fr_0.9fr] items-center overflow-hidden border-b border-[var(--color-border)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] last:border-b-0 {newCapsuleId === capsule.id ? 'capsule-born' : ''}"
|
class="capsule-row relative grid grid-cols-[1.6fr_0.8fr_0.5fr_0.5fr_0.6fr_1fr_0.9fr] items-center overflow-hidden border-b border-[var(--color-border)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] last:border-b-0 {newCapsuleId === capsule.id ? 'capsule-born' : ''}"
|
||||||
style="animation: fadeUp 0.35s ease both; animation-delay: {i * 40}ms"
|
style={initialAnimationDone ? '' : `animation: fadeUp 0.35s ease both; animation-delay: ${i * 40}ms`}
|
||||||
>
|
>
|
||||||
<!-- Left accent stripe — slides in on hover, color-keyed to status -->
|
<!-- Left accent stripe -->
|
||||||
<div class="row-stripe pointer-events-none absolute left-0 top-0 h-full w-0.5 {stripeColor}"></div>
|
<div class="row-stripe pointer-events-none absolute left-0 top-0 h-full w-0.5 {stripeColor}"></div>
|
||||||
|
|
||||||
<!-- ID with status dot -->
|
<!-- ID with status dot -->
|
||||||
<div class="flex items-center gap-2.5 px-5 py-4">
|
<div class="flex items-center gap-2.5 px-5 py-4">
|
||||||
{#if capsule.status === 'running'}
|
{#if capsule.status === 'running'}
|
||||||
<span class="relative flex h-[6px] w-[6px] shrink-0">
|
<span class="relative flex h-[6px] w-[6px] shrink-0">
|
||||||
<span class="absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]" style="animation: wrenn-glow 2.5s ease-in-out infinite"></span>
|
<span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"></span>
|
||||||
<span class="relative inline-flex h-[6px] w-[6px] rounded-full bg-[var(--color-accent)]"></span>
|
<span class="relative inline-flex h-[6px] w-[6px] rounded-full bg-[var(--color-accent)]"></span>
|
||||||
</span>
|
</span>
|
||||||
{:else if capsule.status === 'paused'}
|
{:else if capsule.status === 'paused'}
|
||||||
@ -541,9 +443,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if searchQuery && capsule.id.toLowerCase().includes(searchQuery.toLowerCase())}
|
{#if searchQuery && capsule.id.toLowerCase().includes(searchQuery.toLowerCase())}
|
||||||
{@const matchIdx = capsule.id.toLowerCase().indexOf(searchQuery.toLowerCase())}
|
{@const matchIdx = capsule.id.toLowerCase().indexOf(searchQuery.toLowerCase())}
|
||||||
<span class="font-mono text-ui text-[var(--color-text-bright)]">{capsule.id.slice(0, matchIdx)}<mark class="rounded-[2px] bg-[var(--color-accent-glow-mid)] px-0.5 text-[var(--color-accent-bright)] not-italic">{capsule.id.slice(matchIdx, matchIdx + searchQuery.length)}</mark>{capsule.id.slice(matchIdx + searchQuery.length)}</span>
|
<a href="/dashboard/capsules/{capsule.id}" class="font-mono text-ui text-[var(--color-text-bright)] hover:text-[var(--color-accent-bright)] transition-colors duration-150">{capsule.id.slice(0, matchIdx)}<mark class="rounded-[2px] bg-[var(--color-accent-glow-mid)] px-0.5 text-[var(--color-accent-bright)] not-italic">{capsule.id.slice(matchIdx, matchIdx + searchQuery.length)}</mark>{capsule.id.slice(matchIdx + searchQuery.length)}</a>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="font-mono text-ui text-[var(--color-text-bright)]">{capsule.id}</span>
|
<a href="/dashboard/capsules/{capsule.id}" class="font-mono text-ui text-[var(--color-text-bright)] hover:text-[var(--color-accent-bright)] transition-colors duration-150">{capsule.id}</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -605,27 +507,12 @@
|
|||||||
<polyline points="6 9 12 15 18 9" />
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Status bar -->
|
|
||||||
<footer
|
|
||||||
class="flex h-7 shrink-0 items-center justify-end border-t border-[var(--color-border)] bg-[var(--color-bg-1)] px-7"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span class="inline-flex h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]"></span>
|
|
||||||
<span class="font-mono text-label uppercase tracking-[0.04em] text-[var(--color-text-secondary)]">All systems operational</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fixed-position status popover menu -->
|
<!-- Fixed-position status popover menu -->
|
||||||
@ -703,8 +590,7 @@
|
|||||||
onkeydown={(e) => { if (e.key === 'Escape' && !snapshotting) snapshotTarget = null; }}
|
onkeydown={(e) => { if (e.key === 'Escape' && !snapshotting) snapshotTarget = null; }}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] overflow-hidden" style="animation: fadeUp 0.2s ease both">
|
<div class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] overflow-hidden" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
|
||||||
<!-- Header band -->
|
|
||||||
<div class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-5">
|
<div class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-5">
|
||||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--radius-input)] bg-[var(--color-accent)]/15 text-[var(--color-accent)] shadow-[0_0_12px_var(--color-accent-glow)]">
|
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--radius-input)] bg-[var(--color-accent)]/15 text-[var(--color-accent)] shadow-[0_0_12px_var(--color-accent-glow)]">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@ -783,104 +669,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Create Capsule Dialog -->
|
<!-- Destroy confirmation dialog -->
|
||||||
{#if showCreateDialog}
|
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-black/60"
|
|
||||||
onclick={() => { if (!creating) showCreateDialog = false; }}
|
|
||||||
onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreateDialog = false; }}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<div class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both">
|
|
||||||
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Launch Capsule</h2>
|
|
||||||
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">Configure resources and launch. The VM will be ready in under a second.</p>
|
|
||||||
|
|
||||||
{#if createError}
|
|
||||||
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
|
||||||
{createError}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-5 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-template">Template</label>
|
|
||||||
<input
|
|
||||||
id="create-template"
|
|
||||||
type="text"
|
|
||||||
bind:value={createForm.template}
|
|
||||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)]"
|
|
||||||
placeholder="minimal"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-vcpus">vCPUs</label>
|
|
||||||
<input
|
|
||||||
id="create-vcpus"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="8"
|
|
||||||
bind:value={createForm.vcpus}
|
|
||||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-memory">Memory (MB)</label>
|
|
||||||
<input
|
|
||||||
id="create-memory"
|
|
||||||
type="number"
|
|
||||||
min="128"
|
|
||||||
max="8192"
|
|
||||||
step="128"
|
|
||||||
bind:value={createForm.memory_mb}
|
|
||||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-timeout">Idle timeout (seconds — 0 = never pause)</label>
|
|
||||||
<input
|
|
||||||
id="create-timeout"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
bind:value={createForm.timeout_sec}
|
|
||||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
onclick={() => { showCreateDialog = false; }}
|
|
||||||
disabled={creating}
|
|
||||||
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={handleCreate}
|
|
||||||
disabled={creating}
|
|
||||||
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
|
||||||
>
|
|
||||||
{#if creating}
|
|
||||||
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
||||||
</svg>
|
|
||||||
Launching...
|
|
||||||
{:else}
|
|
||||||
Launch
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Destroy Confirmation Dialog -->
|
|
||||||
{#if destroyTarget}
|
{#if destroyTarget}
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
@ -889,7 +678,6 @@
|
|||||||
onclick={() => { if (!destroying) destroyTarget = null; }}
|
onclick={() => { if (!destroying) destroyTarget = null; }}
|
||||||
onkeydown={(e) => { if (e.key === 'Escape' && !destroying) destroyTarget = null; }}
|
onkeydown={(e) => { if (e.key === 'Escape' && !destroying) destroyTarget = null; }}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both">
|
<div class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both">
|
||||||
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Destroy Capsule</h2>
|
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Destroy Capsule</h2>
|
||||||
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
|
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
|
||||||
@ -913,7 +701,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={handleDestroy}
|
onclick={handleDestroy}
|
||||||
disabled={destroying}
|
disabled={destroying}
|
||||||
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-110 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||||
>
|
>
|
||||||
{#if destroying}
|
{#if destroying}
|
||||||
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@ -929,7 +717,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Sortable header snippet -->
|
<!-- Create Capsule Dialog -->
|
||||||
|
<CreateCapsuleDialog
|
||||||
|
open={showCreateDialog}
|
||||||
|
onclose={() => { showCreateDialog = false; }}
|
||||||
|
oncreated={handleCapsuleCreated}
|
||||||
|
/>
|
||||||
|
|
||||||
{#snippet sortableHeader(label: string, key: SortKey)}
|
{#snippet sortableHeader(label: string, key: SortKey)}
|
||||||
<button
|
<button
|
||||||
onclick={() => toggleSort(key)}
|
onclick={() => toggleSort(key)}
|
||||||
@ -937,87 +731,13 @@
|
|||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{#if sortKey === key}
|
{#if sortKey === key}
|
||||||
<svg
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="text-[var(--color-accent)]">
|
||||||
class="transition-transform duration-150 {sortDir === 'desc' ? 'rotate-180' : ''}"
|
{#if sortDir === 'asc'}
|
||||||
width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="18 15 12 9 6 15" />
|
<polyline points="18 15 12 9 6 15" />
|
||||||
|
{:else}
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
{/if}
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet metricCell(label: string, value: string, sublabel: string, extra: string | null, hasBorderRight: boolean)}
|
|
||||||
<div class="flex-1 bg-[var(--color-bg-2)] px-5 py-5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {hasBorderRight ? 'border-r border-[var(--color-border)]' : ''}">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-meta font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">{label}</span>
|
|
||||||
<span class="rounded-[3px] bg-[var(--color-accent-glow-mid)] px-1.5 py-0.5 text-badge font-semibold uppercase tracking-[0.04em] text-[var(--color-accent-mid)]">
|
|
||||||
<span class="mr-0.5 inline-block h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]" style="animation: wrenn-glow 2.5s ease-in-out infinite"></span>
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 font-serif text-[2.571rem] tracking-[-0.04em] text-[var(--color-text-bright)]">{value}</div>
|
|
||||||
<div class="mt-1 flex items-center gap-1.5 text-label text-[var(--color-text-tertiary)]">
|
|
||||||
<span>{sublabel}</span>
|
|
||||||
{#if extra}
|
|
||||||
<span class="text-[var(--color-text-muted)]">|</span>
|
|
||||||
<span class="font-mono text-[var(--color-text-muted)]">{extra}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet chartCard(label: string, value: string, sublabel: string)}
|
|
||||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
|
||||||
<div class="flex items-center justify-between px-5 pt-5 pb-3">
|
|
||||||
<div>
|
|
||||||
<div class="text-meta font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">{label}</div>
|
|
||||||
<div class="mt-0.5 flex items-baseline gap-2">
|
|
||||||
<span class="font-serif text-[2.143rem] tracking-[-0.04em] text-[var(--color-text-bright)]">{value}</span>
|
|
||||||
<span class="text-ui text-[var(--color-text-secondary)]">{sublabel}</span>
|
|
||||||
<span class="rounded-[3px] bg-[var(--color-accent-glow-mid)] px-1.5 py-0.5 text-badge font-semibold uppercase tracking-[0.04em] text-[var(--color-accent-mid)]">
|
|
||||||
<span class="mr-0.5 inline-block h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]" style="animation: wrenn-glow 2.5s ease-in-out infinite"></span>
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex overflow-hidden rounded-[var(--radius-button)] border border-[var(--color-border)]">
|
|
||||||
{#each ['5m', '1H', '6H', '24H', '30D'] as range, i}
|
|
||||||
<button
|
|
||||||
class="px-2.5 py-1 font-mono text-label transition-colors duration-150 {range === '1H'
|
|
||||||
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
|
|
||||||
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'} {i > 0
|
|
||||||
? 'border-l border-[var(--color-border)]'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
{range}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative h-[200px] px-5 pb-3">
|
|
||||||
<div class="absolute left-0 top-0 flex h-full w-12 flex-col justify-between py-1 text-right">
|
|
||||||
<span class="font-mono text-badge text-[var(--color-text-muted)]">4</span>
|
|
||||||
<span class="font-mono text-badge text-[var(--color-text-muted)]">3</span>
|
|
||||||
<span class="font-mono text-badge text-[var(--color-text-muted)]">2</span>
|
|
||||||
<span class="font-mono text-badge text-[var(--color-text-muted)]">1</span>
|
|
||||||
<span class="font-mono text-badge text-[var(--color-text-muted)]">0</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg class="ml-8 h-full w-[calc(100%-2rem)]" viewBox="0 0 400 180" preserveAspectRatio="none">
|
|
||||||
{#each [0, 45, 90, 135, 180] as y}
|
|
||||||
<line x1="0" y1={y} x2="400" y2={y} stroke="var(--color-border)" stroke-width="0.5" stroke-dasharray="4 4" />
|
|
||||||
{/each}
|
|
||||||
<line x1="0" y1="180" x2="400" y2="180" stroke="var(--color-accent)" stroke-width="1.5" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="ml-8 flex justify-between pt-2">
|
|
||||||
{#each ['03:01', '03:02', '03:03', '03:04', '03:05'] as t}
|
|
||||||
<span class="font-mono text-badge text-[var(--color-text-muted)]">{t}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|||||||
582
frontend/src/routes/dashboard/capsules/[id]/+page.svelte
Normal file
582
frontend/src/routes/dashboard/capsules/[id]/+page.svelte
Normal file
@ -0,0 +1,582 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getCapsule, type Capsule } from '$lib/api/capsules';
|
||||||
|
import {
|
||||||
|
fetchSandboxMetrics,
|
||||||
|
METRIC_RANGES,
|
||||||
|
METRIC_POLL_INTERVAL,
|
||||||
|
type MetricRange,
|
||||||
|
type MetricPoint
|
||||||
|
} from '$lib/api/metrics';
|
||||||
|
|
||||||
|
const sandboxId: string = $page.params.id ?? '';
|
||||||
|
|
||||||
|
let capsule = $state<Capsule | null>(null);
|
||||||
|
let capsuleLoading = $state(true);
|
||||||
|
let capsuleError = $state<string | null>(null);
|
||||||
|
|
||||||
|
type Tab = 'metrics' | 'files';
|
||||||
|
let activeTab = $state<Tab>('metrics');
|
||||||
|
|
||||||
|
let range = $state<MetricRange>('10m');
|
||||||
|
let points = $state<MetricPoint[]>([]);
|
||||||
|
let metricsLoading = $state(true);
|
||||||
|
let metricsError = $state<string | null>(null);
|
||||||
|
|
||||||
|
let canvasCpu = $state<HTMLCanvasElement | undefined>(undefined);
|
||||||
|
let canvasRam = $state<HTMLCanvasElement | undefined>(undefined);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let chartCpu: any = null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let chartRam: any = null;
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const metricsAvailable = $derived(
|
||||||
|
capsule?.status === 'running' || capsule?.status === 'paused'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Latest values for live reading display in chart headers
|
||||||
|
const latestCpu = $derived<number | null>(
|
||||||
|
points.length > 0 ? points[points.length - 1].cpu_pct : null
|
||||||
|
);
|
||||||
|
const latestRamMB = $derived<number | null>(
|
||||||
|
points.length > 0 ? points[points.length - 1].mem_bytes / 1_048_576 : null
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadCapsule() {
|
||||||
|
const result = await getCapsule(sandboxId);
|
||||||
|
if (result.ok) {
|
||||||
|
capsule = result.data;
|
||||||
|
capsuleError = null;
|
||||||
|
} else {
|
||||||
|
capsuleError = result.error;
|
||||||
|
}
|
||||||
|
capsuleLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMetrics() {
|
||||||
|
if (!metricsAvailable) return;
|
||||||
|
const result = await fetchSandboxMetrics(sandboxId, range);
|
||||||
|
if (result.ok) {
|
||||||
|
points = result.data.points;
|
||||||
|
metricsError = null;
|
||||||
|
} else {
|
||||||
|
metricsError = result.error;
|
||||||
|
}
|
||||||
|
metricsLoading = false;
|
||||||
|
updateCharts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple moving average — smooths noisy high-frequency samples. */
|
||||||
|
function smooth(data: number[], window: number): number[] {
|
||||||
|
if (window <= 1) return data;
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const start = Math.max(0, i - Math.floor(window / 2));
|
||||||
|
const end = Math.min(data.length, i + Math.ceil(window / 2));
|
||||||
|
let sum = 0;
|
||||||
|
for (let j = start; j < end; j++) sum += data[j];
|
||||||
|
out.push(+(sum / (end - start)).toFixed(2));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Window size scales with point count — more data = more smoothing. */
|
||||||
|
function smoothWindow(count: number): number {
|
||||||
|
if (count < 60) return 1; // < 60 pts: no smoothing
|
||||||
|
if (count < 200) return 3;
|
||||||
|
if (count < 600) return 5;
|
||||||
|
return 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCharts() {
|
||||||
|
if (!points.length) return;
|
||||||
|
const labels = formatLabels(Array.from(points), range);
|
||||||
|
const w = smoothWindow(points.length);
|
||||||
|
if (chartCpu) {
|
||||||
|
chartCpu.data.labels = labels;
|
||||||
|
chartCpu.data.datasets[0].data = smooth(
|
||||||
|
Array.from(points.map((p) => +p.cpu_pct.toFixed(2))), w
|
||||||
|
);
|
||||||
|
chartCpu.update();
|
||||||
|
}
|
||||||
|
if (chartRam) {
|
||||||
|
chartRam.data.labels = labels;
|
||||||
|
chartRam.data.datasets[0].data = smooth(
|
||||||
|
Array.from(points.map((p) => +(p.mem_bytes / 1_048_576).toFixed(1))), w
|
||||||
|
);
|
||||||
|
chartRam.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabels(pts: MetricPoint[], r: MetricRange): string[] {
|
||||||
|
return pts.map((p) => {
|
||||||
|
const d = new Date(p.timestamp_unix * 1000);
|
||||||
|
if (r === '5m' || r === '10m') {
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
}
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRange(r: MetricRange) {
|
||||||
|
range = r;
|
||||||
|
goto(`?range=${r}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||||
|
metricsLoading = true;
|
||||||
|
restartPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartPolling() {
|
||||||
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
loadMetrics();
|
||||||
|
pollInterval = setInterval(loadMetrics, METRIC_POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart design tokens (match StatsPanel.svelte)
|
||||||
|
const C_BLUE = '#5a9fd4';
|
||||||
|
const C_BLUE_FILL = 'rgba(90,159,212,0.11)';
|
||||||
|
const C_AMBER = '#d4a73c';
|
||||||
|
const C_AMBER_FILL = 'rgba(212,167,60,0.11)';
|
||||||
|
const C_GRID = 'rgba(255,255,255,0.05)';
|
||||||
|
const C_TICK = '#635f5c';
|
||||||
|
const FONT_MONO = "'JetBrains Mono', monospace";
|
||||||
|
|
||||||
|
const BASE_CHART_OPTIONS = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false as const,
|
||||||
|
interaction: { mode: 'index' as const, intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: '#111412',
|
||||||
|
borderColor: '#1f2321',
|
||||||
|
borderWidth: 1,
|
||||||
|
titleColor: '#454340',
|
||||||
|
bodyColor: '#d4cfc8',
|
||||||
|
titleFont: { family: FONT_MONO, size: 10 },
|
||||||
|
bodyFont: { family: FONT_MONO, size: 11 },
|
||||||
|
padding: 10,
|
||||||
|
caretSize: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: C_GRID },
|
||||||
|
ticks: {
|
||||||
|
color: C_TICK,
|
||||||
|
font: { family: FONT_MONO, size: 10 },
|
||||||
|
maxTicksLimit: 8,
|
||||||
|
maxRotation: 0,
|
||||||
|
},
|
||||||
|
border: { color: C_GRID },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: C_GRID },
|
||||||
|
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 } },
|
||||||
|
border: { color: C_GRID },
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const urlRange = new URLSearchParams(window.location.search).get('range');
|
||||||
|
if (urlRange && METRIC_RANGES.includes(urlRange as MetricRange)) {
|
||||||
|
range = urlRange as MetricRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadCapsule();
|
||||||
|
|
||||||
|
if (!metricsAvailable) return;
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
if (!canvasCpu || !canvasRam) return;
|
||||||
|
|
||||||
|
const { Chart } = await import('chart.js/auto');
|
||||||
|
|
||||||
|
chartCpu = new Chart(canvasCpu, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [],
|
||||||
|
borderColor: C_BLUE,
|
||||||
|
backgroundColor: C_BLUE_FILL,
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHoverBackgroundColor: C_BLUE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...BASE_CHART_OPTIONS,
|
||||||
|
plugins: {
|
||||||
|
...BASE_CHART_OPTIONS.plugins,
|
||||||
|
tooltip: {
|
||||||
|
...BASE_CHART_OPTIONS.plugins.tooltip,
|
||||||
|
callbacks: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
label: (ctx: any) => ` ${ctx.parsed.y.toFixed(1)}%`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
...BASE_CHART_OPTIONS.scales,
|
||||||
|
y: {
|
||||||
|
...BASE_CHART_OPTIONS.scales.y,
|
||||||
|
ticks: {
|
||||||
|
...BASE_CHART_OPTIONS.scales.y.ticks,
|
||||||
|
callback: (v: string | number) => `${+v}%`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
chartRam = new Chart(canvasRam, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [],
|
||||||
|
borderColor: C_AMBER,
|
||||||
|
backgroundColor: C_AMBER_FILL,
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHoverBackgroundColor: C_AMBER,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...BASE_CHART_OPTIONS,
|
||||||
|
plugins: {
|
||||||
|
...BASE_CHART_OPTIONS.plugins,
|
||||||
|
tooltip: {
|
||||||
|
...BASE_CHART_OPTIONS.plugins.tooltip,
|
||||||
|
callbacks: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
label: (ctx: any) => ` ${ctx.parsed.y.toFixed(0)} MB`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
...BASE_CHART_OPTIONS.scales,
|
||||||
|
y: {
|
||||||
|
...BASE_CHART_OPTIONS.scales.y,
|
||||||
|
ticks: {
|
||||||
|
...BASE_CHART_OPTIONS.scales.y.ticks,
|
||||||
|
callback: (v: string | number) => `${+v} MB`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
updateCharts();
|
||||||
|
restartPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
chartCpu?.destroy();
|
||||||
|
chartRam?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
function statusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'running': return 'var(--color-accent)';
|
||||||
|
case 'paused': return 'var(--color-amber)';
|
||||||
|
case 'error': return 'var(--color-red)';
|
||||||
|
default: return 'var(--color-text-muted)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBg(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'running': return 'rgba(94,140,88,0.12)';
|
||||||
|
case 'paused': return 'rgba(212,167,60,0.12)';
|
||||||
|
case 'error': return 'rgba(207,129,114,0.12)';
|
||||||
|
default: return 'rgba(255,255,255,0.05)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBorder(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'running': return 'rgba(94,140,88,0.3)';
|
||||||
|
case 'paused': return 'rgba(212,167,60,0.3)';
|
||||||
|
case 'error': return 'rgba(207,129,114,0.3)';
|
||||||
|
default: return 'rgba(255,255,255,0.08)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleString([], {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTimeout(sec: number): string {
|
||||||
|
if (!sec) return 'None';
|
||||||
|
if (sec < 60) return `${sec}s`;
|
||||||
|
if (sec < 3600) return `${Math.round(sec / 60)}m`;
|
||||||
|
return `${Math.round(sec / 3600)}h`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Wrenn — {sandboxId}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.metric-val {
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
@keyframes fadeSlideUp {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.anim-in {
|
||||||
|
animation: fadeSlideUp 0.28s ease both;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{#if capsuleLoading}
|
||||||
|
<div class="flex items-center justify-center py-24">
|
||||||
|
<div class="flex items-center gap-3 text-ui text-[var(--color-text-secondary)]">
|
||||||
|
<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
Loading capsule...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if capsuleError}
|
||||||
|
<div class="px-7 py-8">
|
||||||
|
<div class="flex items-center gap-3 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/8 px-5 py-4">
|
||||||
|
<svg class="shrink-0 text-[var(--color-red)]" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-ui text-[var(--color-red)]">{capsuleError}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if capsule}
|
||||||
|
<div class="flex flex-1 flex-col min-h-0">
|
||||||
|
|
||||||
|
<!-- Tabs (matches Templates page pattern) -->
|
||||||
|
<div class="mt-5 flex gap-0 border-b border-[var(--color-border)] px-7">
|
||||||
|
<button
|
||||||
|
onclick={() => (activeTab = 'metrics')}
|
||||||
|
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
|
||||||
|
{activeTab === 'metrics'
|
||||||
|
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
|
||||||
|
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
Stats
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
title="Coming soon"
|
||||||
|
class="flex cursor-not-allowed items-center gap-2 border-b-2 border-transparent px-4 py-2.5 text-ui font-medium opacity-40"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
Files
|
||||||
|
<span class="rounded-[3px] bg-[var(--color-bg-4)] px-1.5 py-0.5 text-badge font-semibold uppercase tracking-[0.06em] text-[var(--color-text-muted)]">
|
||||||
|
Soon
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats tab content -->
|
||||||
|
{#if activeTab === 'metrics'}
|
||||||
|
<div
|
||||||
|
class="anim-in flex flex-1 flex-col gap-5 min-h-0 p-8"
|
||||||
|
style="animation-delay: 0.05s"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Controls row -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
{#if metricsAvailable && !metricsLoading}
|
||||||
|
<span class="flex items-center gap-1.5 rounded-[3px] border border-[var(--color-accent)]/25 bg-[var(--color-accent-glow-mid)] px-2 py-1 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-accent-mid)]">
|
||||||
|
<span class="h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]" style="animation: wrenn-glow 2.5s ease-in-out infinite"></span>
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<div></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if metricsAvailable}
|
||||||
|
<div class="flex overflow-hidden rounded-[var(--radius-button)] border border-[var(--color-border)]">
|
||||||
|
{#each METRIC_RANGES as r, i}
|
||||||
|
<button
|
||||||
|
onclick={() => setRange(r)}
|
||||||
|
class="px-3 py-1.5 font-mono text-label transition-colors duration-150
|
||||||
|
{range === r
|
||||||
|
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
|
||||||
|
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-secondary)]'}
|
||||||
|
{i > 0 ? 'border-l border-[var(--color-border)]' : ''}"
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info card (StatsPanel style) -->
|
||||||
|
<div class="overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]">
|
||||||
|
<div class="flex divide-x divide-[var(--color-border)]">
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5" style="box-shadow: inset 5px 0 0 {statusColor(capsule.status)}">
|
||||||
|
<div class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Status</div>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 self-start rounded-full px-2.5 py-1 text-label font-semibold uppercase tracking-[0.05em]"
|
||||||
|
style="color: {statusColor(capsule.status)}; background: {statusBg(capsule.status)}; border: 1px solid {statusBorder(capsule.status)}"
|
||||||
|
>
|
||||||
|
{#if capsule.status === 'running'}
|
||||||
|
<span class="relative flex h-[5px] w-[5px] shrink-0">
|
||||||
|
<span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"></span>
|
||||||
|
<span class="relative inline-flex h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]"></span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{capsule.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Template -->
|
||||||
|
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5">
|
||||||
|
<div class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Template</div>
|
||||||
|
<span class="font-mono text-ui text-[var(--color-text-bright)]">{capsule.template}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CPU -->
|
||||||
|
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5">
|
||||||
|
<div class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">CPU</div>
|
||||||
|
<div class="mt-0.5 flex items-baseline gap-1">
|
||||||
|
<span class="font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">{capsule.vcpus}</span>
|
||||||
|
<span class="font-mono text-label text-[var(--color-text-muted)]">vCPU{capsule.vcpus !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Memory -->
|
||||||
|
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5">
|
||||||
|
<div class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Memory</div>
|
||||||
|
<div class="mt-0.5 flex items-baseline gap-1">
|
||||||
|
<span class="font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">{capsule.memory_mb}</span>
|
||||||
|
<span class="font-mono text-label text-[var(--color-text-muted)]">MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disk -->
|
||||||
|
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5">
|
||||||
|
<div class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Disk</div>
|
||||||
|
<span class="mt-0.5 font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-muted)]">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Started -->
|
||||||
|
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5">
|
||||||
|
<div class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Started</div>
|
||||||
|
<span class="font-mono text-ui text-[var(--color-text-secondary)]">{fmtDate(capsule.started_at)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Idle Timeout -->
|
||||||
|
<div class="flex flex-1 flex-col gap-2.5 bg-[var(--color-bg-3)] px-6 py-5">
|
||||||
|
<div class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Idle Timeout</div>
|
||||||
|
<span class="font-mono text-ui text-[var(--color-text-secondary)]">{fmtTimeout(capsule.timeout_sec)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if metricsError}
|
||||||
|
<div class="flex items-center gap-3 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/8 px-4 py-3">
|
||||||
|
<svg class="shrink-0 text-[var(--color-red)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-ui text-[var(--color-red)]">Failed to load metrics: {metricsError}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if metricsAvailable}
|
||||||
|
<!-- Charts stacked — grow to fill remaining space -->
|
||||||
|
<div class="flex flex-1 flex-col gap-5 min-h-0">
|
||||||
|
|
||||||
|
<!-- CPU Usage -->
|
||||||
|
<div class="flex flex-1 flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--color-border)] px-6 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="h-2 w-2 shrink-0 rounded-full" style="background: #5a9fd4"></span>
|
||||||
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">CPU Usage</span>
|
||||||
|
</div>
|
||||||
|
{#if latestCpu !== null}
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class="metric-val font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">{latestCpu.toFixed(1)}</span>
|
||||||
|
<span class="font-mono text-label text-[var(--color-text-muted)]">%</span>
|
||||||
|
</div>
|
||||||
|
{:else if metricsLoading}
|
||||||
|
<span class="font-serif text-[2.571rem] leading-none text-[var(--color-text-muted)]">—</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1 min-h-[180px] px-5 pb-5 pt-3">
|
||||||
|
<canvas bind:this={canvasCpu}></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RAM Usage -->
|
||||||
|
<div class="flex flex-1 flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--color-border)] px-6 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="h-2 w-2 shrink-0 rounded-full" style="background: #d4a73c"></span>
|
||||||
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">RAM Usage</span>
|
||||||
|
</div>
|
||||||
|
{#if latestRamMB !== null}
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class="metric-val font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">{latestRamMB.toFixed(0)}</span>
|
||||||
|
<span class="font-mono text-label text-[var(--color-text-muted)]">MB</span>
|
||||||
|
</div>
|
||||||
|
{:else if metricsLoading}
|
||||||
|
<span class="font-serif text-[2.571rem] leading-none text-[var(--color-text-muted)]">—</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1 min-h-[180px] px-5 pb-5 pt-3">
|
||||||
|
<canvas bind:this={canvasRam}></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Stats unavailable — capsule not running/paused -->
|
||||||
|
<div class="flex items-center gap-3 rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] px-5 py-4">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-ui text-[var(--color-text-tertiary)]">
|
||||||
|
Live stats are only available for running or paused capsules —
|
||||||
|
current status: <span class="font-mono" style="color: {statusColor(capsule.status)}">{capsule.status}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@ -262,7 +262,7 @@
|
|||||||
onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }}
|
onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both">
|
<div class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
|
||||||
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">New API Key</h2>
|
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">New API Key</h2>
|
||||||
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">Name it after its environment or purpose — production, staging, CI. You can't rename it later.</p>
|
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">Name it after its environment or purpose — production, staging, CI. You can't rename it later.</p>
|
||||||
|
|
||||||
@ -323,7 +323,7 @@
|
|||||||
onkeydown={(e) => { if (e.key === 'Escape') dismissReveal(); }}
|
onkeydown={(e) => { if (e.key === 'Escape') dismissReveal(); }}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="relative w-full max-w-[480px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both">
|
<div class="relative w-full max-w-[480px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
|
||||||
<!-- Success indicator -->
|
<!-- Success indicator -->
|
||||||
<div class="mb-4 flex items-center gap-2.5">
|
<div class="mb-4 flex items-center gap-2.5">
|
||||||
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[var(--color-accent-glow-mid)]" style="animation: circlePop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both">
|
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[var(--color-accent-glow-mid)]" style="animation: circlePop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both">
|
||||||
@ -404,7 +404,7 @@
|
|||||||
onkeydown={(e) => { if (e.key === 'Escape' && !revoking) revokeTarget = null; }}
|
onkeydown={(e) => { if (e.key === 'Escape' && !revoking) revokeTarget = null; }}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both">
|
<div class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
|
||||||
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Revoke Key</h2>
|
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Revoke Key</h2>
|
||||||
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
|
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
|
||||||
Permanently revoke <span class="font-medium text-[var(--color-text-secondary)]">{revokeTarget.name || revokeTarget.id}</span>.
|
Permanently revoke <span class="font-medium text-[var(--color-text-secondary)]">{revokeTarget.name || revokeTarget.id}</span>.
|
||||||
|
|||||||
57
frontend/src/routes/dashboard/metrics/+page.svelte
Normal file
57
frontend/src/routes/dashboard/metrics/+page.svelte
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import StatsPanel from '$lib/components/StatsPanel.svelte';
|
||||||
|
import CreateCapsuleDialog from '$lib/components/CreateCapsuleDialog.svelte';
|
||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
|
||||||
|
let collapsed = $state(
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem('wrenn_sidebar_collapsed') === 'true'
|
||||||
|
: false
|
||||||
|
);
|
||||||
|
|
||||||
|
let showCreateDialog = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Wrenn — Metrics</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
<Sidebar bind:collapsed />
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<main class="flex-1 overflow-y-auto bg-[var(--color-bg-0)]">
|
||||||
|
<div class="px-7 pt-8">
|
||||||
|
<h1 class="font-serif text-page tracking-[-0.02em] text-[var(--color-text-bright)]">
|
||||||
|
Metrics
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
|
||||||
|
Resource usage and performance across all capsules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatsPanel
|
||||||
|
onlaunch={() => { showCreateDialog = true; }}
|
||||||
|
launchDisabled={!auth.teamId}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
class="flex h-7 shrink-0 items-center justify-end border-t border-[var(--color-border)] bg-[var(--color-bg-1)] px-7"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="relative flex h-[5px] w-[5px]">
|
||||||
|
<span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"></span>
|
||||||
|
<span class="relative inline-flex h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]"></span>
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-label uppercase tracking-[0.04em] text-[var(--color-text-secondary)]">All systems operational</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateCapsuleDialog
|
||||||
|
open={showCreateDialog}
|
||||||
|
onclose={() => { showCreateDialog = false; }}
|
||||||
|
/>
|
||||||
@ -250,13 +250,24 @@
|
|||||||
<!-- Filter row -->
|
<!-- Filter row -->
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<div class="flex gap-1.5">
|
<div class="flex gap-1.5">
|
||||||
{#each ([['all', 'All'], ['snapshot', 'Snapshots'], ['base', 'Images']] as const) as [val, label]}
|
{#each ([['all', 'All', ''], ['snapshot', 'Snapshots', 'var(--color-accent)'], ['base', 'Images', 'var(--color-blue)']] as const) as [val, label, color]}
|
||||||
<button
|
<button
|
||||||
onclick={() => (typeFilter = val)}
|
onclick={() => (typeFilter = val)}
|
||||||
class="rounded-full border px-3 py-1 text-meta font-medium transition-all duration-150 active:scale-95 {typeFilter === val
|
class="flex items-center gap-1.5 rounded-full border px-3 py-1 text-meta font-medium transition-all duration-150 active:scale-95
|
||||||
|
{typeFilter === val
|
||||||
|
? val === 'all'
|
||||||
? 'border-[var(--color-border-mid)] bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
|
? 'border-[var(--color-border-mid)] bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
|
||||||
|
: val === 'snapshot'
|
||||||
|
? 'border-[var(--color-accent)]/30 bg-[var(--color-accent)]/8 text-[var(--color-accent-bright)]'
|
||||||
|
: 'border-[var(--color-blue)]/30 bg-[var(--color-blue)]/8 text-[var(--color-blue)]'
|
||||||
: 'border-[var(--color-border)] bg-[var(--color-bg-3)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)]'}"
|
: 'border-[var(--color-border)] bg-[var(--color-bg-3)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)]'}"
|
||||||
>
|
>
|
||||||
|
{#if val !== 'all'}
|
||||||
|
<span
|
||||||
|
class="inline-block h-1.5 w-1.5 rounded-full"
|
||||||
|
style="background: {color}"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@ -322,14 +333,18 @@
|
|||||||
|
|
||||||
<!-- Rows -->
|
<!-- Rows -->
|
||||||
{#each filteredSnapshots as snapshot, i (snapshot.name)}
|
{#each filteredSnapshots as snapshot, i (snapshot.name)}
|
||||||
{@const stripeColor = snapshot.type === 'snapshot' ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-blue)]'}
|
{@const isSnapshot = snapshot.type === 'snapshot'}
|
||||||
|
{@const typeColor = isSnapshot ? 'var(--color-accent)' : 'var(--color-blue)'}
|
||||||
<div
|
<div
|
||||||
class="snapshot-row row-item relative grid items-center overflow-hidden border-b border-[var(--color-border)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] last:border-b-0"
|
class="snapshot-row row-item relative grid items-center overflow-hidden border-b border-[var(--color-border)] transition-colors duration-150 last:border-b-0
|
||||||
|
{isSnapshot ? 'type-snapshot' : 'type-image'}"
|
||||||
style="grid-template-columns: 2fr 1fr 0.7fr 0.9fr 0.8fr 1.3fr 140px"
|
style="grid-template-columns: 2fr 1fr 0.7fr 0.9fr 0.8fr 1.3fr 140px"
|
||||||
in:fly={{ y: 6, duration: 350, delay: i * 40, easing: cubicOut }}
|
in:fly={{ y: 6, duration: 350, delay: i * 40, easing: cubicOut }}
|
||||||
out:fly={{ x: -12, duration: 180, easing: cubicIn }}
|
out:fly={{ x: -12, duration: 180, easing: cubicIn }}
|
||||||
>
|
>
|
||||||
<div class="row-stripe pointer-events-none absolute left-0 top-0 h-full w-0.5 {stripeColor}"></div>
|
<!-- Left accent stripe -->
|
||||||
|
<div class="row-stripe pointer-events-none absolute left-0 top-0 h-full w-[3px]" style="background: {typeColor}"></div>
|
||||||
|
|
||||||
<!-- Name -->
|
<!-- Name -->
|
||||||
<div class="min-w-0 px-5 py-4">
|
<div class="min-w-0 px-5 py-4">
|
||||||
<span class="block truncate font-mono text-ui text-[var(--color-text-bright)]">{snapshot.name}</span>
|
<span class="block truncate font-mono text-ui text-[var(--color-text-bright)]">{snapshot.name}</span>
|
||||||
@ -337,8 +352,8 @@
|
|||||||
|
|
||||||
<!-- Type badge -->
|
<!-- Type badge -->
|
||||||
<div class="px-5 py-4">
|
<div class="px-5 py-4">
|
||||||
{#if snapshot.type === 'snapshot'}
|
{#if isSnapshot}
|
||||||
<span class="inline-flex items-center gap-1.5 rounded-[3px] border border-[var(--color-accent)]/20 bg-[var(--color-accent-glow-mid)] px-2 py-0.5 text-badge font-semibold uppercase tracking-[0.04em] text-[var(--color-accent-mid)]">
|
<span class="inline-flex items-center gap-1.5 rounded-[3px] border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/10 px-2.5 py-1 text-badge font-semibold uppercase tracking-[0.04em] text-[var(--color-accent-mid)]">
|
||||||
<span
|
<span
|
||||||
class="inline-block h-[5px] w-[5px] shrink-0 rounded-full bg-[var(--color-accent)]"
|
class="inline-block h-[5px] w-[5px] shrink-0 rounded-full bg-[var(--color-accent)]"
|
||||||
style="box-shadow: 0 0 6px rgba(94,140,88,0.5); animation: wrenn-glow 1.8s ease-in-out infinite"
|
style="box-shadow: 0 0 6px rgba(94,140,88,0.5); animation: wrenn-glow 1.8s ease-in-out infinite"
|
||||||
@ -346,7 +361,7 @@
|
|||||||
Snapshot
|
Snapshot
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="inline-flex items-center gap-1.5 rounded-[3px] border border-[var(--color-blue)]/20 bg-[var(--color-blue)]/10 px-2 py-0.5 text-badge font-semibold uppercase tracking-[0.04em] text-[var(--color-blue)]">
|
<span class="inline-flex items-center gap-1.5 rounded-[3px] border border-[var(--color-blue)]/25 bg-[var(--color-blue)]/10 px-2.5 py-1 text-badge font-semibold uppercase tracking-[0.04em] text-[var(--color-blue)]">
|
||||||
<span class="inline-block h-[5px] w-[5px] shrink-0 rounded-full bg-[var(--color-blue)]"></span>
|
<span class="inline-block h-[5px] w-[5px] shrink-0 rounded-full bg-[var(--color-blue)]"></span>
|
||||||
Image
|
Image
|
||||||
</span>
|
</span>
|
||||||
@ -356,7 +371,12 @@
|
|||||||
<!-- vCPUs -->
|
<!-- vCPUs -->
|
||||||
<div class="px-5 py-4">
|
<div class="px-5 py-4">
|
||||||
{#if snapshot.type === 'snapshot' && snapshot.vcpus != null}
|
{#if snapshot.type === 'snapshot' && snapshot.vcpus != null}
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={typeColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 opacity-50">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><line x1="9" y1="1" x2="9" y2="4" /><line x1="15" y1="1" x2="15" y2="4" /><line x1="9" y1="20" x2="9" y2="23" /><line x1="15" y1="20" x2="15" y2="23" /><line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" /><line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" />
|
||||||
|
</svg>
|
||||||
<span class="font-mono text-ui text-[var(--color-text-secondary)]">{snapshot.vcpus}</span>
|
<span class="font-mono text-ui text-[var(--color-text-secondary)]">{snapshot.vcpus}</span>
|
||||||
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-ui text-[var(--color-text-muted)]">—</span>
|
<span class="text-ui text-[var(--color-text-muted)]">—</span>
|
||||||
{/if}
|
{/if}
|
||||||
@ -365,7 +385,12 @@
|
|||||||
<!-- Memory -->
|
<!-- Memory -->
|
||||||
<div class="px-5 py-4">
|
<div class="px-5 py-4">
|
||||||
{#if snapshot.type === 'snapshot' && snapshot.memory_mb != null}
|
{#if snapshot.type === 'snapshot' && snapshot.memory_mb != null}
|
||||||
<span class="font-mono text-ui text-[var(--color-text-secondary)]">{snapshot.memory_mb} MB</span>
|
<span class="flex items-center gap-1.5">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={typeColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 opacity-50">
|
||||||
|
<rect x="2" y="6" width="20" height="12" rx="2" /><line x1="6" y1="12" x2="6" y2="12.01" /><line x1="10" y1="12" x2="10" y2="12.01" /><line x1="14" y1="12" x2="14" y2="12.01" /><line x1="18" y1="12" x2="18" y2="12.01" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono text-ui text-[var(--color-text-secondary)]">{snapshot.memory_mb} <span class="text-[var(--color-text-muted)]">MB</span></span>
|
||||||
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-ui text-[var(--color-text-muted)]">—</span>
|
<span class="text-ui text-[var(--color-text-muted)]">—</span>
|
||||||
{/if}
|
{/if}
|
||||||
@ -373,7 +398,7 @@
|
|||||||
|
|
||||||
<!-- Size -->
|
<!-- Size -->
|
||||||
<div class="px-5 py-4">
|
<div class="px-5 py-4">
|
||||||
<span class="font-mono text-ui text-[var(--color-text-muted)]">{formatBytes(snapshot.size_bytes)}</span>
|
<span class="font-mono text-ui text-[var(--color-text-secondary)]">{formatBytes(snapshot.size_bytes)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Created -->
|
<!-- Created -->
|
||||||
@ -694,4 +719,12 @@
|
|||||||
.snapshot-row:hover .row-stripe {
|
.snapshot-row:hover .row-stripe {
|
||||||
transform: scaleY(1);
|
transform: scaleY(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Type-tinted row hover backgrounds */
|
||||||
|
.snapshot-row.type-snapshot:hover {
|
||||||
|
background: rgba(94, 140, 88, 0.04);
|
||||||
|
}
|
||||||
|
.snapshot-row.type-image:hover {
|
||||||
|
background: rgba(90, 159, 212, 0.04);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -117,10 +117,17 @@
|
|||||||
class="relative hidden w-1/2 flex-col items-center justify-center overflow-hidden bg-[var(--color-bg-1)] lg:flex"
|
class="relative hidden w-1/2 flex-col items-center justify-center overflow-hidden bg-[var(--color-bg-1)] lg:flex"
|
||||||
onmousemove={handleMouseMove}
|
onmousemove={handleMouseMove}
|
||||||
>
|
>
|
||||||
<!-- Mouse-reactive radial glow -->
|
<!-- Dot grid texture — industrial depth layer -->
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-0 opacity-60"
|
||||||
|
style="background-image: radial-gradient(circle, rgba(94,140,88,0.09) 1px, transparent 1px); background-size: 24px 24px;"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Mouse-reactive radial glow — renders above dot grid -->
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-0"
|
class="pointer-events-none absolute inset-0"
|
||||||
style="background: radial-gradient(ellipse 55% 45% at {glowX}% {glowY}%, rgba(94, 140, 88, 0.14) 0%, transparent 70%)"
|
style="background: radial-gradient(ellipse 60% 50% at {glowX}% {glowY}%, rgba(94, 140, 88, 0.18) 0%, transparent 70%)"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
@ -137,13 +144,13 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tagline below logo -->
|
<!-- Tagline below logo — larger, more commanding -->
|
||||||
<div
|
<div
|
||||||
class="relative z-10 mt-16 max-w-[360px] text-center"
|
class="relative z-10 mt-14 max-w-[460px] text-center"
|
||||||
style="animation: fadeUp 0.35s ease 0.1s both"
|
style="animation: fadeUp 0.35s ease 0.1s both"
|
||||||
>
|
>
|
||||||
<h1
|
<h1
|
||||||
class="font-serif text-[5rem] leading-[1.1] tracking-[-0.04em] text-[var(--color-text-bright)]"
|
class="font-serif text-[6.5rem] leading-[0.95] tracking-[-0.06em] text-[var(--color-text-bright)]"
|
||||||
>
|
>
|
||||||
Scale Up.<br /><span class="text-[var(--color-accent-bright)]">Spin Out.</span>
|
Scale Up.<br /><span class="text-[var(--color-accent-bright)]">Spin Out.</span>
|
||||||
</h1>
|
</h1>
|
||||||
@ -151,7 +158,7 @@
|
|||||||
|
|
||||||
<!-- Sub-tagline -->
|
<!-- Sub-tagline -->
|
||||||
<p
|
<p
|
||||||
class="relative z-10 mt-12 font-mono text-ui uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]"
|
class="relative z-10 mt-10 font-mono text-ui uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]"
|
||||||
style="animation: fadeUp 0.35s ease 0.2s both"
|
style="animation: fadeUp 0.35s ease 0.2s both"
|
||||||
>
|
>
|
||||||
Isolated VMs. Milliseconds to live.
|
Isolated VMs. Milliseconds to live.
|
||||||
|
|||||||
@ -15,6 +15,7 @@ mount -t tmpfs tmpfs /tmp 2>/dev/null || true
|
|||||||
mount -t tmpfs tmpfs /run 2>/dev/null || true
|
mount -t tmpfs tmpfs /run 2>/dev/null || true
|
||||||
mkdir -p /sys/fs/cgroup
|
mkdir -p /sys/fs/cgroup
|
||||||
mount -t cgroup2 cgroup2 /sys/fs/cgroup 2>/dev/null || true
|
mount -t cgroup2 cgroup2 /sys/fs/cgroup 2>/dev/null || true
|
||||||
|
echo "+cpu +memory +io" > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true
|
||||||
|
|
||||||
# Set hostname
|
# Set hostname
|
||||||
hostname sandbox
|
hostname sandbox
|
||||||
|
|||||||
148
internal/api/handlers_metrics.go
Normal file
148
internal/api/handlers_metrics.go
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/auth"
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/lifecycle"
|
||||||
|
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sandboxMetricsHandler struct {
|
||||||
|
db *db.Queries
|
||||||
|
pool *lifecycle.HostClientPool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSandboxMetricsHandler(db *db.Queries, pool *lifecycle.HostClientPool) *sandboxMetricsHandler {
|
||||||
|
return &sandboxMetricsHandler{db: db, pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
type metricPointResponse struct {
|
||||||
|
TimestampUnix int64 `json:"timestamp_unix"`
|
||||||
|
CPUPct float64 `json:"cpu_pct"`
|
||||||
|
MemBytes int64 `json:"mem_bytes"`
|
||||||
|
DiskBytes int64 `json:"disk_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type metricsResponse struct {
|
||||||
|
SandboxID string `json:"sandbox_id"`
|
||||||
|
Range string `json:"range"`
|
||||||
|
Points []metricPointResponse `json:"points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetrics handles GET /v1/sandboxes/{id}/metrics?range=10m|2h|24h.
|
||||||
|
func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sandboxID := chi.URLParam(r, "id")
|
||||||
|
ctx := r.Context()
|
||||||
|
ac := auth.MustFromContext(ctx)
|
||||||
|
|
||||||
|
rangeTier := r.URL.Query().Get("range")
|
||||||
|
if rangeTier == "" {
|
||||||
|
rangeTier = "10m"
|
||||||
|
}
|
||||||
|
validRanges := map[string]bool{"5m": true, "10m": true, "1h": true, "2h": true, "6h": true, "12h": true, "24h": true}
|
||||||
|
if !validRanges[rangeTier] {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "range must be one of: 5m, 10m, 1h, 2h, 6h, 12h, 24h")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sb.Status {
|
||||||
|
case "running":
|
||||||
|
h.getFromAgent(w, r, sandboxID, rangeTier, sb.HostID)
|
||||||
|
case "paused":
|
||||||
|
h.getFromDB(ctx, w, sandboxID, rangeTier)
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "metrics not available for sandbox in state: "+sb.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *sandboxMetricsHandler) getFromAgent(w http.ResponseWriter, r *http.Request, sandboxID, rangeTier, hostID string) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
agent, err := agentForHost(ctx, h.db, h.pool, hostID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := agent.GetSandboxMetrics(ctx, connect.NewRequest(&pb.GetSandboxMetricsRequest{
|
||||||
|
SandboxId: sandboxID,
|
||||||
|
Range: rangeTier,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
status, code, msg := agentErrToHTTP(err)
|
||||||
|
writeError(w, status, code, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
points := make([]metricPointResponse, len(resp.Msg.Points))
|
||||||
|
for i, p := range resp.Msg.Points {
|
||||||
|
points[i] = metricPointResponse{
|
||||||
|
TimestampUnix: p.TimestampUnix,
|
||||||
|
CPUPct: p.CpuPct,
|
||||||
|
MemBytes: p.MemBytes,
|
||||||
|
DiskBytes: p.DiskBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, metricsResponse{
|
||||||
|
SandboxID: sandboxID,
|
||||||
|
Range: rangeTier,
|
||||||
|
Points: points,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// rangeToDB maps a user-facing range filter to the DB tier and cutoff duration.
|
||||||
|
var rangeToDB = map[string]struct {
|
||||||
|
tier string
|
||||||
|
cutoff time.Duration
|
||||||
|
}{
|
||||||
|
"5m": {"10m", 5 * time.Minute},
|
||||||
|
"10m": {"10m", 10 * time.Minute},
|
||||||
|
"1h": {"2h", 1 * time.Hour},
|
||||||
|
"2h": {"2h", 2 * time.Hour},
|
||||||
|
"6h": {"24h", 6 * time.Hour},
|
||||||
|
"12h": {"24h", 12 * time.Hour},
|
||||||
|
"24h": {"24h", 24 * time.Hour},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *sandboxMetricsHandler) getFromDB(ctx context.Context, w http.ResponseWriter, sandboxID, rangeTier string) {
|
||||||
|
mapping := rangeToDB[rangeTier]
|
||||||
|
rows, err := h.db.GetSandboxMetricPoints(ctx, db.GetSandboxMetricPointsParams{
|
||||||
|
SandboxID: sandboxID,
|
||||||
|
Tier: mapping.tier,
|
||||||
|
Ts: time.Now().Add(-mapping.cutoff).Unix(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "failed to read metrics")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
points := make([]metricPointResponse, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
points[i] = metricPointResponse{
|
||||||
|
TimestampUnix: row.Ts,
|
||||||
|
CPUPct: row.CpuPct,
|
||||||
|
MemBytes: row.MemBytes,
|
||||||
|
DiskBytes: row.DiskBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, metricsResponse{
|
||||||
|
SandboxID: sandboxID,
|
||||||
|
Range: rangeTier,
|
||||||
|
Points: points,
|
||||||
|
})
|
||||||
|
}
|
||||||
95
internal/api/handlers_stats.go
Normal file
95
internal/api/handlers_stats.go
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/auth"
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type statsHandler struct {
|
||||||
|
svc *service.StatsService
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStatsHandler(svc *service.StatsService) *statsHandler {
|
||||||
|
return &statsHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
type statsCurrentResponse struct {
|
||||||
|
RunningCount int32 `json:"running_count"`
|
||||||
|
VCPUsReserved int32 `json:"vcpus_reserved"`
|
||||||
|
MemoryMBReserved int32 `json:"memory_mb_reserved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statsPeaksResponse struct {
|
||||||
|
RunningCount int32 `json:"running_count"`
|
||||||
|
VCPUs int32 `json:"vcpus"`
|
||||||
|
MemoryMB int32 `json:"memory_mb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statsSeriesResponse struct {
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
Running []int32 `json:"running"`
|
||||||
|
VCPUs []int32 `json:"vcpus"`
|
||||||
|
MemoryMB []int32 `json:"memory_mb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statsResponse struct {
|
||||||
|
Range string `json:"range"`
|
||||||
|
Current statsCurrentResponse `json:"current"`
|
||||||
|
Peaks statsPeaksResponse `json:"peaks"`
|
||||||
|
Series statsSeriesResponse `json:"series"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats handles GET /v1/sandboxes/stats?range=5m|1h|6h|24h|30d
|
||||||
|
func (h *statsHandler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ac := auth.MustFromContext(r.Context())
|
||||||
|
|
||||||
|
rangeParam := r.URL.Query().Get("range")
|
||||||
|
if rangeParam == "" {
|
||||||
|
rangeParam = string(service.Range1h)
|
||||||
|
}
|
||||||
|
tr := service.TimeRange(rangeParam)
|
||||||
|
if !service.ValidRange(tr) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "range must be one of: 5m, 1h, 6h, 24h, 30d")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current, peaks, series, err := h.svc.GetStats(r.Context(), ac.TeamID, tr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stats handler: get stats failed", "team_id", ac.TeamID, "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "failed to retrieve stats")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := statsResponse{
|
||||||
|
Range: rangeParam,
|
||||||
|
Current: statsCurrentResponse{
|
||||||
|
RunningCount: current.RunningCount,
|
||||||
|
VCPUsReserved: current.VCPUsReserved,
|
||||||
|
MemoryMBReserved: current.MemoryMBReserved,
|
||||||
|
},
|
||||||
|
Peaks: statsPeaksResponse{
|
||||||
|
RunningCount: peaks.RunningCount,
|
||||||
|
VCPUs: peaks.VCPUs,
|
||||||
|
MemoryMB: peaks.MemoryMB,
|
||||||
|
},
|
||||||
|
Series: statsSeriesResponse{
|
||||||
|
Labels: make([]string, len(series)),
|
||||||
|
Running: make([]int32, len(series)),
|
||||||
|
VCPUs: make([]int32, len(series)),
|
||||||
|
MemoryMB: make([]int32, len(series)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, pt := range series {
|
||||||
|
resp.Series.Labels[i] = pt.Bucket.UTC().Format(time.RFC3339)
|
||||||
|
resp.Series.Running[i] = pt.RunningCount
|
||||||
|
resp.Series.VCPUs[i] = pt.VCPUsReserved
|
||||||
|
resp.Series.MemoryMB[i] = pt.MemoryMBReserved
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
@ -4,34 +4,38 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.omukk.dev/wrenn/sandbox/internal/auth"
|
"git.omukk.dev/wrenn/sandbox/internal/auth"
|
||||||
"git.omukk.dev/wrenn/sandbox/internal/service"
|
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type usersHandler struct {
|
type usersHandler struct {
|
||||||
svc *service.TeamService
|
db *db.Queries
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUsersHandler(svc *service.TeamService) *usersHandler {
|
func newUsersHandler(db *db.Queries) *usersHandler {
|
||||||
return &usersHandler{svc: svc}
|
return &usersHandler{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search handles GET /v1/users/search?email=<prefix>
|
// Search handles GET /v1/users/search?email=<prefix>
|
||||||
// Returns up to 10 users whose email starts with the given prefix.
|
// Returns up to 10 users whose email starts with the given prefix.
|
||||||
// The prefix must be at least 3 characters long.
|
// The prefix must be at least 3 characters long and contain "@".
|
||||||
func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) {
|
func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||||
auth.MustFromContext(r.Context()) // ensure authenticated
|
auth.MustFromContext(r.Context()) // ensure authenticated
|
||||||
|
|
||||||
prefix := strings.TrimSpace(r.URL.Query().Get("email"))
|
prefix := strings.TrimSpace(r.URL.Query().Get("email"))
|
||||||
if len(prefix) < 3 {
|
if len(prefix) < 3 || !strings.Contains(prefix, "@") {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "email prefix must be at least 3 characters")
|
writeError(w, http.StatusBadRequest, "invalid_request", "email prefix must be at least 3 characters and contain '@'")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := h.svc.SearchUsersByEmailPrefix(r.Context(), prefix)
|
// Escape LIKE metacharacters to prevent pattern injection.
|
||||||
|
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(prefix)
|
||||||
|
|
||||||
|
results, err := h.db.SearchUsersByEmailPrefix(r.Context(), pgtype.Text{String: escaped, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status, code, msg := serviceErrToHTTP(err)
|
writeError(w, http.StatusInternalServerError, "internal", "search failed")
|
||||||
writeError(w, status, code, msg)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
internal/api/metrics_sampler.go
Normal file
68
internal/api/metrics_sampler.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MetricsSampler records per-team sandbox resource usage to
|
||||||
|
// sandbox_metrics_snapshots every interval. It also prunes rows older than
|
||||||
|
// 60 days on each tick to keep the table bounded.
|
||||||
|
type MetricsSampler struct {
|
||||||
|
db *db.Queries
|
||||||
|
interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMetricsSampler creates a MetricsSampler.
|
||||||
|
func NewMetricsSampler(queries *db.Queries, interval time.Duration) *MetricsSampler {
|
||||||
|
return &MetricsSampler{db: queries, interval: interval}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start runs the sampler loop until the context is cancelled.
|
||||||
|
func (s *MetricsSampler) Start(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(s.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Sample immediately on startup.
|
||||||
|
s.run(ctx)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.run(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MetricsSampler) run(ctx context.Context) {
|
||||||
|
s.prune(ctx)
|
||||||
|
if err := s.sample(ctx); err != nil {
|
||||||
|
slog.Warn("metrics sampler: sample failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MetricsSampler) sample(ctx context.Context) error {
|
||||||
|
rows, err := s.db.SampleSandboxMetrics(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := s.db.InsertMetricsSnapshot(ctx, db.InsertMetricsSnapshotParams(row)); err != nil {
|
||||||
|
slog.Warn("metrics sampler: insert snapshot failed", "team_id", row.TeamID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MetricsSampler) prune(ctx context.Context) {
|
||||||
|
if err := s.db.PruneOldMetrics(ctx); err != nil {
|
||||||
|
slog.Warn("metrics sampler: prune failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -613,6 +613,32 @@ paths:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/Sandbox"
|
$ref: "#/components/schemas/Sandbox"
|
||||||
|
|
||||||
|
/v1/sandboxes/stats:
|
||||||
|
get:
|
||||||
|
summary: Get sandbox usage stats for your team
|
||||||
|
operationId: getSandboxStats
|
||||||
|
tags: [sandboxes]
|
||||||
|
security:
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: range
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [5m, 1h, 6h, 24h, 30d]
|
||||||
|
default: 1h
|
||||||
|
description: Time window for the time-series data.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Sandbox stats for the team
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SandboxStats"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
|
||||||
/v1/sandboxes/{id}:
|
/v1/sandboxes/{id}:
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@ -725,6 +751,60 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
|
/v1/sandboxes/{id}/metrics:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
get:
|
||||||
|
summary: Get per-sandbox resource metrics
|
||||||
|
operationId: getSandboxMetrics
|
||||||
|
tags: [sandboxes]
|
||||||
|
security:
|
||||||
|
- apiKeyAuth: []
|
||||||
|
- bearerAuth: []
|
||||||
|
description: |
|
||||||
|
Returns time-series CPU, memory, and disk metrics for a sandbox.
|
||||||
|
Three tiers are available with different granularity and retention:
|
||||||
|
- `10m`: 500ms samples, last 10 minutes
|
||||||
|
- `2h`: 30-second averages, last 2 hours
|
||||||
|
- `24h`: 5-minute averages, last 24 hours
|
||||||
|
|
||||||
|
For running sandboxes, data comes from the host agent's in-memory
|
||||||
|
ring buffer. For paused sandboxes, data is read from persisted
|
||||||
|
snapshots in the database. Stopped/destroyed sandboxes return 404.
|
||||||
|
parameters:
|
||||||
|
- name: range
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"]
|
||||||
|
default: "10m"
|
||||||
|
description: Time range filter to query
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Metrics retrieved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SandboxMetrics"
|
||||||
|
"400":
|
||||||
|
description: Invalid range parameter
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
"404":
|
||||||
|
description: Sandbox not found or metrics not available
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
/v1/sandboxes/{id}/pause:
|
/v1/sandboxes/{id}/pause:
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@ -1578,6 +1658,57 @@ components:
|
|||||||
after this duration of inactivity (no exec or ping). 0 means
|
after this duration of inactivity (no exec or ping). 0 means
|
||||||
no auto-pause.
|
no auto-pause.
|
||||||
|
|
||||||
|
SandboxStats:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
range:
|
||||||
|
type: string
|
||||||
|
enum: [5m, 1h, 6h, 24h, 30d]
|
||||||
|
current:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
running_count:
|
||||||
|
type: integer
|
||||||
|
vcpus_reserved:
|
||||||
|
type: integer
|
||||||
|
memory_mb_reserved:
|
||||||
|
type: integer
|
||||||
|
sampled_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
peaks:
|
||||||
|
type: object
|
||||||
|
description: Maximum values over the last 30 days.
|
||||||
|
properties:
|
||||||
|
running_count:
|
||||||
|
type: integer
|
||||||
|
vcpus:
|
||||||
|
type: integer
|
||||||
|
memory_mb:
|
||||||
|
type: integer
|
||||||
|
series:
|
||||||
|
type: object
|
||||||
|
description: Parallel arrays for chart rendering.
|
||||||
|
properties:
|
||||||
|
labels:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
running:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
vcpus:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
memory_mb:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
|
||||||
Sandbox:
|
Sandbox:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -1904,6 +2035,38 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/TeamMember"
|
$ref: "#/components/schemas/TeamMember"
|
||||||
|
|
||||||
|
SandboxMetrics:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
sandbox_id:
|
||||||
|
type: string
|
||||||
|
range:
|
||||||
|
type: string
|
||||||
|
enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"]
|
||||||
|
points:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/MetricPoint"
|
||||||
|
|
||||||
|
MetricPoint:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
timestamp_unix:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
cpu_pct:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
description: "CPU utilization percentage (0-100), normalized to vCPU count"
|
||||||
|
mem_bytes:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: "Resident memory in bytes (VmRSS of Firecracker process)"
|
||||||
|
disk_bytes:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: "Allocated disk bytes for the CoW sparse file"
|
||||||
|
|
||||||
Error:
|
Error:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@ -46,6 +46,7 @@ func New(
|
|||||||
hostSvc := &service.HostService{DB: queries, Redis: rdb, JWT: jwtSecret, Pool: pool}
|
hostSvc := &service.HostService{DB: queries, Redis: rdb, JWT: jwtSecret, Pool: pool}
|
||||||
teamSvc := &service.TeamService{DB: queries, Pool: pgPool, HostPool: pool}
|
teamSvc := &service.TeamService{DB: queries, Pool: pgPool, HostPool: pool}
|
||||||
auditSvc := &service.AuditService{DB: queries}
|
auditSvc := &service.AuditService{DB: queries}
|
||||||
|
statsSvc := &service.StatsService{DB: queries, Pool: pgPool}
|
||||||
|
|
||||||
al := audit.New(queries)
|
al := audit.New(queries)
|
||||||
|
|
||||||
@ -60,8 +61,10 @@ func New(
|
|||||||
apiKeys := newAPIKeyHandler(apiKeySvc, al)
|
apiKeys := newAPIKeyHandler(apiKeySvc, al)
|
||||||
hostH := newHostHandler(hostSvc, queries, al)
|
hostH := newHostHandler(hostSvc, queries, al)
|
||||||
teamH := newTeamHandler(teamSvc, al)
|
teamH := newTeamHandler(teamSvc, al)
|
||||||
usersH := newUsersHandler(teamSvc)
|
usersH := newUsersHandler(queries)
|
||||||
auditH := newAuditHandler(auditSvc)
|
auditH := newAuditHandler(auditSvc)
|
||||||
|
statsH := newStatsHandler(statsSvc)
|
||||||
|
metricsH := newSandboxMetricsHandler(queries, pool)
|
||||||
|
|
||||||
// OpenAPI spec and docs.
|
// OpenAPI spec and docs.
|
||||||
r.Get("/openapi.yaml", serveOpenAPI)
|
r.Get("/openapi.yaml", serveOpenAPI)
|
||||||
@ -109,6 +112,7 @@ func New(
|
|||||||
r.Use(requireAPIKeyOrJWT(queries, jwtSecret))
|
r.Use(requireAPIKeyOrJWT(queries, jwtSecret))
|
||||||
r.Post("/", sandbox.Create)
|
r.Post("/", sandbox.Create)
|
||||||
r.Get("/", sandbox.List)
|
r.Get("/", sandbox.List)
|
||||||
|
r.Get("/stats", statsH.GetStats)
|
||||||
|
|
||||||
r.Route("/{id}", func(r chi.Router) {
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
r.Get("/", sandbox.Get)
|
r.Get("/", sandbox.Get)
|
||||||
@ -122,6 +126,7 @@ func New(
|
|||||||
r.Post("/files/read", files.Download)
|
r.Post("/files/read", files.Download)
|
||||||
r.Post("/files/stream/write", filesStream.StreamUpload)
|
r.Post("/files/stream/write", filesStream.StreamUpload)
|
||||||
r.Post("/files/stream/read", filesStream.StreamDownload)
|
r.Post("/files/stream/read", filesStream.StreamDownload)
|
||||||
|
r.Get("/metrics", metricsH.GetMetrics)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
248
internal/db/metrics.sql.go
Normal file
248
internal/db/metrics.sql.go
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
// source: metrics.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteSandboxMetricPoints = `-- name: DeleteSandboxMetricPoints :exec
|
||||||
|
DELETE FROM sandbox_metric_points
|
||||||
|
WHERE sandbox_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteSandboxMetricPoints(ctx context.Context, sandboxID string) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteSandboxMetricPoints, sandboxID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSandboxMetricPointsByTier = `-- name: DeleteSandboxMetricPointsByTier :exec
|
||||||
|
DELETE FROM sandbox_metric_points
|
||||||
|
WHERE sandbox_id = $1 AND tier = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteSandboxMetricPointsByTierParams struct {
|
||||||
|
SandboxID string `json:"sandbox_id"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteSandboxMetricPointsByTier(ctx context.Context, arg DeleteSandboxMetricPointsByTierParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteSandboxMetricPointsByTier, arg.SandboxID, arg.Tier)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLiveMetrics = `-- name: GetLiveMetrics :one
|
||||||
|
SELECT
|
||||||
|
(COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count,
|
||||||
|
(COALESCE(SUM(vcpus) FILTER (WHERE status IN ('running', 'starting')), 0))::INTEGER AS vcpus_reserved,
|
||||||
|
(COALESCE(SUM(memory_mb) FILTER (WHERE status IN ('running', 'starting')), 0)
|
||||||
|
+ COALESCE(SUM(CEIL(memory_mb::NUMERIC / 2)) FILTER (WHERE status = 'paused'), 0))::INTEGER AS memory_mb_reserved
|
||||||
|
FROM sandboxes
|
||||||
|
WHERE team_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetLiveMetricsRow struct {
|
||||||
|
RunningCount int32 `json:"running_count"`
|
||||||
|
VcpusReserved int32 `json:"vcpus_reserved"`
|
||||||
|
MemoryMbReserved int32 `json:"memory_mb_reserved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads directly from sandboxes for accurate real-time current values.
|
||||||
|
// CPU reserved = running + starting only (paused VMs release CPU).
|
||||||
|
// RAM reserved = running + starting + sum(ceil(each_paused/2)) (per-VM ceiling).
|
||||||
|
func (q *Queries) GetLiveMetrics(ctx context.Context, teamID string) (GetLiveMetricsRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getLiveMetrics, teamID)
|
||||||
|
var i GetLiveMetricsRow
|
||||||
|
err := row.Scan(&i.RunningCount, &i.VcpusReserved, &i.MemoryMbReserved)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPeakMetrics = `-- name: GetPeakMetrics :one
|
||||||
|
SELECT
|
||||||
|
COALESCE(MAX(running_count), 0)::INTEGER AS peak_running_count,
|
||||||
|
COALESCE(MAX(vcpus_reserved), 0)::INTEGER AS peak_vcpus,
|
||||||
|
COALESCE(MAX(memory_mb_reserved), 0)::INTEGER AS peak_memory_mb
|
||||||
|
FROM sandbox_metrics_snapshots
|
||||||
|
WHERE team_id = $1
|
||||||
|
AND sampled_at > NOW() - INTERVAL '30 days'
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetPeakMetricsRow struct {
|
||||||
|
PeakRunningCount int32 `json:"peak_running_count"`
|
||||||
|
PeakVcpus int32 `json:"peak_vcpus"`
|
||||||
|
PeakMemoryMb int32 `json:"peak_memory_mb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetPeakMetrics(ctx context.Context, teamID string) (GetPeakMetricsRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getPeakMetrics, teamID)
|
||||||
|
var i GetPeakMetricsRow
|
||||||
|
err := row.Scan(&i.PeakRunningCount, &i.PeakVcpus, &i.PeakMemoryMb)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSandboxMetricPoints = `-- name: GetSandboxMetricPoints :many
|
||||||
|
SELECT ts, cpu_pct, mem_bytes, disk_bytes
|
||||||
|
FROM sandbox_metric_points
|
||||||
|
WHERE sandbox_id = $1 AND tier = $2 AND ts >= $3
|
||||||
|
ORDER BY ts ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetSandboxMetricPointsParams struct {
|
||||||
|
SandboxID string `json:"sandbox_id"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
Ts int64 `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetSandboxMetricPointsRow struct {
|
||||||
|
Ts int64 `json:"ts"`
|
||||||
|
CpuPct float64 `json:"cpu_pct"`
|
||||||
|
MemBytes int64 `json:"mem_bytes"`
|
||||||
|
DiskBytes int64 `json:"disk_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetSandboxMetricPoints(ctx context.Context, arg GetSandboxMetricPointsParams) ([]GetSandboxMetricPointsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getSandboxMetricPoints, arg.SandboxID, arg.Tier, arg.Ts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetSandboxMetricPointsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetSandboxMetricPointsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Ts,
|
||||||
|
&i.CpuPct,
|
||||||
|
&i.MemBytes,
|
||||||
|
&i.DiskBytes,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertMetricsSnapshot = `-- name: InsertMetricsSnapshot :exec
|
||||||
|
INSERT INTO sandbox_metrics_snapshots (team_id, running_count, vcpus_reserved, memory_mb_reserved)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertMetricsSnapshotParams struct {
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
RunningCount int32 `json:"running_count"`
|
||||||
|
VcpusReserved int32 `json:"vcpus_reserved"`
|
||||||
|
MemoryMbReserved int32 `json:"memory_mb_reserved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertMetricsSnapshot(ctx context.Context, arg InsertMetricsSnapshotParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, insertMetricsSnapshot,
|
||||||
|
arg.TeamID,
|
||||||
|
arg.RunningCount,
|
||||||
|
arg.VcpusReserved,
|
||||||
|
arg.MemoryMbReserved,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSandboxMetricPoint = `-- name: InsertSandboxMetricPoint :exec
|
||||||
|
INSERT INTO sandbox_metric_points (sandbox_id, tier, ts, cpu_pct, mem_bytes, disk_bytes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (sandbox_id, tier, ts) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertSandboxMetricPointParams struct {
|
||||||
|
SandboxID string `json:"sandbox_id"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
Ts int64 `json:"ts"`
|
||||||
|
CpuPct float64 `json:"cpu_pct"`
|
||||||
|
MemBytes int64 `json:"mem_bytes"`
|
||||||
|
DiskBytes int64 `json:"disk_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertSandboxMetricPoint(ctx context.Context, arg InsertSandboxMetricPointParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, insertSandboxMetricPoint,
|
||||||
|
arg.SandboxID,
|
||||||
|
arg.Tier,
|
||||||
|
arg.Ts,
|
||||||
|
arg.CpuPct,
|
||||||
|
arg.MemBytes,
|
||||||
|
arg.DiskBytes,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruneOldMetrics = `-- name: PruneOldMetrics :exec
|
||||||
|
DELETE FROM sandbox_metrics_snapshots
|
||||||
|
WHERE sampled_at < NOW() - INTERVAL '60 days'
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) PruneOldMetrics(ctx context.Context) error {
|
||||||
|
_, err := q.db.Exec(ctx, pruneOldMetrics)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruneSandboxMetricPoints = `-- name: PruneSandboxMetricPoints :exec
|
||||||
|
DELETE FROM sandbox_metric_points
|
||||||
|
WHERE ts < EXTRACT(EPOCH FROM NOW() - INTERVAL '30 days')::BIGINT
|
||||||
|
`
|
||||||
|
|
||||||
|
// Remove metric points older than 30 days for destroyed sandboxes.
|
||||||
|
func (q *Queries) PruneSandboxMetricPoints(ctx context.Context) error {
|
||||||
|
_, err := q.db.Exec(ctx, pruneSandboxMetricPoints)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleSandboxMetrics = `-- name: SampleSandboxMetrics :many
|
||||||
|
SELECT
|
||||||
|
team_id,
|
||||||
|
(COUNT(*) FILTER (WHERE status IN ('running', 'starting')))::INTEGER AS running_count,
|
||||||
|
(COALESCE(SUM(vcpus) FILTER (WHERE status IN ('running', 'starting')), 0))::INTEGER AS vcpus_reserved,
|
||||||
|
(COALESCE(SUM(memory_mb) FILTER (WHERE status IN ('running', 'starting')), 0)
|
||||||
|
+ COALESCE(SUM(CEIL(memory_mb::NUMERIC / 2)) FILTER (WHERE status = 'paused'), 0))::INTEGER AS memory_mb_reserved
|
||||||
|
FROM sandboxes
|
||||||
|
GROUP BY team_id
|
||||||
|
`
|
||||||
|
|
||||||
|
type SampleSandboxMetricsRow struct {
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
RunningCount int32 `json:"running_count"`
|
||||||
|
VcpusReserved int32 `json:"vcpus_reserved"`
|
||||||
|
MemoryMbReserved int32 `json:"memory_mb_reserved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregates per-team resource usage from the live sandboxes table.
|
||||||
|
// Groups by all teams that have any sandbox row (including stopped) so that
|
||||||
|
// zero-value snapshots are recorded when all capsules are stopped, keeping the
|
||||||
|
// time-series charts continuous rather than trailing off into empty space.
|
||||||
|
// CPU reserved = running + starting only (paused VMs release CPU).
|
||||||
|
// RAM reserved = running + starting + sum(ceil(each_paused/2)) (per-VM ceiling).
|
||||||
|
func (q *Queries) SampleSandboxMetrics(ctx context.Context) ([]SampleSandboxMetricsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, sampleSandboxMetrics)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []SampleSandboxMetricsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i SampleSandboxMetricsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.TeamID,
|
||||||
|
&i.RunningCount,
|
||||||
|
&i.VcpusReserved,
|
||||||
|
&i.MemoryMbReserved,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@ -99,6 +99,24 @@ type Sandbox struct {
|
|||||||
TeamID string `json:"team_id"`
|
TeamID string `json:"team_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SandboxMetricPoint struct {
|
||||||
|
SandboxID string `json:"sandbox_id"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
Ts int64 `json:"ts"`
|
||||||
|
CpuPct float64 `json:"cpu_pct"`
|
||||||
|
MemBytes int64 `json:"mem_bytes"`
|
||||||
|
DiskBytes int64 `json:"disk_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SandboxMetricsSnapshot struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
SampledAt pgtype.Timestamptz `json:"sampled_at"`
|
||||||
|
RunningCount int32 `json:"running_count"`
|
||||||
|
VcpusReserved int32 `json:"vcpus_reserved"`
|
||||||
|
MemoryMbReserved int32 `json:"memory_mb_reserved"`
|
||||||
|
}
|
||||||
|
|
||||||
type Team struct {
|
type Team struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@ -426,3 +426,55 @@ func (s *Server) Terminate(
|
|||||||
}
|
}
|
||||||
return connect.NewResponse(&pb.TerminateResponse{}), nil
|
return connect.NewResponse(&pb.TerminateResponse{}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetSandboxMetrics(
|
||||||
|
_ context.Context,
|
||||||
|
req *connect.Request[pb.GetSandboxMetricsRequest],
|
||||||
|
) (*connect.Response[pb.GetSandboxMetricsResponse], error) {
|
||||||
|
msg := req.Msg
|
||||||
|
|
||||||
|
points, err := s.mgr.GetMetrics(msg.SandboxId, msg.Range)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "invalid range") {
|
||||||
|
return nil, connect.NewError(connect.CodeInvalidArgument, err)
|
||||||
|
}
|
||||||
|
return nil, connect.NewError(connect.CodeInternal, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return connect.NewResponse(&pb.GetSandboxMetricsResponse{Points: metricPointsToPB(points)}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) FlushSandboxMetrics(
|
||||||
|
_ context.Context,
|
||||||
|
req *connect.Request[pb.FlushSandboxMetricsRequest],
|
||||||
|
) (*connect.Response[pb.FlushSandboxMetricsResponse], error) {
|
||||||
|
pts10m, pts2h, pts24h, err := s.mgr.FlushMetrics(req.Msg.SandboxId)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||||
|
}
|
||||||
|
return nil, connect.NewError(connect.CodeInternal, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return connect.NewResponse(&pb.FlushSandboxMetricsResponse{
|
||||||
|
Points_10M: metricPointsToPB(pts10m),
|
||||||
|
Points_2H: metricPointsToPB(pts2h),
|
||||||
|
Points_24H: metricPointsToPB(pts24h),
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func metricPointsToPB(pts []sandbox.MetricPoint) []*pb.MetricPoint {
|
||||||
|
out := make([]*pb.MetricPoint, len(pts))
|
||||||
|
for i, p := range pts {
|
||||||
|
out[i] = &pb.MetricPoint{
|
||||||
|
TimestampUnix: p.Timestamp.Unix(),
|
||||||
|
CpuPct: p.CPUPct,
|
||||||
|
MemBytes: p.MemBytes,
|
||||||
|
DiskBytes: p.DiskBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@ -58,6 +58,12 @@ type sandboxState struct {
|
|||||||
// sandbox was restored. Non-nil means re-pause should use "Diff" snapshot
|
// sandbox was restored. Non-nil means re-pause should use "Diff" snapshot
|
||||||
// type instead of "Full", avoiding the UFFD fault-in storm.
|
// type instead of "Full", avoiding the UFFD fault-in storm.
|
||||||
parent *snapshotParent
|
parent *snapshotParent
|
||||||
|
|
||||||
|
// Metrics sampling state.
|
||||||
|
fcPID int // Firecracker process PID (child of unshare wrapper)
|
||||||
|
ring *metricsRing // tiered ring buffers for CPU/mem/disk metrics
|
||||||
|
samplerCancel context.CancelFunc // cancels the per-sandbox sampling goroutine
|
||||||
|
samplerDone chan struct{} // closed when the sampling goroutine exits
|
||||||
}
|
}
|
||||||
|
|
||||||
// snapshotParent stores the previous generation's snapshot state so that
|
// snapshotParent stores the previous generation's snapshot state so that
|
||||||
@ -232,6 +238,8 @@ func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus,
|
|||||||
m.boxes[sandboxID] = sb
|
m.boxes[sandboxID] = sb
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.startSampler(sb)
|
||||||
|
|
||||||
slog.Info("sandbox created",
|
slog.Info("sandbox created",
|
||||||
"id", sandboxID,
|
"id", sandboxID,
|
||||||
"template", template,
|
"template", template,
|
||||||
@ -265,6 +273,7 @@ func (m *Manager) Destroy(ctx context.Context, sandboxID string) error {
|
|||||||
|
|
||||||
// cleanup tears down all resources for a sandbox.
|
// cleanup tears down all resources for a sandbox.
|
||||||
func (m *Manager) cleanup(ctx context.Context, sb *sandboxState) {
|
func (m *Manager) cleanup(ctx context.Context, sb *sandboxState) {
|
||||||
|
m.stopSampler(sb)
|
||||||
if err := m.vm.Destroy(ctx, sb.ID); err != nil {
|
if err := m.vm.Destroy(ctx, sb.ID); err != nil {
|
||||||
slog.Warn("vm destroy error", "id", sb.ID, "error", err)
|
slog.Warn("vm destroy error", "id", sb.ID, "error", err)
|
||||||
}
|
}
|
||||||
@ -668,6 +677,8 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
|
|||||||
m.boxes[sandboxID] = sb
|
m.boxes[sandboxID] = sb
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.startSampler(sb)
|
||||||
|
|
||||||
// Don't delete snapshot dir — diff files are needed for re-pause.
|
// Don't delete snapshot dir — diff files are needed for re-pause.
|
||||||
// The CoW file was already moved out. The dir will be cleaned up
|
// The CoW file was already moved out. The dir will be cleaned up
|
||||||
// on destroy or overwritten on re-pause.
|
// on destroy or overwritten on re-pause.
|
||||||
@ -987,6 +998,8 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam
|
|||||||
m.boxes[sandboxID] = sb
|
m.boxes[sandboxID] = sb
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.startSampler(sb)
|
||||||
|
|
||||||
slog.Info("sandbox created from snapshot",
|
slog.Info("sandbox created from snapshot",
|
||||||
"id", sandboxID,
|
"id", sandboxID,
|
||||||
"snapshot", snapshotName,
|
"snapshot", snapshotName,
|
||||||
@ -1213,6 +1226,177 @@ func warnErr(msg string, id string, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
v, ok := m.vm.Get(sb.ID)
|
||||||
|
if !ok {
|
||||||
|
slog.Warn("metrics: VM not found, skipping sampler", "id", sb.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
sb.samplerCancel = cancel
|
||||||
|
sb.samplerDone = make(chan struct{})
|
||||||
|
|
||||||
|
// Read initial CPU counters for delta calculation.
|
||||||
|
// Passed to goroutine as local state — no shared mutation.
|
||||||
|
initialCPU, err := readCPUStat(fcPID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("metrics: could not read initial CPU stat", "id", sb.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go m.samplerLoop(ctx, sb, fcPID, sb.VCPUs, initialCPU)
|
||||||
|
}
|
||||||
|
|
||||||
|
// samplerLoop samples /proc metrics at 500ms intervals.
|
||||||
|
// lastCPU is goroutine-local to avoid shared-state races.
|
||||||
|
func (m *Manager) samplerLoop(ctx context.Context, sb *sandboxState, fcPID, vcpus int, lastCPU cpuStat) {
|
||||||
|
defer close(sb.samplerDone)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
clkTck := 100.0 // sysconf(_SC_CLK_TCK), almost always 100 on Linux
|
||||||
|
lastTime := time.Now()
|
||||||
|
cpuInitialized := lastCPU != (cpuStat{})
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-ticker.C:
|
||||||
|
elapsed := now.Sub(lastTime).Seconds()
|
||||||
|
lastTime = now
|
||||||
|
|
||||||
|
// CPU: delta jiffies / (elapsed * CLK_TCK * vcpus) * 100
|
||||||
|
var cpuPct float64
|
||||||
|
cur, err := readCPUStat(fcPID)
|
||||||
|
if err == nil {
|
||||||
|
if cpuInitialized && elapsed > 0 && vcpus > 0 {
|
||||||
|
deltaJiffies := float64((cur.utime + cur.stime) - (lastCPU.utime + lastCPU.stime))
|
||||||
|
cpuPct = (deltaJiffies / (elapsed * clkTck * float64(vcpus))) * 100.0
|
||||||
|
if cpuPct > 100.0 {
|
||||||
|
cpuPct = 100.0
|
||||||
|
}
|
||||||
|
if cpuPct < 0 {
|
||||||
|
cpuPct = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastCPU = cur
|
||||||
|
cpuInitialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory: VmRSS of the Firecracker process.
|
||||||
|
memBytes, _ := readMemRSS(fcPID)
|
||||||
|
|
||||||
|
// Disk: allocated bytes of the CoW sparse file.
|
||||||
|
var diskBytes int64
|
||||||
|
if sb.dmDevice != nil {
|
||||||
|
diskBytes, _ = readDiskAllocated(sb.dmDevice.CowPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.ring.Push(MetricPoint{
|
||||||
|
Timestamp: now,
|
||||||
|
CPUPct: cpuPct,
|
||||||
|
MemBytes: memBytes,
|
||||||
|
DiskBytes: diskBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopSampler stops the metrics sampling goroutine and waits for it to exit.
|
||||||
|
func (m *Manager) stopSampler(sb *sandboxState) {
|
||||||
|
if sb.samplerCancel != nil {
|
||||||
|
sb.samplerCancel()
|
||||||
|
<-sb.samplerDone
|
||||||
|
sb.samplerCancel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetrics returns the ring buffer data for the given range tier.
|
||||||
|
// Valid ranges: "10m", "2h", "24h".
|
||||||
|
func (m *Manager) GetMetrics(sandboxID, rangeTier string) ([]MetricPoint, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
sb, ok := m.boxes[sandboxID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("sandbox not found: %s", sandboxID)
|
||||||
|
}
|
||||||
|
if sb.ring == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the requested range to the appropriate ring tier and time cutoff.
|
||||||
|
var points []MetricPoint
|
||||||
|
var cutoff time.Duration
|
||||||
|
switch rangeTier {
|
||||||
|
case "5m":
|
||||||
|
points = sb.ring.Get10m()
|
||||||
|
cutoff = 5 * time.Minute
|
||||||
|
case "10m":
|
||||||
|
points = sb.ring.Get10m()
|
||||||
|
cutoff = 10 * time.Minute
|
||||||
|
case "1h":
|
||||||
|
points = sb.ring.Get2h()
|
||||||
|
cutoff = 1 * time.Hour
|
||||||
|
case "2h":
|
||||||
|
points = sb.ring.Get2h()
|
||||||
|
cutoff = 2 * time.Hour
|
||||||
|
case "6h":
|
||||||
|
points = sb.ring.Get24h()
|
||||||
|
cutoff = 6 * time.Hour
|
||||||
|
case "12h":
|
||||||
|
points = sb.ring.Get24h()
|
||||||
|
cutoff = 12 * time.Hour
|
||||||
|
case "24h":
|
||||||
|
points = sb.ring.Get24h()
|
||||||
|
cutoff = 24 * time.Hour
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid range: %s (valid: 5m, 10m, 1h, 2h, 6h, 12h, 24h)", rangeTier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter points to the requested time window.
|
||||||
|
threshold := time.Now().Add(-cutoff)
|
||||||
|
filtered := points[:0:0]
|
||||||
|
for _, p := range points {
|
||||||
|
if !p.Timestamp.Before(threshold) {
|
||||||
|
filtered = append(filtered, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushMetrics returns all three tier ring buffers, clears the ring, and
|
||||||
|
// stops the sampler goroutine. Called by the control plane before pause/destroy.
|
||||||
|
func (m *Manager) FlushMetrics(sandboxID string) (pts10m, pts2h, pts24h []MetricPoint, err error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
sb, ok := m.boxes[sandboxID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, nil, fmt.Errorf("sandbox not found: %s", sandboxID)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.stopSampler(sb)
|
||||||
|
if sb.ring == nil {
|
||||||
|
return nil, nil, nil, nil
|
||||||
|
}
|
||||||
|
pts10m, pts2h, pts24h = sb.ring.Flush()
|
||||||
|
return pts10m, pts2h, pts24h, nil
|
||||||
|
}
|
||||||
|
|
||||||
// copyFile copies a regular file from src to dst using streaming I/O.
|
// copyFile copies a regular file from src to dst using streaming I/O.
|
||||||
func copyFile(src, dst string) error {
|
func copyFile(src, dst string) error {
|
||||||
sf, err := os.Open(src)
|
sf, err := os.Open(src)
|
||||||
|
|||||||
178
internal/sandbox/metrics.go
Normal file
178
internal/sandbox/metrics.go
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MetricPoint holds one metrics sample.
|
||||||
|
type MetricPoint struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
CPUPct float64
|
||||||
|
MemBytes int64
|
||||||
|
DiskBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ring buffer capacity constants.
|
||||||
|
const (
|
||||||
|
ring10mCap = 1200 // 500ms × 1200 = 10 min
|
||||||
|
ring2hCap = 240 // 30s × 240 = 2 h
|
||||||
|
ring24hCap = 288 // 5min × 288 = 24 h
|
||||||
|
|
||||||
|
downsample2hEvery = 60 // 60 × 500ms = 30s
|
||||||
|
downsample24hEvery = 10 // 10 × 30s = 5min
|
||||||
|
)
|
||||||
|
|
||||||
|
// metricsRing holds three tiered ring buffers with automatic downsampling
|
||||||
|
// from the finest tier into coarser tiers.
|
||||||
|
type metricsRing struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// 10-minute tier: 500ms samples.
|
||||||
|
buf10m [ring10mCap]MetricPoint
|
||||||
|
idx10m int
|
||||||
|
count10m int
|
||||||
|
|
||||||
|
// 2-hour tier: 30s averages.
|
||||||
|
buf2h [ring2hCap]MetricPoint
|
||||||
|
idx2h int
|
||||||
|
count2h int
|
||||||
|
|
||||||
|
// 24-hour tier: 5min averages.
|
||||||
|
buf24h [ring24hCap]MetricPoint
|
||||||
|
idx24h int
|
||||||
|
count24h int
|
||||||
|
|
||||||
|
// Accumulators for downsampling.
|
||||||
|
acc500ms [downsample2hEvery]MetricPoint
|
||||||
|
acc500msN int
|
||||||
|
|
||||||
|
acc30s [downsample24hEvery]MetricPoint
|
||||||
|
acc30sN int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMetricsRing creates an empty metrics ring buffer.
|
||||||
|
func newMetricsRing() *metricsRing {
|
||||||
|
return &metricsRing{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push adds a 500ms sample to the finest tier and triggers downsampling
|
||||||
|
// into coarser tiers when enough samples have accumulated.
|
||||||
|
func (r *metricsRing) Push(p MetricPoint) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
// Write to 10m ring.
|
||||||
|
r.buf10m[r.idx10m] = p
|
||||||
|
r.idx10m = (r.idx10m + 1) % ring10mCap
|
||||||
|
if r.count10m < ring10mCap {
|
||||||
|
r.count10m++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate for 2h downsample.
|
||||||
|
r.acc500ms[r.acc500msN] = p
|
||||||
|
r.acc500msN++
|
||||||
|
if r.acc500msN == downsample2hEvery {
|
||||||
|
avg := averagePoints(r.acc500ms[:downsample2hEvery])
|
||||||
|
r.push2h(avg)
|
||||||
|
r.acc500msN = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *metricsRing) push2h(p MetricPoint) {
|
||||||
|
r.buf2h[r.idx2h] = p
|
||||||
|
r.idx2h = (r.idx2h + 1) % ring2hCap
|
||||||
|
if r.count2h < ring2hCap {
|
||||||
|
r.count2h++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate for 24h downsample.
|
||||||
|
r.acc30s[r.acc30sN] = p
|
||||||
|
r.acc30sN++
|
||||||
|
if r.acc30sN == downsample24hEvery {
|
||||||
|
avg := averagePoints(r.acc30s[:downsample24hEvery])
|
||||||
|
r.push24h(avg)
|
||||||
|
r.acc30sN = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *metricsRing) push24h(p MetricPoint) {
|
||||||
|
r.buf24h[r.idx24h] = p
|
||||||
|
r.idx24h = (r.idx24h + 1) % ring24hCap
|
||||||
|
if r.count24h < ring24hCap {
|
||||||
|
r.count24h++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get10m returns the 10-minute tier points in chronological order.
|
||||||
|
func (r *metricsRing) Get10m() []MetricPoint {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.readRing(r.buf10m[:], r.idx10m, r.count10m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get2h returns the 2-hour tier points in chronological order.
|
||||||
|
func (r *metricsRing) Get2h() []MetricPoint {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.readRing(r.buf2h[:], r.idx2h, r.count2h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get24h returns the 24-hour tier points in chronological order.
|
||||||
|
func (r *metricsRing) Get24h() []MetricPoint {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.readRing(r.buf24h[:], r.idx24h, r.count24h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush returns all three tiers and resets the ring buffer.
|
||||||
|
func (r *metricsRing) Flush() (pts10m, pts2h, pts24h []MetricPoint) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
pts10m = r.readRing(r.buf10m[:], r.idx10m, r.count10m)
|
||||||
|
pts2h = r.readRing(r.buf2h[:], r.idx2h, r.count2h)
|
||||||
|
pts24h = r.readRing(r.buf24h[:], r.idx24h, r.count24h)
|
||||||
|
|
||||||
|
// Reset all state.
|
||||||
|
r.idx10m, r.count10m = 0, 0
|
||||||
|
r.idx2h, r.count2h = 0, 0
|
||||||
|
r.idx24h, r.count24h = 0, 0
|
||||||
|
r.acc500msN = 0
|
||||||
|
r.acc30sN = 0
|
||||||
|
|
||||||
|
return pts10m, pts2h, pts24h
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRing extracts elements from a circular buffer in chronological order.
|
||||||
|
func (r *metricsRing) readRing(buf []MetricPoint, nextIdx, count int) []MetricPoint {
|
||||||
|
if count == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]MetricPoint, count)
|
||||||
|
bufLen := len(buf)
|
||||||
|
start := (nextIdx - count + bufLen) % bufLen
|
||||||
|
for i := range count {
|
||||||
|
result[i] = buf[(start+i)%bufLen]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// averagePoints computes the average of a slice of MetricPoints.
|
||||||
|
// The timestamp is set to the last point's timestamp.
|
||||||
|
func averagePoints(pts []MetricPoint) MetricPoint {
|
||||||
|
n := float64(len(pts))
|
||||||
|
var cpu float64
|
||||||
|
var mem, disk int64
|
||||||
|
for _, p := range pts {
|
||||||
|
cpu += p.CPUPct
|
||||||
|
mem += p.MemBytes
|
||||||
|
disk += p.DiskBytes
|
||||||
|
}
|
||||||
|
return MetricPoint{
|
||||||
|
Timestamp: pts[len(pts)-1].Timestamp,
|
||||||
|
CPUPct: cpu / n,
|
||||||
|
MemBytes: int64(float64(mem) / n),
|
||||||
|
DiskBytes: int64(float64(disk) / n),
|
||||||
|
}
|
||||||
|
}
|
||||||
83
internal/sandbox/proc.go
Normal file
83
internal/sandbox/proc.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cpuStat holds raw CPU jiffies read from /proc/{pid}/stat.
|
||||||
|
type cpuStat struct {
|
||||||
|
utime uint64
|
||||||
|
stime uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCPUStat reads user and system CPU jiffies from /proc/{pid}/stat.
|
||||||
|
// Fields 14 (utime) and 15 (stime) are 1-indexed in the man page;
|
||||||
|
// after splitting on space, they are at indices 13 and 14.
|
||||||
|
func readCPUStat(pid int) (cpuStat, error) {
|
||||||
|
path := fmt.Sprintf("/proc/%d/stat", pid)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return cpuStat{}, fmt.Errorf("read stat: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /proc/{pid}/stat format: pid (comm) state fields...
|
||||||
|
// The comm field may contain spaces and parens, so find the last ')' first.
|
||||||
|
content := string(data)
|
||||||
|
idx := strings.LastIndex(content, ")")
|
||||||
|
if idx < 0 {
|
||||||
|
return cpuStat{}, fmt.Errorf("malformed /proc/%d/stat: no closing paren", pid)
|
||||||
|
}
|
||||||
|
// After ")" there is " state field3 field4 ... fieldN"
|
||||||
|
// field1 after ')' is state (index 0), utime is field 11, stime is field 12
|
||||||
|
// (0-indexed from after the closing paren).
|
||||||
|
fields := strings.Fields(content[idx+2:])
|
||||||
|
if len(fields) < 13 {
|
||||||
|
return cpuStat{}, fmt.Errorf("malformed /proc/%d/stat: too few fields (%d)", pid, len(fields))
|
||||||
|
}
|
||||||
|
utime, err := strconv.ParseUint(fields[11], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return cpuStat{}, fmt.Errorf("parse utime: %w", err)
|
||||||
|
}
|
||||||
|
stime, err := strconv.ParseUint(fields[12], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return cpuStat{}, fmt.Errorf("parse stime: %w", err)
|
||||||
|
}
|
||||||
|
return cpuStat{utime: utime, stime: stime}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMemRSS reads VmRSS from /proc/{pid}/status and returns bytes.
|
||||||
|
func readMemRSS(pid int) (int64, error) {
|
||||||
|
path := fmt.Sprintf("/proc/%d/status", pid)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("read status: %w", err)
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.HasPrefix(line, "VmRSS:") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return 0, fmt.Errorf("malformed VmRSS line")
|
||||||
|
}
|
||||||
|
kb, err := strconv.ParseInt(fields[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("parse VmRSS: %w", err)
|
||||||
|
}
|
||||||
|
return kb * 1024, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("VmRSS not found in /proc/%d/status", pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDiskAllocated returns the actual allocated bytes (not apparent size)
|
||||||
|
// of the file at path. This uses stat's block count × 512.
|
||||||
|
func readDiskAllocated(path string) (int64, error) {
|
||||||
|
var stat syscall.Stat_t
|
||||||
|
if err := syscall.Stat(path, &stat); err != nil {
|
||||||
|
return 0, fmt.Errorf("stat %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return stat.Blocks * 512, nil
|
||||||
|
}
|
||||||
@ -58,6 +58,8 @@ type hostagentClient = interface {
|
|||||||
PauseSandbox(ctx context.Context, req *connect.Request[pb.PauseSandboxRequest]) (*connect.Response[pb.PauseSandboxResponse], error)
|
PauseSandbox(ctx context.Context, req *connect.Request[pb.PauseSandboxRequest]) (*connect.Response[pb.PauseSandboxResponse], error)
|
||||||
ResumeSandbox(ctx context.Context, req *connect.Request[pb.ResumeSandboxRequest]) (*connect.Response[pb.ResumeSandboxResponse], error)
|
ResumeSandbox(ctx context.Context, req *connect.Request[pb.ResumeSandboxRequest]) (*connect.Response[pb.ResumeSandboxResponse], error)
|
||||||
PingSandbox(ctx context.Context, req *connect.Request[pb.PingSandboxRequest]) (*connect.Response[pb.PingSandboxResponse], error)
|
PingSandbox(ctx context.Context, req *connect.Request[pb.PingSandboxRequest]) (*connect.Response[pb.PingSandboxResponse], error)
|
||||||
|
GetSandboxMetrics(ctx context.Context, req *connect.Request[pb.GetSandboxMetricsRequest]) (*connect.Response[pb.GetSandboxMetricsResponse], error)
|
||||||
|
FlushSandboxMetrics(ctx context.Context, req *connect.Request[pb.FlushSandboxMetricsRequest]) (*connect.Response[pb.FlushSandboxMetricsResponse], error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create creates a new sandbox: picks a host via the scheduler, inserts a pending
|
// Create creates a new sandbox: picks a host via the scheduler, inserts a pending
|
||||||
@ -180,6 +182,9 @@ func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID string) (d
|
|||||||
return db.Sandbox{}, err
|
return db.Sandbox{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush all metrics tiers before pausing so data survives in DB.
|
||||||
|
s.flushAndPersistMetrics(ctx, agent, sandboxID, true)
|
||||||
|
|
||||||
if _, err := agent.PauseSandbox(ctx, connect.NewRequest(&pb.PauseSandboxRequest{
|
if _, err := agent.PauseSandbox(ctx, connect.NewRequest(&pb.PauseSandboxRequest{
|
||||||
SandboxId: sandboxID,
|
SandboxId: sandboxID,
|
||||||
})); err != nil {
|
})); err != nil {
|
||||||
@ -236,7 +241,8 @@ func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID string) (
|
|||||||
|
|
||||||
// Destroy stops a sandbox and marks it as stopped.
|
// Destroy stops a sandbox and marks it as stopped.
|
||||||
func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID string) error {
|
func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID string) error {
|
||||||
if _, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID}); err != nil {
|
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("sandbox not found: %w", err)
|
return fmt.Errorf("sandbox not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,6 +251,11 @@ func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID string)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If running, flush 24h tier metrics for analytics before destroying.
|
||||||
|
if sb.Status == "running" {
|
||||||
|
s.flushAndPersistMetrics(ctx, agent, sandboxID, false)
|
||||||
|
}
|
||||||
|
|
||||||
// Destroy on host agent. A not-found response is fine — sandbox is already gone.
|
// Destroy on host agent. A not-found response is fine — sandbox is already gone.
|
||||||
if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{
|
if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{
|
||||||
SandboxId: sandboxID,
|
SandboxId: sandboxID,
|
||||||
@ -252,6 +263,16 @@ func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID string)
|
|||||||
return fmt.Errorf("agent destroy: %w", err)
|
return fmt.Errorf("agent destroy: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For a paused sandbox, only keep 24h tier; remove the finer-grained tiers.
|
||||||
|
if sb.Status == "paused" {
|
||||||
|
_ = s.DB.DeleteSandboxMetricPointsByTier(ctx, db.DeleteSandboxMetricPointsByTierParams{
|
||||||
|
SandboxID: sandboxID, Tier: "10m",
|
||||||
|
})
|
||||||
|
_ = s.DB.DeleteSandboxMetricPointsByTier(ctx, db.DeleteSandboxMetricPointsByTierParams{
|
||||||
|
SandboxID: sandboxID, Tier: "2h",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
|
if _, err := s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
|
||||||
ID: sandboxID, Status: "stopped",
|
ID: sandboxID, Status: "stopped",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@ -260,6 +281,41 @@ func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// flushAndPersistMetrics calls FlushSandboxMetrics on the agent and stores
|
||||||
|
// the returned data to DB. If allTiers is true, all three tiers are saved;
|
||||||
|
// otherwise only the 24h tier (for post-destroy analytics).
|
||||||
|
func (s *SandboxService) flushAndPersistMetrics(ctx context.Context, agent hostagentClient, sandboxID string, allTiers bool) {
|
||||||
|
resp, err := agent.FlushSandboxMetrics(ctx, connect.NewRequest(&pb.FlushSandboxMetricsRequest{
|
||||||
|
SandboxId: sandboxID,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("flush metrics failed (best-effort)", "sandbox_id", sandboxID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := resp.Msg
|
||||||
|
|
||||||
|
if allTiers {
|
||||||
|
s.persistMetricPoints(ctx, sandboxID, "10m", msg.Points_10M)
|
||||||
|
s.persistMetricPoints(ctx, sandboxID, "2h", msg.Points_2H)
|
||||||
|
}
|
||||||
|
s.persistMetricPoints(ctx, sandboxID, "24h", msg.Points_24H)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SandboxService) persistMetricPoints(ctx context.Context, sandboxID, tier string, points []*pb.MetricPoint) {
|
||||||
|
for _, p := range points {
|
||||||
|
if err := s.DB.InsertSandboxMetricPoint(ctx, db.InsertSandboxMetricPointParams{
|
||||||
|
SandboxID: sandboxID,
|
||||||
|
Tier: tier,
|
||||||
|
Ts: p.TimestampUnix,
|
||||||
|
CpuPct: p.CpuPct,
|
||||||
|
MemBytes: p.MemBytes,
|
||||||
|
DiskBytes: p.DiskBytes,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Warn("persist metric point failed", "sandbox_id", sandboxID, "tier", tier, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ping resets the inactivity timer for a running sandbox.
|
// Ping resets the inactivity timer for a running sandbox.
|
||||||
func (s *SandboxService) Ping(ctx context.Context, sandboxID, teamID string) error {
|
func (s *SandboxService) Ping(ctx context.Context, sandboxID, teamID string) error {
|
||||||
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
|
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
|
||||||
|
|||||||
153
internal/service/stats.go
Normal file
153
internal/service/stats.go
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimeRange identifies a chart time window.
|
||||||
|
type TimeRange string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Range5m TimeRange = "5m"
|
||||||
|
Range1h TimeRange = "1h"
|
||||||
|
Range6h TimeRange = "6h"
|
||||||
|
Range24h TimeRange = "24h"
|
||||||
|
Range30d TimeRange = "30d"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rangeConfig struct {
|
||||||
|
bucketSec int // bucket width in seconds for time-series aggregation
|
||||||
|
intervalLiteral string // PostgreSQL interval literal for the lookback window
|
||||||
|
}
|
||||||
|
|
||||||
|
var rangeConfigs = map[TimeRange]rangeConfig{
|
||||||
|
Range5m: {bucketSec: 3, intervalLiteral: "5 minutes"},
|
||||||
|
Range1h: {bucketSec: 30, intervalLiteral: "1 hour"},
|
||||||
|
Range6h: {bucketSec: 180, intervalLiteral: "6 hours"},
|
||||||
|
Range24h: {bucketSec: 720, intervalLiteral: "24 hours"},
|
||||||
|
Range30d: {bucketSec: 21600, intervalLiteral: "30 days"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidRange returns true if r is a known TimeRange value.
|
||||||
|
func ValidRange(r TimeRange) bool {
|
||||||
|
_, ok := rangeConfigs[r]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatPoint is one bucketed data point in the time-series.
|
||||||
|
type StatPoint struct {
|
||||||
|
Bucket time.Time
|
||||||
|
RunningCount int32
|
||||||
|
VCPUsReserved int32
|
||||||
|
MemoryMBReserved int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentStats holds the live values for a team, read directly from sandboxes.
|
||||||
|
type CurrentStats struct {
|
||||||
|
RunningCount int32
|
||||||
|
VCPUsReserved int32
|
||||||
|
MemoryMBReserved int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeakStats holds the 30-day maximum values for a team.
|
||||||
|
type PeakStats struct {
|
||||||
|
RunningCount int32
|
||||||
|
VCPUs int32
|
||||||
|
MemoryMB int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsService computes sandbox metrics for the dashboard.
|
||||||
|
type StatsService struct {
|
||||||
|
DB *db.Queries
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns current stats, 30-day peaks, and a time-series for the
|
||||||
|
// given team and time range. If no snapshots exist yet, zeros are returned.
|
||||||
|
func (s *StatsService) GetStats(ctx context.Context, teamID string, r TimeRange) (CurrentStats, PeakStats, []StatPoint, error) {
|
||||||
|
cfg, ok := rangeConfigs[r]
|
||||||
|
if !ok {
|
||||||
|
return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("unknown range: %s", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current live values — read directly from sandboxes so we always reflect
|
||||||
|
// the true state even when no capsules are running.
|
||||||
|
cur, err := s.DB.GetLiveMetrics(ctx, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("get live metrics: %w", err)
|
||||||
|
}
|
||||||
|
current := CurrentStats{
|
||||||
|
RunningCount: cur.RunningCount,
|
||||||
|
VCPUsReserved: cur.VcpusReserved,
|
||||||
|
MemoryMBReserved: cur.MemoryMbReserved,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30-day peaks.
|
||||||
|
var peaks PeakStats
|
||||||
|
pk, err := s.DB.GetPeakMetrics(ctx, teamID)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("get peak metrics: %w", err)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
peaks = PeakStats{
|
||||||
|
RunningCount: pk.PeakRunningCount,
|
||||||
|
VCPUs: pk.PeakVcpus,
|
||||||
|
MemoryMB: pk.PeakMemoryMb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-series — dynamic bucket width, executed via pgx directly.
|
||||||
|
series, err := s.queryTimeSeries(ctx, teamID, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("get time series: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return current, peaks, series, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeSeriesSQL uses an epoch-floor trick to bucket rows by an arbitrary
|
||||||
|
// integer number of seconds without requiring TimescaleDB.
|
||||||
|
//
|
||||||
|
// $1 = bucket width in seconds (integer)
|
||||||
|
// $2 = team_id
|
||||||
|
// $3 = lookback interval literal (e.g. '1 hour')
|
||||||
|
const timeSeriesSQL = `
|
||||||
|
SELECT
|
||||||
|
to_timestamp(floor(extract(epoch FROM sampled_at) / $1) * $1) AS bucket,
|
||||||
|
AVG(running_count)::INTEGER AS running_count,
|
||||||
|
AVG(vcpus_reserved)::INTEGER AS vcpus_reserved,
|
||||||
|
AVG(memory_mb_reserved)::INTEGER AS memory_mb_reserved
|
||||||
|
FROM sandbox_metrics_snapshots
|
||||||
|
WHERE team_id = $2
|
||||||
|
AND sampled_at >= NOW() - $3::INTERVAL
|
||||||
|
GROUP BY bucket
|
||||||
|
ORDER BY bucket ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (s *StatsService) queryTimeSeries(ctx context.Context, teamID string, cfg rangeConfig) ([]StatPoint, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, timeSeriesSQL, cfg.bucketSec, teamID, cfg.intervalLiteral)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var points []StatPoint
|
||||||
|
for rows.Next() {
|
||||||
|
var p StatPoint
|
||||||
|
var bucket time.Time
|
||||||
|
if err := rows.Scan(&bucket, &p.RunningCount, &p.VCPUsReserved, &p.MemoryMBReserved); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Bucket = bucket
|
||||||
|
points = append(points, p)
|
||||||
|
}
|
||||||
|
return points, rows.Err()
|
||||||
|
}
|
||||||
@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.omukk.dev/wrenn/sandbox/internal/db"
|
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||||
@ -369,12 +368,6 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID string
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchUsersByEmailPrefix returns up to 10 users whose email starts with the given prefix.
|
|
||||||
// The prefix must contain "@" to prevent broad enumeration.
|
|
||||||
func (s *TeamService) SearchUsersByEmailPrefix(ctx context.Context, prefix string) ([]db.SearchUsersByEmailPrefixRow, error) {
|
|
||||||
return s.DB.SearchUsersByEmailPrefix(ctx, pgtype.Text{String: prefix, Valid: true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetBYOC enables the BYOC feature flag for a team. Once enabled, BYOC cannot
|
// SetBYOC enables the BYOC feature flag for a team. Once enabled, BYOC cannot
|
||||||
// be disabled — it is a one-way transition.
|
// be disabled — it is a one-way transition.
|
||||||
// Admin-only — the caller must verify admin status before invoking this.
|
// Admin-only — the caller must verify admin status before invoking this.
|
||||||
|
|||||||
@ -250,6 +250,12 @@ func (m *Manager) CreateFromSnapshot(ctx context.Context, cfg VMConfig, snapPath
|
|||||||
return vm, nil
|
return vm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PID returns the process ID of the unshare wrapper process.
|
||||||
|
// The actual Firecracker process is a direct child of this PID.
|
||||||
|
func (v *VM) PID() int {
|
||||||
|
return v.process.cmd.Process.Pid
|
||||||
|
}
|
||||||
|
|
||||||
// Get returns a running VM by sandbox ID.
|
// Get returns a running VM by sandbox ID.
|
||||||
func (m *Manager) Get(sandboxID string) (*VM, bool) {
|
func (m *Manager) Get(sandboxID string) (*VM, bool) {
|
||||||
vm, ok := m.vms[sandboxID]
|
vm, ok := m.vms[sandboxID]
|
||||||
|
|||||||
@ -1902,6 +1902,275 @@ func (*TerminateResponse) Descriptor() ([]byte, []int) {
|
|||||||
return file_hostagent_proto_rawDescGZIP(), []int{34}
|
return file_hostagent_proto_rawDescGZIP(), []int{34}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MetricPoint struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
TimestampUnix int64 `protobuf:"varint,1,opt,name=timestamp_unix,json=timestampUnix,proto3" json:"timestamp_unix,omitempty"`
|
||||||
|
CpuPct float64 `protobuf:"fixed64,2,opt,name=cpu_pct,json=cpuPct,proto3" json:"cpu_pct,omitempty"`
|
||||||
|
MemBytes int64 `protobuf:"varint,3,opt,name=mem_bytes,json=memBytes,proto3" json:"mem_bytes,omitempty"`
|
||||||
|
DiskBytes int64 `protobuf:"varint,4,opt,name=disk_bytes,json=diskBytes,proto3" json:"disk_bytes,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricPoint) Reset() {
|
||||||
|
*x = MetricPoint{}
|
||||||
|
mi := &file_hostagent_proto_msgTypes[35]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricPoint) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MetricPoint) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *MetricPoint) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_hostagent_proto_msgTypes[35]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use MetricPoint.ProtoReflect.Descriptor instead.
|
||||||
|
func (*MetricPoint) Descriptor() ([]byte, []int) {
|
||||||
|
return file_hostagent_proto_rawDescGZIP(), []int{35}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricPoint) GetTimestampUnix() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TimestampUnix
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricPoint) GetCpuPct() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.CpuPct
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricPoint) GetMemBytes() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MemBytes
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricPoint) GetDiskBytes() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.DiskBytes
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetSandboxMetricsRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
|
||||||
|
// Range tier: "10m", "2h", or "24h".
|
||||||
|
Range string `protobuf:"bytes,2,opt,name=range,proto3" json:"range,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetSandboxMetricsRequest) Reset() {
|
||||||
|
*x = GetSandboxMetricsRequest{}
|
||||||
|
mi := &file_hostagent_proto_msgTypes[36]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetSandboxMetricsRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetSandboxMetricsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetSandboxMetricsRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_hostagent_proto_msgTypes[36]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetSandboxMetricsRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetSandboxMetricsRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_hostagent_proto_rawDescGZIP(), []int{36}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetSandboxMetricsRequest) GetSandboxId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.SandboxId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetSandboxMetricsRequest) GetRange() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Range
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetSandboxMetricsResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Points []*MetricPoint `protobuf:"bytes,1,rep,name=points,proto3" json:"points,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetSandboxMetricsResponse) Reset() {
|
||||||
|
*x = GetSandboxMetricsResponse{}
|
||||||
|
mi := &file_hostagent_proto_msgTypes[37]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetSandboxMetricsResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetSandboxMetricsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetSandboxMetricsResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_hostagent_proto_msgTypes[37]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetSandboxMetricsResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetSandboxMetricsResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_hostagent_proto_rawDescGZIP(), []int{37}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetSandboxMetricsResponse) GetPoints() []*MetricPoint {
|
||||||
|
if x != nil {
|
||||||
|
return x.Points
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlushSandboxMetricsRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *FlushSandboxMetricsRequest) Reset() {
|
||||||
|
*x = FlushSandboxMetricsRequest{}
|
||||||
|
mi := &file_hostagent_proto_msgTypes[38]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *FlushSandboxMetricsRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*FlushSandboxMetricsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *FlushSandboxMetricsRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_hostagent_proto_msgTypes[38]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use FlushSandboxMetricsRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*FlushSandboxMetricsRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_hostagent_proto_rawDescGZIP(), []int{38}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *FlushSandboxMetricsRequest) GetSandboxId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.SandboxId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlushSandboxMetricsResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Points_10M []*MetricPoint `protobuf:"bytes,1,rep,name=points_10m,json=points10m,proto3" json:"points_10m,omitempty"`
|
||||||
|
Points_2H []*MetricPoint `protobuf:"bytes,2,rep,name=points_2h,json=points2h,proto3" json:"points_2h,omitempty"`
|
||||||
|
Points_24H []*MetricPoint `protobuf:"bytes,3,rep,name=points_24h,json=points24h,proto3" json:"points_24h,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *FlushSandboxMetricsResponse) Reset() {
|
||||||
|
*x = FlushSandboxMetricsResponse{}
|
||||||
|
mi := &file_hostagent_proto_msgTypes[39]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *FlushSandboxMetricsResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*FlushSandboxMetricsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *FlushSandboxMetricsResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_hostagent_proto_msgTypes[39]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use FlushSandboxMetricsResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*FlushSandboxMetricsResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_hostagent_proto_rawDescGZIP(), []int{39}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *FlushSandboxMetricsResponse) GetPoints_10M() []*MetricPoint {
|
||||||
|
if x != nil {
|
||||||
|
return x.Points_10M
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *FlushSandboxMetricsResponse) GetPoints_2H() []*MetricPoint {
|
||||||
|
if x != nil {
|
||||||
|
return x.Points_2H
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *FlushSandboxMetricsResponse) GetPoints_24H() []*MetricPoint {
|
||||||
|
if x != nil {
|
||||||
|
return x.Points_24H
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var File_hostagent_proto protoreflect.FileDescriptor
|
var File_hostagent_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_hostagent_proto_rawDesc = "" +
|
const file_hostagent_proto_rawDesc = "" +
|
||||||
@ -2029,8 +2298,28 @@ const file_hostagent_proto_rawDesc = "" +
|
|||||||
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x15\n" +
|
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x15\n" +
|
||||||
"\x13PingSandboxResponse\"\x12\n" +
|
"\x13PingSandboxResponse\"\x12\n" +
|
||||||
"\x10TerminateRequest\"\x13\n" +
|
"\x10TerminateRequest\"\x13\n" +
|
||||||
"\x11TerminateResponse2\x9c\n" +
|
"\x11TerminateResponse\"\x89\x01\n" +
|
||||||
|
"\vMetricPoint\x12%\n" +
|
||||||
|
"\x0etimestamp_unix\x18\x01 \x01(\x03R\rtimestampUnix\x12\x17\n" +
|
||||||
|
"\acpu_pct\x18\x02 \x01(\x01R\x06cpuPct\x12\x1b\n" +
|
||||||
|
"\tmem_bytes\x18\x03 \x01(\x03R\bmemBytes\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
|
"disk_bytes\x18\x04 \x01(\x03R\tdiskBytes\"O\n" +
|
||||||
|
"\x18GetSandboxMetricsRequest\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x14\n" +
|
||||||
|
"\x05range\x18\x02 \x01(\tR\x05range\"N\n" +
|
||||||
|
"\x19GetSandboxMetricsResponse\x121\n" +
|
||||||
|
"\x06points\x18\x01 \x03(\v2\x19.hostagent.v1.MetricPointR\x06points\";\n" +
|
||||||
|
"\x1aFlushSandboxMetricsRequest\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\xc9\x01\n" +
|
||||||
|
"\x1bFlushSandboxMetricsResponse\x128\n" +
|
||||||
|
"\n" +
|
||||||
|
"points_10m\x18\x01 \x03(\v2\x19.hostagent.v1.MetricPointR\tpoints10m\x126\n" +
|
||||||
|
"\tpoints_2h\x18\x02 \x03(\v2\x19.hostagent.v1.MetricPointR\bpoints2h\x128\n" +
|
||||||
|
"\n" +
|
||||||
|
"points_24h\x18\x03 \x03(\v2\x19.hostagent.v1.MetricPointR\tpoints24h2\xee\v\n" +
|
||||||
"\x10HostAgentService\x12X\n" +
|
"\x10HostAgentService\x12X\n" +
|
||||||
"\rCreateSandbox\x12\".hostagent.v1.CreateSandboxRequest\x1a#.hostagent.v1.CreateSandboxResponse\x12[\n" +
|
"\rCreateSandbox\x12\".hostagent.v1.CreateSandboxRequest\x1a#.hostagent.v1.CreateSandboxResponse\x12[\n" +
|
||||||
"\x0eDestroySandbox\x12#.hostagent.v1.DestroySandboxRequest\x1a$.hostagent.v1.DestroySandboxResponse\x12U\n" +
|
"\x0eDestroySandbox\x12#.hostagent.v1.DestroySandboxRequest\x1a$.hostagent.v1.DestroySandboxResponse\x12U\n" +
|
||||||
@ -2047,7 +2336,9 @@ const file_hostagent_proto_rawDesc = "" +
|
|||||||
"\x0fWriteFileStream\x12$.hostagent.v1.WriteFileStreamRequest\x1a%.hostagent.v1.WriteFileStreamResponse(\x01\x12]\n" +
|
"\x0fWriteFileStream\x12$.hostagent.v1.WriteFileStreamRequest\x1a%.hostagent.v1.WriteFileStreamResponse(\x01\x12]\n" +
|
||||||
"\x0eReadFileStream\x12#.hostagent.v1.ReadFileStreamRequest\x1a$.hostagent.v1.ReadFileStreamResponse0\x01\x12R\n" +
|
"\x0eReadFileStream\x12#.hostagent.v1.ReadFileStreamRequest\x1a$.hostagent.v1.ReadFileStreamResponse0\x01\x12R\n" +
|
||||||
"\vPingSandbox\x12 .hostagent.v1.PingSandboxRequest\x1a!.hostagent.v1.PingSandboxResponse\x12L\n" +
|
"\vPingSandbox\x12 .hostagent.v1.PingSandboxRequest\x1a!.hostagent.v1.PingSandboxResponse\x12L\n" +
|
||||||
"\tTerminate\x12\x1e.hostagent.v1.TerminateRequest\x1a\x1f.hostagent.v1.TerminateResponseB\xb0\x01\n" +
|
"\tTerminate\x12\x1e.hostagent.v1.TerminateRequest\x1a\x1f.hostagent.v1.TerminateResponse\x12d\n" +
|
||||||
|
"\x11GetSandboxMetrics\x12&.hostagent.v1.GetSandboxMetricsRequest\x1a'.hostagent.v1.GetSandboxMetricsResponse\x12j\n" +
|
||||||
|
"\x13FlushSandboxMetrics\x12(.hostagent.v1.FlushSandboxMetricsRequest\x1a).hostagent.v1.FlushSandboxMetricsResponseB\xb0\x01\n" +
|
||||||
"\x10com.hostagent.v1B\x0eHostagentProtoP\x01Z;git.omukk.dev/wrenn/sandbox/proto/hostagent/gen;hostagentv1\xa2\x02\x03HXX\xaa\x02\fHostagent.V1\xca\x02\fHostagent\\V1\xe2\x02\x18Hostagent\\V1\\GPBMetadata\xea\x02\rHostagent::V1b\x06proto3"
|
"\x10com.hostagent.v1B\x0eHostagentProtoP\x01Z;git.omukk.dev/wrenn/sandbox/proto/hostagent/gen;hostagentv1\xa2\x02\x03HXX\xaa\x02\fHostagent.V1\xca\x02\fHostagent\\V1\xe2\x02\x18Hostagent\\V1\\GPBMetadata\xea\x02\rHostagent::V1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -2062,7 +2353,7 @@ func file_hostagent_proto_rawDescGZIP() []byte {
|
|||||||
return file_hostagent_proto_rawDescData
|
return file_hostagent_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 35)
|
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 40)
|
||||||
var file_hostagent_proto_goTypes = []any{
|
var file_hostagent_proto_goTypes = []any{
|
||||||
(*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest
|
(*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest
|
||||||
(*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse
|
(*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse
|
||||||
@ -2099,6 +2390,11 @@ var file_hostagent_proto_goTypes = []any{
|
|||||||
(*PingSandboxResponse)(nil), // 32: hostagent.v1.PingSandboxResponse
|
(*PingSandboxResponse)(nil), // 32: hostagent.v1.PingSandboxResponse
|
||||||
(*TerminateRequest)(nil), // 33: hostagent.v1.TerminateRequest
|
(*TerminateRequest)(nil), // 33: hostagent.v1.TerminateRequest
|
||||||
(*TerminateResponse)(nil), // 34: hostagent.v1.TerminateResponse
|
(*TerminateResponse)(nil), // 34: hostagent.v1.TerminateResponse
|
||||||
|
(*MetricPoint)(nil), // 35: hostagent.v1.MetricPoint
|
||||||
|
(*GetSandboxMetricsRequest)(nil), // 36: hostagent.v1.GetSandboxMetricsRequest
|
||||||
|
(*GetSandboxMetricsResponse)(nil), // 37: hostagent.v1.GetSandboxMetricsResponse
|
||||||
|
(*FlushSandboxMetricsRequest)(nil), // 38: hostagent.v1.FlushSandboxMetricsRequest
|
||||||
|
(*FlushSandboxMetricsResponse)(nil), // 39: hostagent.v1.FlushSandboxMetricsResponse
|
||||||
}
|
}
|
||||||
var file_hostagent_proto_depIdxs = []int32{
|
var file_hostagent_proto_depIdxs = []int32{
|
||||||
16, // 0: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo
|
16, // 0: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo
|
||||||
@ -2106,41 +2402,49 @@ var file_hostagent_proto_depIdxs = []int32{
|
|||||||
24, // 2: hostagent.v1.ExecStreamResponse.data:type_name -> hostagent.v1.ExecStreamData
|
24, // 2: hostagent.v1.ExecStreamResponse.data:type_name -> hostagent.v1.ExecStreamData
|
||||||
25, // 3: hostagent.v1.ExecStreamResponse.end:type_name -> hostagent.v1.ExecStreamEnd
|
25, // 3: hostagent.v1.ExecStreamResponse.end:type_name -> hostagent.v1.ExecStreamEnd
|
||||||
27, // 4: hostagent.v1.WriteFileStreamRequest.meta:type_name -> hostagent.v1.WriteFileStreamMeta
|
27, // 4: hostagent.v1.WriteFileStreamRequest.meta:type_name -> hostagent.v1.WriteFileStreamMeta
|
||||||
0, // 5: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest
|
35, // 5: hostagent.v1.GetSandboxMetricsResponse.points:type_name -> hostagent.v1.MetricPoint
|
||||||
2, // 6: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest
|
35, // 6: hostagent.v1.FlushSandboxMetricsResponse.points_10m:type_name -> hostagent.v1.MetricPoint
|
||||||
4, // 7: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest
|
35, // 7: hostagent.v1.FlushSandboxMetricsResponse.points_2h:type_name -> hostagent.v1.MetricPoint
|
||||||
6, // 8: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest
|
35, // 8: hostagent.v1.FlushSandboxMetricsResponse.points_24h:type_name -> hostagent.v1.MetricPoint
|
||||||
12, // 9: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest
|
0, // 9: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest
|
||||||
14, // 10: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest
|
2, // 10: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest
|
||||||
17, // 11: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest
|
4, // 11: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest
|
||||||
19, // 12: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest
|
6, // 12: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest
|
||||||
8, // 13: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest
|
12, // 13: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest
|
||||||
10, // 14: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest
|
14, // 14: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest
|
||||||
21, // 15: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest
|
17, // 15: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest
|
||||||
26, // 16: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest
|
19, // 16: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest
|
||||||
29, // 17: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest
|
8, // 17: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest
|
||||||
31, // 18: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest
|
10, // 18: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest
|
||||||
33, // 19: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest
|
21, // 19: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest
|
||||||
1, // 20: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
|
26, // 20: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest
|
||||||
3, // 21: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
|
29, // 21: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest
|
||||||
5, // 22: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
|
31, // 22: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest
|
||||||
7, // 23: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
|
33, // 23: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest
|
||||||
13, // 24: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
|
36, // 24: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest
|
||||||
15, // 25: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
|
38, // 25: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest
|
||||||
18, // 26: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
|
1, // 26: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
|
||||||
20, // 27: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
|
3, // 27: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
|
||||||
9, // 28: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
|
5, // 28: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
|
||||||
11, // 29: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
|
7, // 29: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
|
||||||
22, // 30: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
|
13, // 30: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
|
||||||
28, // 31: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
|
15, // 31: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
|
||||||
30, // 32: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
|
18, // 32: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
|
||||||
32, // 33: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
|
20, // 33: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
|
||||||
34, // 34: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse
|
9, // 34: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
|
||||||
20, // [20:35] is the sub-list for method output_type
|
11, // 35: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
|
||||||
5, // [5:20] is the sub-list for method input_type
|
22, // 36: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
|
||||||
5, // [5:5] is the sub-list for extension type_name
|
28, // 37: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
|
||||||
5, // [5:5] is the sub-list for extension extendee
|
30, // 38: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
|
||||||
0, // [0:5] is the sub-list for field type_name
|
32, // 39: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
|
||||||
|
34, // 40: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse
|
||||||
|
37, // 41: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse
|
||||||
|
39, // 42: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse
|
||||||
|
26, // [26:43] is the sub-list for method output_type
|
||||||
|
9, // [9:26] is the sub-list for method input_type
|
||||||
|
9, // [9:9] is the sub-list for extension type_name
|
||||||
|
9, // [9:9] is the sub-list for extension extendee
|
||||||
|
0, // [0:9] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_hostagent_proto_init() }
|
func init() { file_hostagent_proto_init() }
|
||||||
@ -2167,7 +2471,7 @@ func file_hostagent_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 35,
|
NumMessages: 40,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -77,6 +77,12 @@ const (
|
|||||||
// HostAgentServiceTerminateProcedure is the fully-qualified name of the HostAgentService's
|
// HostAgentServiceTerminateProcedure is the fully-qualified name of the HostAgentService's
|
||||||
// Terminate RPC.
|
// Terminate RPC.
|
||||||
HostAgentServiceTerminateProcedure = "/hostagent.v1.HostAgentService/Terminate"
|
HostAgentServiceTerminateProcedure = "/hostagent.v1.HostAgentService/Terminate"
|
||||||
|
// HostAgentServiceGetSandboxMetricsProcedure is the fully-qualified name of the HostAgentService's
|
||||||
|
// GetSandboxMetrics RPC.
|
||||||
|
HostAgentServiceGetSandboxMetricsProcedure = "/hostagent.v1.HostAgentService/GetSandboxMetrics"
|
||||||
|
// HostAgentServiceFlushSandboxMetricsProcedure is the fully-qualified name of the
|
||||||
|
// HostAgentService's FlushSandboxMetrics RPC.
|
||||||
|
HostAgentServiceFlushSandboxMetricsProcedure = "/hostagent.v1.HostAgentService/FlushSandboxMetrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostAgentServiceClient is a client for the hostagent.v1.HostAgentService service.
|
// HostAgentServiceClient is a client for the hostagent.v1.HostAgentService service.
|
||||||
@ -115,6 +121,11 @@ type HostAgentServiceClient interface {
|
|||||||
// Called by the control plane immediately when a host is deleted so the
|
// Called by the control plane immediately when a host is deleted so the
|
||||||
// agent shuts down without waiting for the next heartbeat cycle.
|
// agent shuts down without waiting for the next heartbeat cycle.
|
||||||
Terminate(context.Context, *connect.Request[gen.TerminateRequest]) (*connect.Response[gen.TerminateResponse], error)
|
Terminate(context.Context, *connect.Request[gen.TerminateRequest]) (*connect.Response[gen.TerminateResponse], error)
|
||||||
|
// GetSandboxMetrics returns ring buffer metrics for a running sandbox.
|
||||||
|
GetSandboxMetrics(context.Context, *connect.Request[gen.GetSandboxMetricsRequest]) (*connect.Response[gen.GetSandboxMetricsResponse], error)
|
||||||
|
// FlushSandboxMetrics returns all ring buffer tiers and clears them.
|
||||||
|
// Called by the control plane before pause/destroy to persist metrics to DB.
|
||||||
|
FlushSandboxMetrics(context.Context, *connect.Request[gen.FlushSandboxMetricsRequest]) (*connect.Response[gen.FlushSandboxMetricsResponse], error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHostAgentServiceClient constructs a client for the hostagent.v1.HostAgentService service. By
|
// NewHostAgentServiceClient constructs a client for the hostagent.v1.HostAgentService service. By
|
||||||
@ -218,6 +229,18 @@ func NewHostAgentServiceClient(httpClient connect.HTTPClient, baseURL string, op
|
|||||||
connect.WithSchema(hostAgentServiceMethods.ByName("Terminate")),
|
connect.WithSchema(hostAgentServiceMethods.ByName("Terminate")),
|
||||||
connect.WithClientOptions(opts...),
|
connect.WithClientOptions(opts...),
|
||||||
),
|
),
|
||||||
|
getSandboxMetrics: connect.NewClient[gen.GetSandboxMetricsRequest, gen.GetSandboxMetricsResponse](
|
||||||
|
httpClient,
|
||||||
|
baseURL+HostAgentServiceGetSandboxMetricsProcedure,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("GetSandboxMetrics")),
|
||||||
|
connect.WithClientOptions(opts...),
|
||||||
|
),
|
||||||
|
flushSandboxMetrics: connect.NewClient[gen.FlushSandboxMetricsRequest, gen.FlushSandboxMetricsResponse](
|
||||||
|
httpClient,
|
||||||
|
baseURL+HostAgentServiceFlushSandboxMetricsProcedure,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("FlushSandboxMetrics")),
|
||||||
|
connect.WithClientOptions(opts...),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,6 +261,8 @@ type hostAgentServiceClient struct {
|
|||||||
readFileStream *connect.Client[gen.ReadFileStreamRequest, gen.ReadFileStreamResponse]
|
readFileStream *connect.Client[gen.ReadFileStreamRequest, gen.ReadFileStreamResponse]
|
||||||
pingSandbox *connect.Client[gen.PingSandboxRequest, gen.PingSandboxResponse]
|
pingSandbox *connect.Client[gen.PingSandboxRequest, gen.PingSandboxResponse]
|
||||||
terminate *connect.Client[gen.TerminateRequest, gen.TerminateResponse]
|
terminate *connect.Client[gen.TerminateRequest, gen.TerminateResponse]
|
||||||
|
getSandboxMetrics *connect.Client[gen.GetSandboxMetricsRequest, gen.GetSandboxMetricsResponse]
|
||||||
|
flushSandboxMetrics *connect.Client[gen.FlushSandboxMetricsRequest, gen.FlushSandboxMetricsResponse]
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSandbox calls hostagent.v1.HostAgentService.CreateSandbox.
|
// CreateSandbox calls hostagent.v1.HostAgentService.CreateSandbox.
|
||||||
@ -315,6 +340,16 @@ func (c *hostAgentServiceClient) Terminate(ctx context.Context, req *connect.Req
|
|||||||
return c.terminate.CallUnary(ctx, req)
|
return c.terminate.CallUnary(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSandboxMetrics calls hostagent.v1.HostAgentService.GetSandboxMetrics.
|
||||||
|
func (c *hostAgentServiceClient) GetSandboxMetrics(ctx context.Context, req *connect.Request[gen.GetSandboxMetricsRequest]) (*connect.Response[gen.GetSandboxMetricsResponse], error) {
|
||||||
|
return c.getSandboxMetrics.CallUnary(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushSandboxMetrics calls hostagent.v1.HostAgentService.FlushSandboxMetrics.
|
||||||
|
func (c *hostAgentServiceClient) FlushSandboxMetrics(ctx context.Context, req *connect.Request[gen.FlushSandboxMetricsRequest]) (*connect.Response[gen.FlushSandboxMetricsResponse], error) {
|
||||||
|
return c.flushSandboxMetrics.CallUnary(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
// HostAgentServiceHandler is an implementation of the hostagent.v1.HostAgentService service.
|
// HostAgentServiceHandler is an implementation of the hostagent.v1.HostAgentService service.
|
||||||
type HostAgentServiceHandler interface {
|
type HostAgentServiceHandler interface {
|
||||||
// CreateSandbox boots a new microVM with the given configuration.
|
// CreateSandbox boots a new microVM with the given configuration.
|
||||||
@ -351,6 +386,11 @@ type HostAgentServiceHandler interface {
|
|||||||
// Called by the control plane immediately when a host is deleted so the
|
// Called by the control plane immediately when a host is deleted so the
|
||||||
// agent shuts down without waiting for the next heartbeat cycle.
|
// agent shuts down without waiting for the next heartbeat cycle.
|
||||||
Terminate(context.Context, *connect.Request[gen.TerminateRequest]) (*connect.Response[gen.TerminateResponse], error)
|
Terminate(context.Context, *connect.Request[gen.TerminateRequest]) (*connect.Response[gen.TerminateResponse], error)
|
||||||
|
// GetSandboxMetrics returns ring buffer metrics for a running sandbox.
|
||||||
|
GetSandboxMetrics(context.Context, *connect.Request[gen.GetSandboxMetricsRequest]) (*connect.Response[gen.GetSandboxMetricsResponse], error)
|
||||||
|
// FlushSandboxMetrics returns all ring buffer tiers and clears them.
|
||||||
|
// Called by the control plane before pause/destroy to persist metrics to DB.
|
||||||
|
FlushSandboxMetrics(context.Context, *connect.Request[gen.FlushSandboxMetricsRequest]) (*connect.Response[gen.FlushSandboxMetricsResponse], error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHostAgentServiceHandler builds an HTTP handler from the service implementation. It returns the
|
// NewHostAgentServiceHandler builds an HTTP handler from the service implementation. It returns the
|
||||||
@ -450,6 +490,18 @@ func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.Han
|
|||||||
connect.WithSchema(hostAgentServiceMethods.ByName("Terminate")),
|
connect.WithSchema(hostAgentServiceMethods.ByName("Terminate")),
|
||||||
connect.WithHandlerOptions(opts...),
|
connect.WithHandlerOptions(opts...),
|
||||||
)
|
)
|
||||||
|
hostAgentServiceGetSandboxMetricsHandler := connect.NewUnaryHandler(
|
||||||
|
HostAgentServiceGetSandboxMetricsProcedure,
|
||||||
|
svc.GetSandboxMetrics,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("GetSandboxMetrics")),
|
||||||
|
connect.WithHandlerOptions(opts...),
|
||||||
|
)
|
||||||
|
hostAgentServiceFlushSandboxMetricsHandler := connect.NewUnaryHandler(
|
||||||
|
HostAgentServiceFlushSandboxMetricsProcedure,
|
||||||
|
svc.FlushSandboxMetrics,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("FlushSandboxMetrics")),
|
||||||
|
connect.WithHandlerOptions(opts...),
|
||||||
|
)
|
||||||
return "/hostagent.v1.HostAgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return "/hostagent.v1.HostAgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case HostAgentServiceCreateSandboxProcedure:
|
case HostAgentServiceCreateSandboxProcedure:
|
||||||
@ -482,6 +534,10 @@ func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.Han
|
|||||||
hostAgentServicePingSandboxHandler.ServeHTTP(w, r)
|
hostAgentServicePingSandboxHandler.ServeHTTP(w, r)
|
||||||
case HostAgentServiceTerminateProcedure:
|
case HostAgentServiceTerminateProcedure:
|
||||||
hostAgentServiceTerminateHandler.ServeHTTP(w, r)
|
hostAgentServiceTerminateHandler.ServeHTTP(w, r)
|
||||||
|
case HostAgentServiceGetSandboxMetricsProcedure:
|
||||||
|
hostAgentServiceGetSandboxMetricsHandler.ServeHTTP(w, r)
|
||||||
|
case HostAgentServiceFlushSandboxMetricsProcedure:
|
||||||
|
hostAgentServiceFlushSandboxMetricsHandler.ServeHTTP(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@ -550,3 +606,11 @@ func (UnimplementedHostAgentServiceHandler) PingSandbox(context.Context, *connec
|
|||||||
func (UnimplementedHostAgentServiceHandler) Terminate(context.Context, *connect.Request[gen.TerminateRequest]) (*connect.Response[gen.TerminateResponse], error) {
|
func (UnimplementedHostAgentServiceHandler) Terminate(context.Context, *connect.Request[gen.TerminateRequest]) (*connect.Response[gen.TerminateResponse], error) {
|
||||||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.Terminate is not implemented"))
|
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.Terminate is not implemented"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (UnimplementedHostAgentServiceHandler) GetSandboxMetrics(context.Context, *connect.Request[gen.GetSandboxMetricsRequest]) (*connect.Response[gen.GetSandboxMetricsResponse], error) {
|
||||||
|
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.GetSandboxMetrics is not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedHostAgentServiceHandler) FlushSandboxMetrics(context.Context, *connect.Request[gen.FlushSandboxMetricsRequest]) (*connect.Response[gen.FlushSandboxMetricsResponse], error) {
|
||||||
|
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.FlushSandboxMetrics is not implemented"))
|
||||||
|
}
|
||||||
|
|||||||
@ -54,6 +54,13 @@ service HostAgentService {
|
|||||||
// agent shuts down without waiting for the next heartbeat cycle.
|
// agent shuts down without waiting for the next heartbeat cycle.
|
||||||
rpc Terminate(TerminateRequest) returns (TerminateResponse);
|
rpc Terminate(TerminateRequest) returns (TerminateResponse);
|
||||||
|
|
||||||
|
// GetSandboxMetrics returns ring buffer metrics for a running sandbox.
|
||||||
|
rpc GetSandboxMetrics(GetSandboxMetricsRequest) returns (GetSandboxMetricsResponse);
|
||||||
|
|
||||||
|
// FlushSandboxMetrics returns all ring buffer tiers and clears them.
|
||||||
|
// Called by the control plane before pause/destroy to persist metrics to DB.
|
||||||
|
rpc FlushSandboxMetrics(FlushSandboxMetricsRequest) returns (FlushSandboxMetricsResponse);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateSandboxRequest {
|
message CreateSandboxRequest {
|
||||||
@ -248,3 +255,32 @@ message PingSandboxResponse {}
|
|||||||
message TerminateRequest {}
|
message TerminateRequest {}
|
||||||
|
|
||||||
message TerminateResponse {}
|
message TerminateResponse {}
|
||||||
|
|
||||||
|
// ── Metrics ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message MetricPoint {
|
||||||
|
int64 timestamp_unix = 1;
|
||||||
|
double cpu_pct = 2;
|
||||||
|
int64 mem_bytes = 3;
|
||||||
|
int64 disk_bytes = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetSandboxMetricsRequest {
|
||||||
|
string sandbox_id = 1;
|
||||||
|
// Range tier: "10m", "2h", or "24h".
|
||||||
|
string range = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetSandboxMetricsResponse {
|
||||||
|
repeated MetricPoint points = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FlushSandboxMetricsRequest {
|
||||||
|
string sandbox_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FlushSandboxMetricsResponse {
|
||||||
|
repeated MetricPoint points_10m = 1;
|
||||||
|
repeated MetricPoint points_2h = 2;
|
||||||
|
repeated MetricPoint points_24h = 3;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user