mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Visual pass: inspiration-style metric cards on Usage and service detail
- Service detail strip: EnhancedMetricCard with CPU sparkline, memory gauge, RX-rate sparkline, instance count — real telemetry or honest empty states, seeded series only in demo mode - Usage: host monitoring becomes CPU sparkline + memory gauge + disk segmented-bar trio; build stats upgraded to EnhancedMetricCard with a real builds-per-day bar chart - DonutChart: fix gauge geometry — arc baseline was below the viewBox, so only a sliver rendered; also fix SegmentedBar dynamic Tailwind class that never compiled - auth sidecar: trust localhost:5175 origin for the second dev server Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
9f3b196032
commit
f2d980e384
4 files changed
+202
-80
No files matched your search
@@ -78,8 +78,10 @@ const trustedOrigins = [
|
||||
'http://localhost:3001',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://localhost:5175',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:5174',
|
||||
'http://127.0.0.1:5175',
|
||||
]
|
||||
.map((origin) => (origin || '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@/lib/api-client';
|
||||
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
|
||||
import { useBuildUpdates } from '@/lib/use-build-updates';
|
||||
import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart } from '@/shared/components';
|
||||
import {
|
||||
Clock,
|
||||
Activity,
|
||||
@@ -47,6 +48,8 @@ import {
|
||||
GitBranch,
|
||||
Link2,
|
||||
Unlink,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
} from 'lucide-react';
|
||||
|
||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||
@@ -207,6 +210,52 @@ export function UsagePage() {
|
||||
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
|
||||
const host = hostQuery.data;
|
||||
const agents = agentsQuery.data ?? [];
|
||||
|
||||
// Accumulate host samples for the load sparkline (polled every 15s).
|
||||
const [loadHistory, setLoadHistory] = useState<number[]>([]);
|
||||
useEffect(() => {
|
||||
const sample = hostQuery.data;
|
||||
if (!sample || sample.cpu.cores <= 0) {
|
||||
return;
|
||||
}
|
||||
const loadPercent = Math.min(100, (sample.load.load1m / sample.cpu.cores) * 100);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
|
||||
setLoadHistory((prev) => [...prev, loadPercent].slice(-24));
|
||||
}, [hostQuery.data]);
|
||||
|
||||
const hostLoadPercent = host && host.cpu.cores > 0
|
||||
? Math.min(100, (host.load.load1m / host.cpu.cores) * 100)
|
||||
: 0;
|
||||
|
||||
const hostStatus = (percent: number): 'good' | 'average' | 'warning' =>
|
||||
percent < 50 ? 'good' : percent < 80 ? 'average' : 'warning';
|
||||
|
||||
// Builds per day over the last 14 days, normalized to bar heights.
|
||||
// Anchored on the newest build timestamp so the chart is render-pure.
|
||||
const buildsPerDay = useMemo(() => {
|
||||
const days = 14;
|
||||
const counts = new Array<number>(days).fill(0);
|
||||
const timestamps = (buildsQuery.data?.builds ?? [])
|
||||
.map((build) => new Date((build.startedAt ?? build.completedAt) as string).getTime())
|
||||
.filter((t) => Number.isFinite(t));
|
||||
if (timestamps.length === 0) {
|
||||
return counts;
|
||||
}
|
||||
const anchor = Math.max(...timestamps);
|
||||
(buildsQuery.data?.builds ?? []).forEach((build) => {
|
||||
const timestamp = build.startedAt ?? build.completedAt;
|
||||
if (!timestamp) {
|
||||
return;
|
||||
}
|
||||
const created = new Date(timestamp).getTime();
|
||||
const dayIndex = days - 1 - Math.floor((anchor - created) / 86_400_000);
|
||||
if (dayIndex >= 0 && dayIndex < days) {
|
||||
counts[dayIndex] += 1;
|
||||
}
|
||||
});
|
||||
const peak = Math.max(...counts, 1);
|
||||
return counts.map((count) => (count / peak) * 100);
|
||||
}, [buildsQuery.data]);
|
||||
const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length;
|
||||
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
|
||||
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
||||
@@ -276,26 +325,30 @@ containr-agent`;
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
<EnhancedMetricCard
|
||||
title="Runtime Hours"
|
||||
value={totalBuildHours > 0 ? `${totalBuildHours.toFixed(1)}h` : '—'}
|
||||
description={runtimeBody}
|
||||
icon={Clock}
|
||||
color={completedDurationsMs.length > 0 ? 'success' : 'default'}
|
||||
statusText={completedDurationsMs.length > 0 ? 'Active' : ''}
|
||||
status="good"
|
||||
subtitle={runtimeBody}
|
||||
icon={<Clock size={18} />}
|
||||
/>
|
||||
<StatCard
|
||||
<EnhancedMetricCard
|
||||
title="Build Activity"
|
||||
value={String(builds.length)}
|
||||
description={buildActivityBody}
|
||||
icon={Activity}
|
||||
color={failedBuilds > 0 ? 'warning' : 'success'}
|
||||
status={failedBuilds > 0 ? 'average' : 'good'}
|
||||
statusText={failedBuilds > 0 ? `${failedBuilds} failed` : 'Healthy'}
|
||||
subtitle={buildActivityBody}
|
||||
icon={<Activity size={18} />}
|
||||
chart={<BarChart data={buildsPerDay} color="#e8316a" height={72} gap={3} />}
|
||||
/>
|
||||
<StatCard
|
||||
<EnhancedMetricCard
|
||||
title="Capacity"
|
||||
value={String(activeBuildPressure)}
|
||||
description={capacityBody}
|
||||
icon={Gauge}
|
||||
color={activeBuildPressure > 0 ? 'warning' : 'default'}
|
||||
status={activeBuildPressure > 0 ? 'average' : 'good'}
|
||||
statusText={activeBuildPressure > 0 ? 'Busy' : 'Idle'}
|
||||
subtitle={capacityBody}
|
||||
icon={<Gauge size={18} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -307,7 +360,11 @@ containr-agent`;
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Host Monitoring</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Autoscaling base signal from this Containr host.</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
{host
|
||||
? `${host.hostname} · ${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}${host.dockerAvailable ? '' : ' · Docker unavailable'}`
|
||||
: 'Autoscaling base signal from this Containr host.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -327,34 +384,57 @@ containr-agent`;
|
||||
Host monitoring unavailable.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title="Host"
|
||||
value={host?.hostname || '—'}
|
||||
description={host ? `${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}` : 'Loading host identity.'}
|
||||
icon={Server}
|
||||
color={host?.dockerAvailable ? 'success' : 'warning'}
|
||||
/>
|
||||
<StatCard
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<EnhancedMetricCard
|
||||
title="CPU Load"
|
||||
icon={<Cpu size={18} />}
|
||||
value={host ? host.load.load1m.toFixed(2) : '—'}
|
||||
description={host ? `${host.cpu.cores} cores · 5m ${host.load.load5m.toFixed(2)} · 15m ${host.load.load15m.toFixed(2)}` : 'Loading CPU load.'}
|
||||
icon={Gauge}
|
||||
color={host && host.load.load1m > host.cpu.cores ? 'warning' : 'default'}
|
||||
status={hostStatus(hostLoadPercent)}
|
||||
statusText={host ? { good: 'Good', average: 'Average', warning: 'High' }[hostStatus(hostLoadPercent)] : ''}
|
||||
subtitle={host ? `${host.cpu.cores} cores · 5m ${host.load.load5m.toFixed(2)} · 15m ${host.load.load15m.toFixed(2)}` : 'Loading CPU telemetry.'}
|
||||
chart={<LineAreaChart data={loadHistory.length > 0 ? loadHistory : [0]} color="#ff7043" height={72} />}
|
||||
/>
|
||||
<StatCard
|
||||
<EnhancedMetricCard
|
||||
title="Memory"
|
||||
icon={<MemoryStick size={18} />}
|
||||
value={host ? `${host.memory.usagePercent.toFixed(0)}%` : '—'}
|
||||
description={host ? `${formatBytes(host.memory.used)} used · ${formatBytes(host.memory.available)} free` : 'Loading memory telemetry.'}
|
||||
icon={Activity}
|
||||
color={host && host.memory.usagePercent > 80 ? 'warning' : 'success'}
|
||||
status={hostStatus(host?.memory.usagePercent ?? 0)}
|
||||
statusText={host ? { good: 'Good', average: 'Average', warning: 'High' }[hostStatus(host.memory.usagePercent)] : ''}
|
||||
subtitle={host ? `${formatBytes(host.memory.available)} free` : 'Loading memory telemetry.'}
|
||||
chart={
|
||||
<div className="relative mx-auto" style={{ width: 150 }}>
|
||||
<DonutChart percentage={host?.memory.usagePercent ?? 0} color="#9c7ef0" size={150} thickness={14} />
|
||||
<div className="absolute inset-x-0 bottom-0 text-center">
|
||||
<div className="text-[10px] uppercase tracking-wide text-[#6b6e7d]">Used</div>
|
||||
<div className="text-sm font-bold text-[#e8e9f0]">
|
||||
{host ? `${formatBytes(host.memory.used)} / ${formatBytes(host.memory.total)}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
<EnhancedMetricCard
|
||||
title="Disk"
|
||||
icon={<HardDrive size={18} />}
|
||||
value={host ? `${host.storage.usagePercent.toFixed(0)}%` : '—'}
|
||||
description={host ? `${formatBytes(host.storage.used)} used · ${formatBytes(host.storage.available)} free on ${host.storage.path}` : 'Loading disk telemetry.'}
|
||||
icon={HardDrive}
|
||||
color={host && host.storage.usagePercent > 80 ? 'warning' : 'success'}
|
||||
status={hostStatus(host?.storage.usagePercent ?? 0)}
|
||||
statusText={host ? { good: 'Good', average: 'Average', warning: 'High' }[hostStatus(host.storage.usagePercent)] : ''}
|
||||
subtitle={host ? `Mounted at ${host.storage.path}` : 'Loading disk telemetry.'}
|
||||
chart={
|
||||
<div>
|
||||
<SegmentedBar
|
||||
segments={[
|
||||
{ width: host?.storage.usagePercent ?? 0, color: '#ff8a5c' },
|
||||
{ width: Math.max(2, 100 - (host?.storage.usagePercent ?? 0)), color: 'rgba(255,255,255,0.07)' },
|
||||
]}
|
||||
height={18}
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-4 text-xs text-[#6b6e7d]">
|
||||
<span><span className="mr-1.5 inline-block h-2 w-2 rounded-full" style={{ background: '#ff8a5c' }} />{host ? `${formatBytes(host.storage.used)} used` : '—'}</span>
|
||||
<span><span className="mr-1.5 inline-block h-2 w-2 rounded-full bg-white/10" />{host ? `${formatBytes(host.storage.available)} free` : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data';
|
||||
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
|
||||
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
|
||||
import {
|
||||
Activity,
|
||||
FileText,
|
||||
@@ -42,26 +43,14 @@ const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Act
|
||||
{ key: 'settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
function MetricCard({ label, value, hint, icon: Icon, trend }: { label: string; value: string; hint: string; icon: typeof Cpu; trend?: 'up' | 'down' | 'stable' }) {
|
||||
return (
|
||||
<div className="panel p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Icon size={16} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
{trend && (
|
||||
<div className={`text-xs ${trend === 'up' ? 'text-[var(--success)]' : trend === 'down' ? 'text-[var(--error)]' : 'text-[var(--text-tertiary)]'}`}>
|
||||
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-[var(--text-primary)]">{value}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">{label}</p>
|
||||
<p className="text-[10px] text-[var(--text-tertiary)] mt-0.5">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
function metricStatus(percent: number): 'good' | 'average' | 'warning' {
|
||||
if (percent < 50) return 'good';
|
||||
if (percent < 80) return 'average';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
const statusLabel = { good: 'Good', average: 'Average', warning: 'High' } as const;
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = {
|
||||
running: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
|
||||
@@ -176,7 +165,8 @@ export function ServiceDetailPage() {
|
||||
});
|
||||
|
||||
// Accumulate live samples into a small history for the timeline charts.
|
||||
const [metricHistory, setMetricHistory] = useState<{ cpu: number[]; memory: number[] }>({ cpu: [], memory: [] });
|
||||
const [metricHistory, setMetricHistory] = useState<{ cpu: number[]; memory: number[]; net: number[] }>({ cpu: [], memory: [], net: [] });
|
||||
const lastNetSampleRef = useRef<{ rx: number; at: number } | null>(null);
|
||||
useEffect(() => {
|
||||
const sample = metricsQuery.data;
|
||||
if (!sample || sample.status !== 'ok') {
|
||||
@@ -185,10 +175,18 @@ export function ServiceDetailPage() {
|
||||
const memPercent = sample.memory_limit_bytes > 0
|
||||
? (sample.memory_usage_bytes / sample.memory_limit_bytes) * 100
|
||||
: 0;
|
||||
const now = Date.now();
|
||||
const previous = lastNetSampleRef.current;
|
||||
const elapsedSec = previous ? Math.max(1, (now - previous.at) / 1000) : 0;
|
||||
const rxRate = previous && elapsedSec > 0
|
||||
? Math.max(0, (sample.network_rx_bytes - previous.rx) / elapsedSec)
|
||||
: 0;
|
||||
lastNetSampleRef.current = { rx: sample.network_rx_bytes, at: now };
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
|
||||
setMetricHistory((prev) => ({
|
||||
cpu: [...prev.cpu, Math.min(100, sample.cpu_percent)].slice(-24),
|
||||
memory: [...prev.memory, Math.min(100, memPercent)].slice(-24),
|
||||
net: [...prev.net, rxRate].slice(-24),
|
||||
}));
|
||||
}, [metricsQuery.data]);
|
||||
|
||||
@@ -394,31 +392,70 @@ export function ServiceDetailPage() {
|
||||
{/* Metrics Overview */}
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
label="CPU"
|
||||
<EnhancedMetricCard
|
||||
title="CPU Usage"
|
||||
icon={<Cpu size={18} />}
|
||||
value={isDemoMode ? `${metricSet?.cpu ?? 0}%` : hasLiveTelemetry ? `${liveMetrics!.cpu_percent.toFixed(1)}%` : '—'}
|
||||
hint={isDemoMode ? 'Current utilization' : hasLiveTelemetry ? `${runningInstances} running container${runningInstances === 1 ? '' : 's'}` : 'No running containers'}
|
||||
icon={Cpu}
|
||||
trend={hasLiveTelemetry && liveMetrics!.cpu_percent > 60 ? 'up' : 'stable'}
|
||||
status={metricStatus(isDemoMode ? metricSet?.cpu ?? 0 : liveMetrics?.cpu_percent ?? 0)}
|
||||
statusText={isDemoMode || hasLiveTelemetry ? statusLabel[metricStatus(isDemoMode ? metricSet?.cpu ?? 0 : liveMetrics?.cpu_percent ?? 0)] : ''}
|
||||
subtitle={isDemoMode ? 'Current utilization' : hasLiveTelemetry ? `${runningInstances} running container${runningInstances === 1 ? '' : 's'}` : 'No running containers'}
|
||||
chart={
|
||||
<LineAreaChart
|
||||
data={isDemoMode
|
||||
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:cpu:${i}`, 8, 80))
|
||||
: metricHistory.cpu.length > 0 ? metricHistory.cpu : [0]}
|
||||
color="#ff7043"
|
||||
height={72}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Memory"
|
||||
value={isDemoMode ? `${metricSet?.memory ?? 0}%` : hasLiveTelemetry ? formatBytes(liveMetrics!.memory_usage_bytes) : '—'}
|
||||
hint={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${memoryPercent.toFixed(0)}% of ${formatBytes(liveMetrics!.memory_limit_bytes)}` : 'No running containers'}
|
||||
icon={MemoryStick}
|
||||
trend={hasLiveTelemetry && memoryPercent > 70 ? 'up' : 'stable'}
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Network"
|
||||
value={isDemoMode ? `${metricSet?.req ?? 0}` : hasLiveTelemetry ? `${formatBytes(liveMetrics!.network_rx_bytes)}` : '—'}
|
||||
hint={isDemoMode ? 'Requests, last 60 minutes' : hasLiveTelemetry ? `RX total · TX ${formatBytes(liveMetrics!.network_tx_bytes)}` : 'No traffic recorded'}
|
||||
icon={Zap}
|
||||
<EnhancedMetricCard
|
||||
title="Memory"
|
||||
icon={<MemoryStick size={18} />}
|
||||
value={isDemoMode ? `${metricSet?.memory ?? 0}%` : hasLiveTelemetry ? `${memoryPercent.toFixed(0)}%` : '—'}
|
||||
status={metricStatus(isDemoMode ? metricSet?.memory ?? 0 : memoryPercent)}
|
||||
statusText={isDemoMode || hasLiveTelemetry ? statusLabel[metricStatus(isDemoMode ? metricSet?.memory ?? 0 : memoryPercent)] : ''}
|
||||
subtitle={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${formatBytes(liveMetrics!.memory_usage_bytes)} used` : 'No running containers'}
|
||||
chart={
|
||||
<div className="relative mx-auto" style={{ width: 150 }}>
|
||||
<DonutChart percentage={isDemoMode ? metricSet?.memory ?? 0 : memoryPercent} color="#9c7ef0" size={150} thickness={14} />
|
||||
<div className="absolute inset-x-0 bottom-0 text-center">
|
||||
<div className="text-[10px] uppercase tracking-wide text-[#6b6e7d]">Used</div>
|
||||
<div className="text-sm font-bold text-[#e8e9f0]">
|
||||
{isDemoMode
|
||||
? `${((8 * (metricSet?.memory ?? 0)) / 100).toFixed(1)} GB / 8 GB`
|
||||
: hasLiveTelemetry
|
||||
? `${formatBytes(liveMetrics!.memory_usage_bytes)} / ${formatBytes(liveMetrics!.memory_limit_bytes)}`
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Instances"
|
||||
<EnhancedMetricCard
|
||||
title="Network"
|
||||
icon={<Zap size={18} />}
|
||||
value={isDemoMode ? `${metricSet?.req ?? 0}` : hasLiveTelemetry ? formatBytes(liveMetrics!.network_rx_bytes) : '—'}
|
||||
status="good"
|
||||
statusText={isDemoMode || hasLiveTelemetry ? 'RX' : ''}
|
||||
subtitle={isDemoMode ? 'Requests, last 60 minutes' : hasLiveTelemetry ? `TX ${formatBytes(liveMetrics!.network_tx_bytes)}` : 'No traffic recorded'}
|
||||
chart={
|
||||
<LineAreaChart
|
||||
data={isDemoMode
|
||||
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:net:${i}`, 15, 70))
|
||||
: metricHistory.net.length > 0 ? metricHistory.net : [0]}
|
||||
color="#6c8ef0"
|
||||
height={72}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Instances"
|
||||
icon={<Timer size={18} />}
|
||||
value={isDemoMode ? `${metricSet?.latency ?? 0}ms` : hasLiveTelemetry ? `${runningInstances}` : '0'}
|
||||
hint={isDemoMode ? 'P95 latency estimate' : hasLiveTelemetry ? `${liveMetrics!.instances.length} container${liveMetrics!.instances.length === 1 ? '' : 's'} discovered` : 'Deploy to create containers'}
|
||||
icon={Timer}
|
||||
statusText={hasLiveTelemetry || isDemoMode ? 'Running' : ''}
|
||||
status="good"
|
||||
subtitle={isDemoMode ? 'P95 latency estimate' : hasLiveTelemetry ? `${liveMetrics!.instances.length} container${liveMetrics!.instances.length === 1 ? '' : 's'} discovered` : 'Deploy to create containers'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -75,10 +75,13 @@ export function DonutChart({
|
||||
}
|
||||
}, [animated, isVisible, resolvedPercent]);
|
||||
|
||||
const R = size / 2 - 4;
|
||||
const r = R - thickness;
|
||||
// Semicircle gauge: arc baseline sits at the bottom of the viewBox so the
|
||||
// whole arc is visible. Radius is capped by both half-width and height.
|
||||
const svgHeight = size * 0.55;
|
||||
const cx = size / 2;
|
||||
const cy = size - 8;
|
||||
const cy = svgHeight - 2;
|
||||
const R = Math.min(size / 2 - 4, svgHeight - thickness - 6);
|
||||
const r = R - thickness;
|
||||
|
||||
const filled = Math.round(segments * (animatedPercent / 100));
|
||||
|
||||
@@ -110,8 +113,8 @@ export function DonutChart({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative" style={{ width: size, height: size * 0.6 }}>
|
||||
<svg width={size} height={size * 0.6} viewBox={`0 0 ${size} ${size * 0.6}`}>
|
||||
<div ref={containerRef} className="relative" style={{ width: size, height: svgHeight }}>
|
||||
<svg width={size} height={svgHeight} viewBox={`0 0 ${size} ${svgHeight}`}>
|
||||
{segmentPaths}
|
||||
</svg>
|
||||
</div>
|
||||
@@ -126,7 +129,7 @@ interface SegmentedBarProps {
|
||||
|
||||
export function SegmentedBar({ segments, height = 32, className = '' }: SegmentedBarProps) {
|
||||
return (
|
||||
<div className={`flex items-center gap-[5px] h-[${height}px] ${className}`}>
|
||||
<div className={`flex items-center gap-[5px] ${className}`} style={{ height }}>
|
||||
{segments.map((seg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
|
||||
Reference in new issue
Block a user