forked from wrenn/wrenn
Add daily usage metrics (CPU-minutes, RAM GB-minutes)
Introduce pre-computed daily usage rollups from sandbox_metrics_snapshots. An hourly background worker aggregates completed days, while today's usage is computed live from snapshots at query time for freshness. Backend: new daily_usage table, rollup worker, UsageService, and GET /v1/capsules/usage endpoint with date range filtering (up to 92 days). Frontend: replace Usage page placeholder with bar charts (Chart.js), summary total cards, and preset/custom date range controls.
This commit is contained in:
@ -1,39 +1,287 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { fetchUsage, defaultRange, formatDate, type UsageResponse } from '$lib/api/usage';
|
||||
|
||||
type EndpointStatus = 'loading' | 'available' | 'not_available' | 'error';
|
||||
let status = $state<EndpointStatus>('loading');
|
||||
let errorMsg = $state<string | null>(null);
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function probe() {
|
||||
status = 'loading';
|
||||
errorMsg = null;
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
||||
const PRESETS = ['7d', '30d', '90d'] as const;
|
||||
type Preset = (typeof PRESETS)[number];
|
||||
|
||||
const res = await fetch('/api/v1/usage', { headers });
|
||||
if (res.status === 404) {
|
||||
status = 'not_available';
|
||||
} else if (!res.ok) {
|
||||
status = 'error';
|
||||
try {
|
||||
const data = await res.json();
|
||||
errorMsg = data?.error?.message ?? `Server returned ${res.status}`;
|
||||
} catch {
|
||||
errorMsg = `Server returned ${res.status}`;
|
||||
}
|
||||
} else {
|
||||
status = 'available';
|
||||
}
|
||||
} catch {
|
||||
status = 'error';
|
||||
errorMsg = 'Unable to connect to the server';
|
||||
let preset = $state<Preset | null>('30d');
|
||||
let fromInput = $state('');
|
||||
let toInput = $state('');
|
||||
let data = $state<UsageResponse | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let canvasCpu = $state<HTMLCanvasElement | null>(null);
|
||||
let canvasRam = $state<HTMLCanvasElement | null>(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;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let ChartJS: any = null;
|
||||
|
||||
// ─── Derived ──────────────────────────────────────────────────────────────
|
||||
|
||||
let totalCpuMinutes = $derived(
|
||||
data?.points.reduce((sum, p) => sum + p.cpu_minutes, 0) ?? 0
|
||||
);
|
||||
let totalRamGBMinutes = $derived(
|
||||
(data?.points.reduce((sum, p) => sum + p.ram_mb_minutes, 0) ?? 0) / 1024
|
||||
);
|
||||
|
||||
// ─── Chart config ─────────────────────────────────────────────────────────
|
||||
|
||||
const C_BLUE = '#5a9fd4';
|
||||
const C_BLUE_FILL = 'rgba(90,159,212,0.55)';
|
||||
const C_AMBER = '#d4a73c';
|
||||
const C_AMBER_FILL = 'rgba(212,167,60,0.55)';
|
||||
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: { display: false },
|
||||
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 }, maxTicksLimit: 12, 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────────────────────
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
const result = await fetchUsage(fromInput, toInput);
|
||||
if (result.ok) {
|
||||
data = result.data;
|
||||
} else {
|
||||
error = result.error;
|
||||
}
|
||||
loading = false;
|
||||
updateCharts();
|
||||
}
|
||||
|
||||
let boundCpuCanvas: HTMLCanvasElement | null = null;
|
||||
let boundRamCanvas: HTMLCanvasElement | null = null;
|
||||
|
||||
function initCharts() {
|
||||
if (!ChartJS || !canvasCpu || !canvasRam) return;
|
||||
// Skip if already bound to these exact canvas elements.
|
||||
if (boundCpuCanvas === canvasCpu && boundRamCanvas === canvasRam) {
|
||||
updateCharts();
|
||||
return;
|
||||
}
|
||||
// Destroy stale instances if canvases were re-mounted.
|
||||
chartCpu?.destroy();
|
||||
chartRam?.destroy();
|
||||
boundCpuCanvas = canvasCpu;
|
||||
boundRamCanvas = canvasRam;
|
||||
|
||||
chartCpu = new ChartJS(canvasCpu, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: C_BLUE_FILL,
|
||||
borderColor: C_BLUE,
|
||||
borderWidth: 1,
|
||||
borderRadius: 2,
|
||||
hoverBackgroundColor: 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)} min`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
...BASE_CHART_OPTIONS.scales,
|
||||
y: {
|
||||
...BASE_CHART_OPTIONS.scales.y,
|
||||
ticks: {
|
||||
...BASE_CHART_OPTIONS.scales.y.ticks,
|
||||
callback: (v: string | number) => `${(+v).toFixed(0)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
chartRam = new ChartJS(canvasRam, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: C_AMBER_FILL,
|
||||
borderColor: C_AMBER,
|
||||
borderWidth: 1,
|
||||
borderRadius: 2,
|
||||
hoverBackgroundColor: 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(2)} GB-min`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
...BASE_CHART_OPTIONS.scales,
|
||||
y: {
|
||||
...BASE_CHART_OPTIONS.scales.y,
|
||||
ticks: {
|
||||
...BASE_CHART_OPTIONS.scales.y.ticks,
|
||||
callback: (v: string | number) => `${(+v).toFixed(1)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
updateCharts();
|
||||
}
|
||||
|
||||
function updateCharts() {
|
||||
if (!data) return;
|
||||
|
||||
// Build a lookup from date string → point for O(1) access.
|
||||
const pointMap = new Map(data.points.map((p) => [p.date, p]));
|
||||
|
||||
// Generate a label + value for every day in the range so bars are
|
||||
// evenly distributed and days with no usage show as zero.
|
||||
const labels: string[] = [];
|
||||
const cpuData: number[] = [];
|
||||
const ramData: number[] = [];
|
||||
|
||||
// Use UTC dates to avoid timezone-induced date shifts when
|
||||
// comparing against the YYYY-MM-DD keys from the API.
|
||||
const from = new Date(fromInput + 'T00:00:00Z');
|
||||
const to = new Date(toInput + 'T00:00:00Z');
|
||||
for (const d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const pt = pointMap.get(key);
|
||||
labels.push(new Date(key + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' }));
|
||||
cpuData.push(pt ? +pt.cpu_minutes.toFixed(2) : 0);
|
||||
ramData.push(pt ? +(pt.ram_mb_minutes / 1024).toFixed(2) : 0);
|
||||
}
|
||||
|
||||
if (chartCpu) {
|
||||
chartCpu.data.labels = labels;
|
||||
chartCpu.data.datasets[0].data = cpuData;
|
||||
chartCpu.update();
|
||||
}
|
||||
if (chartRam) {
|
||||
chartRam.data.labels = labels;
|
||||
chartRam.data.datasets[0].data = ramData;
|
||||
chartRam.update();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(probe);
|
||||
// ─── Range controls ───────────────────────────────────────────────────────
|
||||
|
||||
function applyPreset(p: Preset) {
|
||||
preset = p;
|
||||
const to = new Date();
|
||||
const from = new Date(to);
|
||||
const days = p === '7d' ? 6 : p === '30d' ? 29 : 89;
|
||||
from.setDate(from.getDate() - days);
|
||||
fromInput = formatDate(from);
|
||||
toInput = formatDate(to);
|
||||
load();
|
||||
}
|
||||
|
||||
function onDateChange() {
|
||||
// Clear preset highlight when custom dates are used
|
||||
const to = new Date();
|
||||
const from7 = new Date(to); from7.setDate(from7.getDate() - 6);
|
||||
const from30 = new Date(to); from30.setDate(from30.getDate() - 29);
|
||||
const from90 = new Date(to); from90.setDate(from90.getDate() - 89);
|
||||
const todayStr = formatDate(to);
|
||||
|
||||
if (toInput === todayStr) {
|
||||
if (fromInput === formatDate(from7)) preset = '7d';
|
||||
else if (fromInput === formatDate(from30)) preset = '30d';
|
||||
else if (fromInput === formatDate(from90)) preset = '90d';
|
||||
else preset = null;
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
// ─── Formatting ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmtNumber(n: number): string {
|
||||
if (n >= 1000) return n.toLocaleString('en-US', { maximumFractionDigits: 1 });
|
||||
return n.toFixed(1);
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
// When canvas elements appear in the DOM (after data loads), init charts.
|
||||
$effect(() => {
|
||||
if (canvasCpu && canvasRam && ChartJS) {
|
||||
initCharts();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const { from, to } = defaultRange();
|
||||
fromInput = from;
|
||||
toInput = to;
|
||||
|
||||
const mod = await import('chart.js/auto');
|
||||
ChartJS = mod.Chart;
|
||||
|
||||
await load();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
chartCpu?.destroy();
|
||||
chartRam?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@ -41,77 +289,170 @@
|
||||
</svelte:head>
|
||||
|
||||
<main class="flex-1 overflow-y-auto bg-[var(--color-bg-0)]">
|
||||
<!-- Header -->
|
||||
<div class="px-7 pt-8">
|
||||
<h1 class="font-serif text-page text-[var(--color-text-bright)]">
|
||||
Usage
|
||||
</h1>
|
||||
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
|
||||
Resource consumption and execution metrics across your team.
|
||||
|
||||
<!-- Header -->
|
||||
<div class="px-7 pt-8">
|
||||
<h1 class="font-serif text-page text-[var(--color-text-bright)]">
|
||||
Usage
|
||||
</h1>
|
||||
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
|
||||
Resource consumption and execution metrics across your team.
|
||||
</p>
|
||||
<div class="mt-6 border-b border-[var(--color-border)]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-8" style="animation: fadeUp 0.35s ease both">
|
||||
|
||||
<!-- Controls row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Preset range selector -->
|
||||
<div class="flex overflow-hidden rounded-[var(--radius-button)] border border-[var(--color-border)]">
|
||||
{#each PRESETS as p, i}
|
||||
<button
|
||||
onclick={() => applyPreset(p)}
|
||||
class="px-3 py-1.5 font-mono text-label transition-colors duration-150
|
||||
{preset === p
|
||||
? '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)]' : ''}"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Date inputs -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
bind:value={fromInput}
|
||||
onchange={onDateChange}
|
||||
class="rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2.5 py-1.5 font-mono text-label text-[var(--color-text-secondary)] transition-colors duration-150 focus:border-[var(--color-accent)] focus:outline-none"
|
||||
/>
|
||||
<span class="text-meta text-[var(--color-text-muted)]">to</span>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={toInput}
|
||||
onchange={onDateChange}
|
||||
class="rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2.5 py-1.5 font-mono text-label text-[var(--color-text-secondary)] transition-colors duration-150 focus:border-[var(--color-accent)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
{#if error}
|
||||
<div class="mt-6 flex items-center justify-between gap-4 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onclick={load}
|
||||
class="shrink-0 font-semibold underline-offset-2 hover:underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<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 usage data...
|
||||
</div>
|
||||
</div>
|
||||
{:else if data && data.points.length === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="flex flex-col items-center justify-center py-[72px]">
|
||||
<div class="relative mb-5">
|
||||
<div class="absolute inset-0 -m-6 rounded-full" style="background: radial-gradient(circle, rgba(90,159,212,0.06) 0%, transparent 70%)"></div>
|
||||
<div class="relative flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-blue)]/20 bg-[var(--color-bg-3)]" style="animation: iconFloat 4s ease-in-out infinite">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-blue)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="12" width="4" height="9" rx="1" />
|
||||
<rect x="10" y="7" width="4" height="14" rx="1" />
|
||||
<rect x="17" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-serif text-heading text-[var(--color-text-bright)]">
|
||||
No usage data yet
|
||||
</p>
|
||||
<div class="mt-6 border-b border-[var(--color-border)]"></div>
|
||||
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
|
||||
Usage will appear here once capsules have been running.
|
||||
</p>
|
||||
</div>
|
||||
{:else if data}
|
||||
<!-- Summary cards -->
|
||||
<div class="mt-6 grid grid-cols-2 overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]">
|
||||
|
||||
<!-- CPU-minutes total -->
|
||||
<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)]">Total CPU-Minutes</span>
|
||||
</div>
|
||||
<div class="bg-[var(--color-bg-3)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
|
||||
<div class="font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
|
||||
{fmtNumber(totalCpuMinutes)}
|
||||
</div>
|
||||
<div class="mt-2 font-mono text-meta text-[var(--color-text-muted)]">
|
||||
{fromInput} — {toInput}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RAM GB-minutes total -->
|
||||
<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)]">Total RAM GB-Minutes</span>
|
||||
</div>
|
||||
<div class="bg-[var(--color-bg-3)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
|
||||
<div class="font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
|
||||
{fmtNumber(totalRamGBMinutes)}
|
||||
</div>
|
||||
<div class="mt-2 font-mono text-meta text-[var(--color-text-muted)]">
|
||||
{fromInput} — {toInput}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-8" style="animation: fadeUp 0.35s ease both">
|
||||
{#if status === 'loading'}
|
||||
<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 usage data...
|
||||
</div>
|
||||
</div>
|
||||
{:else if status === 'error'}
|
||||
<div class="mb-4 flex items-center justify-between gap-4 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]">
|
||||
<span>{errorMsg}</span>
|
||||
<button
|
||||
onclick={probe}
|
||||
class="shrink-0 font-semibold underline-offset-2 hover:underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
{:else if status === 'not_available'}
|
||||
<div class="flex flex-col items-center justify-center py-[72px]">
|
||||
<!-- Icon with glow -->
|
||||
<div class="relative mb-5">
|
||||
<div class="absolute inset-0 -m-6 rounded-full" style="background: radial-gradient(circle, rgba(90,159,212,0.06) 0%, transparent 70%)"></div>
|
||||
<div class="relative flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-blue)]/20 bg-[var(--color-bg-3)]" style="animation: iconFloat 4s ease-in-out infinite">
|
||||
<!-- Usage/chart icon -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-blue)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-serif text-heading text-[var(--color-text-bright)]">
|
||||
Cloud Feature
|
||||
</p>
|
||||
<p class="mt-2 max-w-sm text-center text-ui leading-relaxed text-[var(--color-text-tertiary)]">
|
||||
Usage tracking is available on Wrenn Cloud.
|
||||
</p>
|
||||
<!-- Charts -->
|
||||
<div class="mt-6 flex flex-col gap-5">
|
||||
|
||||
<!-- Info badge -->
|
||||
<div class="mt-6 flex items-center gap-2.5 rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-4 py-3">
|
||||
<svg class="shrink-0" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
<span class="text-meta text-[var(--color-text-secondary)]">
|
||||
This instance is running in self-hosted mode
|
||||
</span>
|
||||
<!-- CPU chart -->
|
||||
<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 · Minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Available state — placeholder for when the endpoint exists -->
|
||||
<div class="text-ui text-[var(--color-text-secondary)]">
|
||||
Usage data will be displayed here.
|
||||
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 260px">
|
||||
<canvas bind:this={canvasCpu}></canvas>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- RAM chart -->
|
||||
<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-Minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 260px">
|
||||
<canvas bind:this={canvasRam}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
{/if}
|
||||
</div>
|
||||
</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">
|
||||
@ -122,3 +463,14 @@
|
||||
<span class="font-mono text-label uppercase tracking-[0.04em] text-[var(--color-text-secondary)]">All systems operational</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
/* Dark theme date input overrides */
|
||||
input[type='date']::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.6);
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type='date']::-webkit-datetime-edit {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user