1042 lines
46 KiB
TypeScript
1042 lines
46 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import {
|
|
OperationsInfrastructureData,
|
|
ServiceStatusValue,
|
|
ComponentHealthStatus,
|
|
OperationsRole,
|
|
InfrastructureSpeedtestResult,
|
|
SpeedtestMode,
|
|
} from './types';
|
|
import { normalizeInfrastructureResponse } from '../../lib/ops-data';
|
|
import { OperationsActions } from './operations-actions';
|
|
|
|
interface OperationsInfrastructureProps {
|
|
role: OperationsRole;
|
|
}
|
|
|
|
export function OperationsInfrastructure({ role }: OperationsInfrastructureProps) {
|
|
const [data, setData] = useState<OperationsInfrastructureData | null>(null);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [lastFetchedAt, setLastFetchedAt] = useState<string | null>(null);
|
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
|
const [copiedSha, setCopiedSha] = useState(false);
|
|
const [refreshInterval, setRefreshInterval] = useState<number>(30);
|
|
|
|
// Speedtest state: dual options (Client to Server vs Server to External)
|
|
const [speedtestMode, setSpeedtestMode] = useState<SpeedtestMode>('client_to_server');
|
|
const [isRunningSpeedtest, setIsRunningSpeedtest] = useState(false);
|
|
const [clientSpeedtestResult, setClientSpeedtestResult] = useState<InfrastructureSpeedtestResult | null>(null);
|
|
const [serverSpeedtestResult, setServerSpeedtestResult] = useState<InfrastructureSpeedtestResult | null>(null);
|
|
const [speedtestError, setSpeedtestError] = useState<string | null>(null);
|
|
const [speedtestProgress, setSpeedtestProgress] = useState<{
|
|
percent: number;
|
|
currentMbps?: number;
|
|
message: string;
|
|
}>({ percent: 0, message: '' });
|
|
|
|
const activeSpeedtestResult = speedtestMode === 'client_to_server' ? clientSpeedtestResult : serverSpeedtestResult;
|
|
const speedtestResult = activeSpeedtestResult;
|
|
|
|
const isFetchingRef = useRef(false);
|
|
const pollTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
if (isFetchingRef.current) return;
|
|
isFetchingRef.current = true;
|
|
setIsRefreshing(true);
|
|
|
|
try {
|
|
const resp = await fetch('/api/ops/infrastructure', {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
});
|
|
|
|
if (resp.ok) {
|
|
const raw = await resp.json();
|
|
const normalized = normalizeInfrastructureResponse(raw);
|
|
setData(normalized);
|
|
setLastFetchedAt(new Date().toLocaleTimeString());
|
|
setFetchError(null);
|
|
} else if (resp.status === 401 || resp.status === 403) {
|
|
setFetchError('Authorization failed. Access restricted.');
|
|
} else {
|
|
const raw = await resp.json().catch(() => null);
|
|
setData(normalizeInfrastructureResponse(raw));
|
|
setFetchError('Metrics collector degraded or partially unavailable.');
|
|
}
|
|
} catch {
|
|
setFetchError('Network error reaching infrastructure endpoint.');
|
|
} finally {
|
|
isFetchingRef.current = false;
|
|
setIsRefreshing(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleRunClientSpeedtest = async () => {
|
|
setSpeedtestProgress({ percent: 10, message: 'Measuring latency to MotorSignal server...' });
|
|
|
|
// 1. Latency probe (3 rapid samples)
|
|
const pings: number[] = [];
|
|
let detectedIp: string | undefined;
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const t0 = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
const pingResp = await fetch(`/api/ops/infrastructure/speedtest?action=ping&sample=${i}&t=${Date.now()}`, {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
});
|
|
if (!pingResp.ok) throw new Error('Client-to-server latency probe failed');
|
|
const t1 = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
pings.push(Math.max(1, t1 - t0));
|
|
const pingJson = await pingResp.json().catch(() => null);
|
|
if (pingJson?.client_ip) detectedIp = pingJson.client_ip;
|
|
}
|
|
|
|
const minPing = Math.min(...pings);
|
|
const latencyMs = Math.round(minPing * 10) / 10;
|
|
setSpeedtestProgress({
|
|
percent: 30,
|
|
message: `Latency: ${latencyMs} ms. Testing download throughput...`,
|
|
});
|
|
|
|
// 2. Download throughput benchmark (2.5 MB bounded payload)
|
|
const expectedBytes = 2500000;
|
|
const downloadUrl = `/api/ops/infrastructure/speedtest?action=download&bytes=${expectedBytes}&t=${Date.now()}`;
|
|
const tDownStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
const downResp = await fetch(downloadUrl, {
|
|
method: 'GET',
|
|
headers: { accept: 'application/octet-stream' },
|
|
cache: 'no-store',
|
|
});
|
|
if (!downResp.ok) throw new Error('Download throughput test failed');
|
|
if (downResp.headers.get('X-Client-IP')) {
|
|
detectedIp = downResp.headers.get('X-Client-IP')!;
|
|
}
|
|
|
|
let receivedBytes = 0;
|
|
if (downResp.body && typeof downResp.body.getReader === 'function') {
|
|
const reader = downResp.body.getReader();
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (value) {
|
|
receivedBytes += value.length;
|
|
const elapsedSec = Math.max(
|
|
0.01,
|
|
((typeof performance !== 'undefined' ? performance.now() : Date.now()) - tDownStart) / 1000
|
|
);
|
|
const currentMbps = Math.round(((receivedBytes * 8) / (elapsedSec * 1000000)) * 100) / 100;
|
|
const pct = Math.min(70, 30 + Math.round((receivedBytes / expectedBytes) * 40));
|
|
setSpeedtestProgress({
|
|
percent: pct,
|
|
currentMbps,
|
|
message: `Downloading: ${Math.round((receivedBytes / 1024 / 1024) * 10) / 10} MB (${currentMbps} Mbps)...`,
|
|
});
|
|
}
|
|
}
|
|
} else if (typeof downResp.arrayBuffer === 'function') {
|
|
const buf = await downResp.arrayBuffer();
|
|
receivedBytes = buf.byteLength;
|
|
} else {
|
|
const blob = await downResp.blob();
|
|
receivedBytes = blob.size;
|
|
}
|
|
|
|
const tDownEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
const downDurationSec = Math.max(0.01, (tDownEnd - tDownStart) / 1000);
|
|
const downloadMbps = Math.round(((receivedBytes * 8) / (downDurationSec * 1000000)) * 100) / 100;
|
|
|
|
setSpeedtestProgress({
|
|
percent: 75,
|
|
currentMbps: downloadMbps,
|
|
message: `Download: ${downloadMbps} Mbps. Testing upload throughput...`,
|
|
});
|
|
|
|
// 3. Upload throughput benchmark (1.5 MB bounded payload)
|
|
const uploadBytes = 1500000;
|
|
const uploadPayload = new Uint8Array(uploadBytes);
|
|
for (let i = 0; i < uploadBytes; i += 4096) {
|
|
uploadPayload[i] = (i * 37) & 0xff;
|
|
}
|
|
|
|
const tUpStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
const upResp = await fetch(`/api/ops/infrastructure/speedtest?action=upload&t=${Date.now()}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/octet-stream' },
|
|
body: uploadPayload,
|
|
});
|
|
if (!upResp.ok) throw new Error('Upload throughput test failed');
|
|
const tUpEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
const upDurationSec = Math.max(0.01, (tUpEnd - tUpStart) / 1000);
|
|
const uploadMbps = Math.round(((uploadBytes * 8) / (upDurationSec * 1000000)) * 100) / 100;
|
|
|
|
const upData = await upResp.json().catch(() => null);
|
|
if (upData?.client_ip) detectedIp = upData.client_ip;
|
|
|
|
setSpeedtestProgress({
|
|
percent: 100,
|
|
currentMbps: uploadMbps,
|
|
message: 'Client-to-server benchmark complete',
|
|
});
|
|
|
|
const result: InfrastructureSpeedtestResult = {
|
|
status: 'completed',
|
|
tested_at: new Date().toISOString(),
|
|
latency_ms: latencyMs,
|
|
download_mbps: downloadMbps,
|
|
upload_mbps: uploadMbps,
|
|
server_target: 'MotorSignal Host Edge',
|
|
client_ip: detectedIp ?? 'Operator Browser Link',
|
|
mode: 'client_to_server',
|
|
direction_label: 'Operator Browser ➔ MotorSignal Server',
|
|
};
|
|
setClientSpeedtestResult(result);
|
|
|
|
// Audit recording proxy call (non-blocking)
|
|
void fetch('/api/ops/infrastructure/speedtest?action=record_client_result', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
latency_ms: latencyMs,
|
|
download_mbps: downloadMbps,
|
|
upload_mbps: uploadMbps,
|
|
client_ip: detectedIp,
|
|
}),
|
|
}).catch(() => {});
|
|
};
|
|
|
|
const handleRunServerSpeedtest = async () => {
|
|
setSpeedtestProgress({
|
|
percent: 30,
|
|
message: 'Benchmarking MotorSignal server to upstream internet probe...',
|
|
});
|
|
|
|
const resp = await fetch('/api/ops/infrastructure/speedtest', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mode: 'server_to_external' }),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const data = await resp.json().catch(() => null);
|
|
throw new Error(data?.detail || 'Failed to complete upstream speedtest');
|
|
}
|
|
|
|
const data = (await resp.json()) as InfrastructureSpeedtestResult;
|
|
setSpeedtestProgress({
|
|
percent: 100,
|
|
currentMbps: data.download_mbps,
|
|
message: 'Server upstream benchmark complete',
|
|
});
|
|
|
|
const result: InfrastructureSpeedtestResult = {
|
|
...data,
|
|
mode: 'server_to_external',
|
|
direction_label: data.direction_label ?? 'MotorSignal Server ➔ Upstream Edge (1.1.1.1)',
|
|
};
|
|
setServerSpeedtestResult(result);
|
|
};
|
|
|
|
const handleRunSpeedtest = async () => {
|
|
if (isRunningSpeedtest) return;
|
|
setIsRunningSpeedtest(true);
|
|
setSpeedtestError(null);
|
|
setSpeedtestProgress({ percent: 5, message: 'Preparing benchmark...' });
|
|
|
|
try {
|
|
if (speedtestMode === 'client_to_server') {
|
|
await handleRunClientSpeedtest();
|
|
} else {
|
|
await handleRunServerSpeedtest();
|
|
}
|
|
} catch (err: unknown) {
|
|
setSpeedtestError(err instanceof Error ? err.message : 'Network error executing speedtest');
|
|
} finally {
|
|
setIsRunningSpeedtest(false);
|
|
}
|
|
};
|
|
|
|
// Initial fetch and user-selectable visibility-gated polling loop (5s, 10s, 30s)
|
|
useEffect(() => {
|
|
void fetchData();
|
|
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === 'visible') {
|
|
void fetchData();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
const pollIntervalMs = refreshInterval ? refreshInterval * 1000 : 30000;
|
|
pollTimerRef.current = setInterval(() => {
|
|
if (document.visibilityState === 'visible') {
|
|
void fetchData();
|
|
}
|
|
}, pollIntervalMs || 30000);
|
|
|
|
return () => {
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
if (pollTimerRef.current) {
|
|
clearInterval(pollTimerRef.current);
|
|
}
|
|
};
|
|
}, [fetchData, refreshInterval]);
|
|
|
|
const handleCopySha = (sha: string) => {
|
|
if (!sha) return;
|
|
navigator.clipboard.writeText(sha).then(() => {
|
|
setCopiedSha(true);
|
|
setTimeout(() => setCopiedSha(false), 2000);
|
|
});
|
|
};
|
|
|
|
const formatBytes = (bytes: number | null): string => {
|
|
if (bytes === null || bytes === undefined) return 'N/A';
|
|
if (bytes >= 1e12) return `${(bytes / 1e12).toFixed(2)} TB`;
|
|
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`;
|
|
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
|
|
return `${(bytes / 1e3).toFixed(1)} KB`;
|
|
};
|
|
|
|
const getServiceStatusBadge = (status: ServiceStatusValue) => {
|
|
switch (status) {
|
|
case 'active':
|
|
return {
|
|
label: 'Active (Running)',
|
|
icon: 'check_circle',
|
|
classes: 'bg-emerald-950/60 text-emerald-400 border-emerald-800/60',
|
|
};
|
|
case 'inactive':
|
|
return {
|
|
label: 'Inactive (Stopped)',
|
|
icon: 'pause_circle',
|
|
classes: 'bg-amber-950/60 text-amber-400 border-amber-800/60',
|
|
};
|
|
case 'failed':
|
|
return {
|
|
label: 'Failed',
|
|
icon: 'cancel',
|
|
classes: 'bg-rose-950/60 text-rose-400 border-rose-800/60',
|
|
};
|
|
case 'unknown':
|
|
default:
|
|
return {
|
|
label: 'Unknown / Unreachable',
|
|
icon: 'help',
|
|
classes: 'bg-slate-900/60 text-slate-400 border-slate-700/60',
|
|
};
|
|
}
|
|
};
|
|
|
|
const getComponentHealthBadge = (status: ComponentHealthStatus) => {
|
|
switch (status) {
|
|
case 'available':
|
|
return {
|
|
label: 'Available',
|
|
icon: 'check_circle',
|
|
classes: 'bg-emerald-950/60 text-emerald-400 border-emerald-800/60',
|
|
};
|
|
case 'degraded':
|
|
return {
|
|
label: 'Degraded',
|
|
icon: 'warning',
|
|
classes: 'bg-amber-950/60 text-amber-400 border-amber-800/60',
|
|
};
|
|
case 'unavailable':
|
|
return {
|
|
label: 'Unavailable',
|
|
icon: 'cancel',
|
|
classes: 'bg-rose-950/60 text-rose-400 border-rose-800/60',
|
|
};
|
|
case 'unknown':
|
|
default:
|
|
return {
|
|
label: 'Unknown',
|
|
icon: 'help',
|
|
classes: 'bg-slate-900/60 text-slate-400 border-slate-700/60',
|
|
};
|
|
}
|
|
};
|
|
|
|
const resources = data?.resources;
|
|
const services = data?.services ?? [];
|
|
const network = data?.network;
|
|
const deployment = data?.deployment;
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Header & Controls */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-outline-variant/20">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="material-symbols-outlined text-primary-container text-2xl">
|
|
dns
|
|
</span>
|
|
<h1 className="text-xl font-bold tracking-tight text-on-surface">
|
|
Infrastructure & Runtime
|
|
</h1>
|
|
<span className="px-2 py-0.5 rounded text-[11px] font-mono bg-surface-container-high border border-outline-variant/30 text-primary-fixed-dim">
|
|
Phase 2 Operations
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-on-surface-variant">
|
|
Host system resources, systemd core services, network totals, and deployment runtime health.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{/* Selectable Auto-refresh interval (5s, 10s, 30s) */}
|
|
<div className="flex items-center gap-1.5 text-[11px] text-on-surface-variant font-mono bg-surface-container-low px-2 py-1 rounded-lg border border-outline-variant/20">
|
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse mr-0.5" />
|
|
<span className="text-outline">Auto:</span>
|
|
{[5, 10, 30].map((sec) => (
|
|
<button
|
|
key={sec}
|
|
type="button"
|
|
onClick={() => setRefreshInterval(sec)}
|
|
className={`px-1.5 py-0.5 rounded text-[11px] font-semibold transition-colors ${
|
|
refreshInterval === sec
|
|
? 'bg-primary-container text-on-primary-container font-bold'
|
|
: 'text-on-surface-variant hover:text-on-surface'
|
|
}`}
|
|
title={`Auto-refresh every ${sec} seconds`}
|
|
>
|
|
{sec}s
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Last refreshed */}
|
|
{lastFetchedAt && (
|
|
<span className="text-[11px] text-outline font-mono hidden md:inline">
|
|
Updated: {lastFetchedAt}
|
|
</span>
|
|
)}
|
|
|
|
{/* Manual Refresh Button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => void fetchData()}
|
|
disabled={isRefreshing}
|
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold bg-surface-container-high hover:bg-surface-container-highest border border-outline-variant/40 text-on-surface disabled:opacity-50 transition-all focus-visible:ring-2 focus-visible:ring-primary-container"
|
|
>
|
|
<span
|
|
className={`material-symbols-outlined text-[16px] ${
|
|
isRefreshing ? 'animate-spin' : ''
|
|
}`}
|
|
>
|
|
refresh
|
|
</span>
|
|
<span>{isRefreshing ? 'Refreshing...' : 'Refresh'}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Optional Warning Banner */}
|
|
{fetchError && (
|
|
<div className="p-3.5 rounded-lg bg-amber-950/40 border border-amber-800/50 flex items-center gap-2.5 text-xs text-amber-300">
|
|
<span className="material-symbols-outlined text-amber-400 text-base">
|
|
warning
|
|
</span>
|
|
<span>{fetchError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* SECTION 1: System Resources */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-xs font-bold uppercase tracking-wider text-outline">
|
|
Server Resources
|
|
</h2>
|
|
<span className="text-[10px] text-outline font-mono">
|
|
Target Host: Ubuntu VPS
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
|
{/* CPU Usage */}
|
|
<div className="p-4 rounded-xl bg-surface-container-low border border-outline-variant/20 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium text-on-surface-variant">CPU Usage</span>
|
|
<span className="material-symbols-outlined text-base text-primary-fixed-dim">
|
|
memory
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold font-mono tracking-tight text-on-surface">
|
|
{resources?.cpu_percent !== null && resources?.cpu_percent !== undefined
|
|
? `${resources.cpu_percent}%`
|
|
: 'Unavailable'}
|
|
</div>
|
|
<p className="text-[11px] text-outline font-mono mt-0.5">
|
|
Load: {resources?.load_avg ? resources.load_avg.join(', ') : 'N/A'}
|
|
</p>
|
|
</div>
|
|
{/* Progress Bar */}
|
|
<div className="w-full bg-surface-container-highest h-1.5 rounded-full overflow-hidden">
|
|
<div
|
|
className="bg-primary-container h-full rounded-full transition-all duration-300"
|
|
style={{ width: `${Math.min(100, Math.max(0, resources?.cpu_percent ?? 0))}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Memory (RAM) */}
|
|
<div className="p-4 rounded-xl bg-surface-container-low border border-outline-variant/20 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium text-on-surface-variant">Memory (RAM)</span>
|
|
<span className="material-symbols-outlined text-base text-primary-fixed-dim">
|
|
developer_board
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<div className="text-xl font-bold font-mono tracking-tight text-on-surface">
|
|
{resources?.memory
|
|
? `${formatBytes(resources.memory.used)} / ${formatBytes(resources.memory.total)}`
|
|
: 'Unavailable'}
|
|
</div>
|
|
<p className="text-[11px] text-outline font-mono mt-0.5">
|
|
{resources?.memory
|
|
? `${resources.memory.percent}% used (${formatBytes(resources.memory.available)} free)`
|
|
: 'N/A'}
|
|
</p>
|
|
</div>
|
|
{/* Progress Bar */}
|
|
<div className="w-full bg-surface-container-highest h-1.5 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-300 ${
|
|
(resources?.memory?.percent ?? 0) >= 90
|
|
? 'bg-rose-500'
|
|
: (resources?.memory?.percent ?? 0) >= 80
|
|
? 'bg-amber-500'
|
|
: 'bg-primary-container'
|
|
}`}
|
|
style={{ width: `${Math.min(100, Math.max(0, resources?.memory?.percent ?? 0))}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Swap Memory */}
|
|
<div className="p-4 rounded-xl bg-surface-container-low border border-outline-variant/20 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium text-on-surface-variant">Swap Space</span>
|
|
<span className="material-symbols-outlined text-base text-primary-fixed-dim">
|
|
swap_horiz
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<div className="text-xl font-bold font-mono tracking-tight text-on-surface">
|
|
{resources?.swap
|
|
? `${formatBytes(resources.swap.used)} / ${formatBytes(resources.swap.total)}`
|
|
: 'Unavailable'}
|
|
</div>
|
|
<p className="text-[11px] text-outline font-mono mt-0.5">
|
|
{resources?.swap ? `${resources.swap.percent}% utilized` : 'N/A'}
|
|
</p>
|
|
</div>
|
|
{/* Progress Bar */}
|
|
<div className="w-full bg-surface-container-highest h-1.5 rounded-full overflow-hidden">
|
|
<div
|
|
className="bg-primary-container/70 h-full rounded-full transition-all duration-300"
|
|
style={{ width: `${Math.min(100, Math.max(0, resources?.swap?.percent ?? 0))}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Disk Storage (/) */}
|
|
<div className="p-4 rounded-xl bg-surface-container-low border border-outline-variant/20 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs font-medium text-on-surface-variant">Root Disk (/)</span>
|
|
{resources?.disk?.critical ? (
|
|
<span className="px-1.5 py-0.2 rounded text-[9px] font-mono font-bold bg-rose-950 text-rose-300 border border-rose-800">
|
|
CRITICAL
|
|
</span>
|
|
) : resources?.disk?.warning ? (
|
|
<span className="px-1.5 py-0.2 rounded text-[9px] font-mono font-bold bg-amber-950 text-amber-300 border border-amber-800">
|
|
WARNING
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<span className="material-symbols-outlined text-base text-primary-fixed-dim">
|
|
hard_drive
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<div className="text-xl font-bold font-mono tracking-tight text-on-surface">
|
|
{resources?.disk
|
|
? `${formatBytes(resources.disk.used)} / ${formatBytes(resources.disk.total)}`
|
|
: 'Unavailable'}
|
|
</div>
|
|
<p className="text-[11px] text-outline font-mono mt-0.5">
|
|
{resources?.disk
|
|
? `${resources.disk.percent}% used (${formatBytes(resources.disk.available)} free)`
|
|
: 'N/A'}
|
|
</p>
|
|
</div>
|
|
{/* Progress Bar */}
|
|
<div className="w-full bg-surface-container-highest h-1.5 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-300 ${
|
|
resources?.disk?.critical
|
|
? 'bg-rose-500'
|
|
: resources?.disk?.warning
|
|
? 'bg-amber-500'
|
|
: 'bg-primary-container'
|
|
}`}
|
|
style={{ width: `${Math.min(100, Math.max(0, resources?.disk?.percent ?? 0))}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Server Uptime */}
|
|
<div className="p-4 rounded-xl bg-surface-container-low border border-outline-variant/20 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium text-on-surface-variant">Server Uptime</span>
|
|
<span className="material-symbols-outlined text-base text-primary-fixed-dim">
|
|
schedule
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<div className="text-lg font-bold font-mono tracking-tight text-on-surface truncate">
|
|
{resources?.uptime_formatted || 'Unavailable'}
|
|
</div>
|
|
<p className="text-[11px] text-outline font-mono mt-0.5">
|
|
{resources?.uptime_seconds
|
|
? `${resources.uptime_seconds.toLocaleString()}s total uptime`
|
|
: 'N/A'}
|
|
</p>
|
|
</div>
|
|
<div className="text-[10px] text-primary-fixed-dim font-mono">
|
|
System running continuously
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SECTION 2: Core Systemd Services */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-xs font-bold uppercase tracking-wider text-outline">
|
|
Core Services (systemd)
|
|
</h2>
|
|
<span className="text-[10px] text-outline font-mono">
|
|
Strict Allowlist: 4 Services
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{services.map((svc) => {
|
|
const badge = getServiceStatusBadge(svc.status);
|
|
return (
|
|
<div
|
|
key={svc.name}
|
|
className="p-4 rounded-xl bg-surface-container-low border border-outline-variant/20 flex flex-col justify-between space-y-3"
|
|
>
|
|
<div>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-on-surface">
|
|
{svc.display_name}
|
|
</h3>
|
|
<p className="text-[11px] font-mono text-outline mt-0.5">
|
|
{svc.name}.service
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-on-surface-variant mt-2 leading-relaxed">
|
|
{svc.description}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="pt-3 border-t border-outline-variant/10 flex items-center justify-between">
|
|
<span className="text-[11px] text-outline">Service Status</span>
|
|
<div
|
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-mono font-medium border ${badge.classes}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">
|
|
{badge.icon}
|
|
</span>
|
|
<span>{badge.label}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* SECTION 3 & 4: Network Cumulative Totals & Deployment Health */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Network Cumulative Totals */}
|
|
<div className="p-5 rounded-xl bg-surface-container-low border border-outline-variant/20 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-primary-container text-xl">
|
|
network_check
|
|
</span>
|
|
<div>
|
|
<h3 className="text-sm font-bold text-on-surface">
|
|
Network Cumulative Totals
|
|
</h3>
|
|
<p className="text-[11px] text-outline">
|
|
Aggregated across non-loopback interfaces since host boot
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono bg-surface-container-high text-primary-fixed-dim border border-outline-variant/30">
|
|
Not a rate
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4 pt-2">
|
|
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/15 space-y-1">
|
|
<div className="flex items-center gap-1.5 text-xs text-on-surface-variant">
|
|
<span className="material-symbols-outlined text-[15px] text-emerald-400">
|
|
download
|
|
</span>
|
|
<span>Total Received</span>
|
|
</div>
|
|
<div className="text-xl font-bold font-mono text-on-surface">
|
|
{formatBytes(network?.bytes_received ?? null)}
|
|
</div>
|
|
<p className="text-[10px] text-outline font-mono">
|
|
{network?.bytes_received ? `${network.bytes_received.toLocaleString()} bytes` : 'N/A'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/15 space-y-1">
|
|
<div className="flex items-center gap-1.5 text-xs text-on-surface-variant">
|
|
<span className="material-symbols-outlined text-[15px] text-primary-container">
|
|
upload
|
|
</span>
|
|
<span>Total Transmitted</span>
|
|
</div>
|
|
<div className="text-xl font-bold font-mono text-on-surface">
|
|
{formatBytes(network?.bytes_transmitted ?? null)}
|
|
</div>
|
|
<p className="text-[10px] text-outline font-mono">
|
|
{network?.bytes_transmitted ? `${network.bytes_transmitted.toLocaleString()} bytes` : 'N/A'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Speedtest Suite: Client-to-Server vs Server-to-Upstream */}
|
|
<div className="p-4 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-3.5">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div className="space-y-0.5">
|
|
<div className="flex items-center gap-1.5 font-bold text-xs text-on-surface">
|
|
<span className="material-symbols-outlined text-[16px] text-primary-container">speed</span>
|
|
<span>Network Speedtest & Diagnostics</span>
|
|
</div>
|
|
<p className="text-[10px] text-outline">
|
|
Measure real-time latency and throughput across network segments
|
|
</p>
|
|
</div>
|
|
|
|
{/* Mode Toggle Tabs */}
|
|
<div className="flex items-center p-1 rounded-lg bg-surface-container-high/60 border border-outline-variant/20 gap-1 self-start sm:self-auto">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!isRunningSpeedtest) setSpeedtestMode('client_to_server');
|
|
}}
|
|
disabled={isRunningSpeedtest}
|
|
className={`flex items-center gap-1 px-2.5 py-1 rounded text-xs transition-all ${
|
|
speedtestMode === 'client_to_server'
|
|
? 'bg-primary-container text-on-primary-container font-semibold shadow-xs'
|
|
: 'text-on-surface-variant hover:text-on-surface'
|
|
}`}
|
|
title="Measure connection from your browser to MotorSignal server"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">laptop_mac</span>
|
|
<span>My Connection → MotorSignal</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!isRunningSpeedtest) setSpeedtestMode('server_to_external');
|
|
}}
|
|
disabled={isRunningSpeedtest}
|
|
className={`flex items-center gap-1 px-2.5 py-1 rounded text-xs transition-all ${
|
|
speedtestMode === 'server_to_external'
|
|
? 'bg-primary-container text-on-primary-container font-semibold shadow-xs'
|
|
: 'text-on-surface-variant hover:text-on-surface'
|
|
}`}
|
|
title="Measure connection from MotorSignal server to upstream internet"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">cloud</span>
|
|
<span>MotorSignal → Upstream Server</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Direction description banner & trigger */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5 p-2.5 rounded-md bg-surface-container-low/70 border border-outline-variant/10 text-[11px]">
|
|
<div className="flex items-center gap-2 text-on-surface-variant">
|
|
<span className="material-symbols-outlined text-[15px] text-primary-fixed-dim">
|
|
{speedtestMode === 'client_to_server' ? 'sync_alt' : 'hub'}
|
|
</span>
|
|
<span>
|
|
{speedtestMode === 'client_to_server' ? (
|
|
<span>
|
|
<strong className="text-on-surface">Client to Server:</strong> Measures latency, download, and upload between your operator browser and the MotorSignal web host.
|
|
</span>
|
|
) : (
|
|
<span>
|
|
<strong className="text-on-surface">Server to External:</strong> Measures server-side network performance from MotorSignal production host to upstream Cloudflare edge.
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRunSpeedtest()}
|
|
disabled={isRunningSpeedtest}
|
|
className="shrink-0 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded text-xs font-semibold bg-primary-container text-on-primary-container hover:opacity-90 disabled:opacity-50 transition-all shadow-sm"
|
|
>
|
|
<span className={`material-symbols-outlined text-[15px] ${isRunningSpeedtest ? 'animate-spin' : ''}`}>
|
|
{isRunningSpeedtest ? 'sync' : 'speed'}
|
|
</span>
|
|
<span>
|
|
{isRunningSpeedtest
|
|
? 'Testing...'
|
|
: speedtestMode === 'client_to_server'
|
|
? 'Run Client Speedtest'
|
|
: 'Run Server Speedtest'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Running Progress Card */}
|
|
{isRunningSpeedtest && (
|
|
<div className="p-3.5 rounded-lg bg-surface-container-low border border-primary-container/30 space-y-2 text-xs font-mono">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-primary-fixed-dim font-sans font-medium">
|
|
<span className="material-symbols-outlined text-[17px] animate-spin">network_check</span>
|
|
<span>
|
|
{speedtestMode === 'client_to_server'
|
|
? 'Benchmarking Client ➔ MotorSignal Server'
|
|
: 'Benchmarking MotorSignal Server ➔ Upstream CDN'}
|
|
</span>
|
|
</div>
|
|
<span className="text-[11px] font-mono text-outline">{speedtestProgress.percent}%</span>
|
|
</div>
|
|
|
|
{/* Progress bar */}
|
|
<div className="w-full bg-surface-container-high rounded-full h-1.5 overflow-hidden">
|
|
<div
|
|
className="bg-primary-container h-1.5 rounded-full transition-all duration-300 ease-out"
|
|
style={{ width: `${Math.max(5, speedtestProgress.percent)}%` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between text-[11px] text-outline pt-0.5">
|
|
<span className="truncate">{speedtestProgress.message}</span>
|
|
{speedtestProgress.currentMbps !== undefined && (
|
|
<span className="font-bold text-cyan-400 shrink-0 ml-2">
|
|
{speedtestProgress.currentMbps} Mbps
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error banner */}
|
|
{speedtestError && (
|
|
<div className="p-3 rounded-lg bg-rose-950/40 border border-rose-800/40 text-xs text-rose-300 flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[16px]">error</span>
|
|
<span>{speedtestError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Result display */}
|
|
{activeSpeedtestResult && !isRunningSpeedtest && (
|
|
<div className="p-3.5 rounded-lg bg-surface-container-low border border-outline-variant/20 space-y-2.5">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<div className="flex items-center gap-1.5 font-bold text-on-surface">
|
|
<span className="material-symbols-outlined text-[16px] text-emerald-400">check_circle</span>
|
|
<span>
|
|
{activeSpeedtestResult.mode === 'client_to_server'
|
|
? 'Client to Server Result (My Connection → MotorSignal)'
|
|
: 'Server to External Result (MotorSignal → Upstream Server)'}
|
|
</span>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-outline">
|
|
{new Date(activeSpeedtestResult.tested_at).toLocaleTimeString()}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-2 text-center">
|
|
<div className="p-2 rounded bg-surface-container-lowest border border-outline-variant/10">
|
|
<span className="block text-[10px] text-outline font-mono">LATENCY</span>
|
|
<span className="text-sm font-bold font-mono text-emerald-400">
|
|
{activeSpeedtestResult.latency_ms !== null && activeSpeedtestResult.latency_ms !== undefined
|
|
? `${activeSpeedtestResult.latency_ms} ms`
|
|
: 'N/A'}
|
|
</span>
|
|
</div>
|
|
<div className="p-2 rounded bg-surface-container-lowest border border-outline-variant/10">
|
|
<span className="block text-[10px] text-outline font-mono">DOWNLOAD</span>
|
|
<span className="text-sm font-bold font-mono text-cyan-400">
|
|
{activeSpeedtestResult.download_mbps !== null && activeSpeedtestResult.download_mbps !== undefined
|
|
? `${activeSpeedtestResult.download_mbps} Mbps`
|
|
: 'N/A'}
|
|
</span>
|
|
</div>
|
|
<div className="p-2 rounded bg-surface-container-lowest border border-outline-variant/10">
|
|
<span className="block text-[10px] text-outline font-mono">UPLOAD</span>
|
|
<span className="text-sm font-bold font-mono text-primary-container">
|
|
{activeSpeedtestResult.upload_mbps !== null && activeSpeedtestResult.upload_mbps !== undefined
|
|
? `${activeSpeedtestResult.upload_mbps} Mbps`
|
|
: 'N/A'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-[10px] font-mono text-outline flex flex-col sm:flex-row sm:items-center justify-between gap-1 pt-1 border-t border-outline-variant/10">
|
|
<span>
|
|
Route: {activeSpeedtestResult.direction_label || (activeSpeedtestResult.mode === 'client_to_server' ? 'Browser ➔ MotorSignal Server' : 'MotorSignal Server ➔ Upstream CDN')}
|
|
</span>
|
|
<div className="flex items-center gap-3">
|
|
<span>Target: {activeSpeedtestResult.server_target}</span>
|
|
{activeSpeedtestResult.client_ip && <span>IP: {activeSpeedtestResult.client_ip}</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!activeSpeedtestResult && !isRunningSpeedtest && (
|
|
<div className="p-3 rounded-lg bg-surface-container-low/40 border border-outline-variant/10 text-center text-xs text-outline">
|
|
No speedtest run yet for{' '}
|
|
<span className="font-semibold text-on-surface-variant">
|
|
{speedtestMode === 'client_to_server' ? 'My Connection → MotorSignal' : 'MotorSignal → Upstream Server'}
|
|
</span>
|
|
. Click above to run test.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-2.5 rounded-lg bg-surface-container-lowest/60 border border-outline-variant/10 text-[11px] text-outline flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[14px] text-primary-fixed-dim">
|
|
info
|
|
</span>
|
|
<span>
|
|
Cumulative Total Since Boot across physical network interfaces. Instantaneous bandwidth rates are not tracked.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Deployment Health Foundations */}
|
|
<div className="p-5 rounded-xl bg-surface-container-low border border-outline-variant/20 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-primary-container text-xl">
|
|
deployed_code
|
|
</span>
|
|
<div>
|
|
<h3 className="text-sm font-bold text-on-surface">
|
|
Deployment Health Foundations
|
|
</h3>
|
|
<p className="text-[11px] text-outline">
|
|
Active code revision and subsystem runtime readiness
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono bg-surface-container-high text-on-surface-variant border border-outline-variant/30">
|
|
Foundation
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-3 pt-1">
|
|
{/* Active Git Revision */}
|
|
<div className="p-3 rounded-lg bg-surface-container-lowest border border-outline-variant/15 flex items-center justify-between">
|
|
<div>
|
|
<div className="text-xs text-on-surface-variant">Active Git Revision</div>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className="font-mono text-sm font-bold text-on-surface">
|
|
{deployment?.short_revision || 'Unavailable'}
|
|
</span>
|
|
{deployment?.git_revision && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleCopySha(deployment.git_revision!)}
|
|
title="Copy full SHA"
|
|
className="p-1 rounded text-outline hover:text-on-surface hover:bg-surface-container transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">
|
|
{copiedSha ? 'check' : 'content_copy'}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 font-mono text-[11px]">
|
|
<span
|
|
className={`w-2 h-2 rounded-full ${
|
|
deployment?.revision_status === 'known' ? 'bg-emerald-400' : 'bg-slate-500'
|
|
}`}
|
|
/>
|
|
<span className="capitalize text-outline">
|
|
{deployment?.revision_status ?? 'unavailable'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Health indicators */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="p-3 rounded-lg bg-surface-container-lowest border border-outline-variant/15">
|
|
<div className="text-xs text-on-surface-variant mb-1">Website Ingress</div>
|
|
{(() => {
|
|
const badge = getComponentHealthBadge(deployment?.website_health ?? 'unknown');
|
|
return (
|
|
<div
|
|
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-mono border ${badge.classes}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[12px]">{badge.icon}</span>
|
|
<span>{badge.label}</span>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
<div className="p-3 rounded-lg bg-surface-container-lowest border border-outline-variant/15">
|
|
<div className="text-xs text-on-surface-variant mb-1">API Readiness</div>
|
|
{(() => {
|
|
const badge = getComponentHealthBadge(deployment?.api_health ?? 'unknown');
|
|
return (
|
|
<div
|
|
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-mono border ${badge.classes}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[12px]">{badge.icon}</span>
|
|
<span>{badge.label}</span>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Deployment History Status */}
|
|
<div className="p-2.5 rounded-lg bg-surface-container-lowest/60 border border-outline-variant/10 text-[11px] text-outline flex items-center justify-between">
|
|
<span className="font-mono">Deployment History:</span>
|
|
<span className="text-primary-fixed-dim font-mono">Audited in Phase 2</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Operations Actions & Deployment Controls */}
|
|
<div className="mt-8 pt-6 border-t border-outline-variant/20">
|
|
<OperationsActions role={role} onActionCompleted={fetchData} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|