Simplify metric bar labels

This commit is contained in:
rcourtman 2025-10-29 10:37:18 +00:00
parent 7ee417a629
commit 93faaacbd1
8 changed files with 51 additions and 58 deletions

View file

@ -1,7 +1,7 @@
import { Component, createSignal, Show, For, createMemo, createEffect } from 'solid-js'; import { Component, createSignal, Show, For, createMemo, createEffect } from 'solid-js';
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import { useWebSocket } from '@/App'; import { useWebSocket } from '@/App';
import { formatBytes, formatAbsoluteTime, formatRelativeTime, formatUptime } from '@/utils/format'; import { formatBytes, formatAbsoluteTime, formatRelativeTime, formatUptime, formatPercent } from '@/utils/format';
import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage'; import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage';
import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery'; import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery';
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector'; import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
@ -1302,15 +1302,15 @@ const UnifiedBackups: Component = () => {
</div> </div>
</td> </td>
<td class="p-0.5 px-1.5 min-w-[180px]"> <td class="p-0.5 px-1.5 min-w-[180px]">
<MetricBar value={cpuPercent()} label={`${cpuPercent()}%`} type="cpu" /> <MetricBar value={cpuPercent()} label={formatPercent(cpuPercent())} type="cpu" />
</td> </td>
<td class="p-0.5 px-1.5 min-w-[180px]"> <td class="p-0.5 px-1.5 min-w-[180px]">
<MetricBar <MetricBar
value={memPercent()} value={memPercent()}
label={`${memPercent()}%`} label={formatPercent(memPercent())}
sublabel={ sublabel={
pbs.memoryTotal pbs.memoryTotal
? `${formatBytes(pbs.memoryUsed)}/${formatBytes(pbs.memoryTotal)}` ? `${formatBytes(pbs.memoryUsed, 0)}/${formatBytes(pbs.memoryTotal, 0)}`
: undefined : undefined
} }
type="memory" type="memory"
@ -1319,8 +1319,8 @@ const UnifiedBackups: Component = () => {
<td class="p-0.5 px-1.5 min-w-[180px]"> <td class="p-0.5 px-1.5 min-w-[180px]">
<MetricBar <MetricBar
value={storage.percent} value={storage.percent}
label={`${storage.percent}%`} label={formatPercent(storage.percent)}
sublabel={`${formatBytes(storage.used)}/${formatBytes(storage.total)}`} sublabel={`${formatBytes(storage.used, 0)}/${formatBytes(storage.total, 0)}`}
type="disk" type="disk"
/> />
</td> </td>

View file

@ -1,6 +1,6 @@
import { createMemo, createSignal, createEffect, on, Show, For } from 'solid-js'; import { createMemo, createSignal, createEffect, on, Show, For } from 'solid-js';
import type { VM, Container } from '@/types/api'; import type { VM, Container } from '@/types/api';
import { formatBytes, formatUptime } from '@/utils/format'; import { formatBytes, formatPercent, formatUptime } from '@/utils/format';
import { MetricBar } from './MetricBar'; import { MetricBar } from './MetricBar';
import { IOMetric } from './IOMetric'; import { IOMetric } from './IOMetric';
import { TagBadges } from './TagBadges'; import { TagBadges } from './TagBadges';
@ -113,7 +113,7 @@ export function GuestRow(props: GuestRowProps) {
if (!props.guest.memory) return undefined; if (!props.guest.memory) return undefined;
const used = props.guest.memory.used ?? 0; const used = props.guest.memory.used ?? 0;
const total = props.guest.memory.total ?? 0; const total = props.guest.memory.total ?? 0;
return `${formatBytes(used)}/${formatBytes(total)}`; return `${formatBytes(used, 0)}/${formatBytes(total, 0)}`;
}); });
const memoryExtraLines = createMemo(() => { const memoryExtraLines = createMemo(() => {
if (!props.guest.memory) return undefined; if (!props.guest.memory) return undefined;
@ -124,11 +124,11 @@ export function GuestRow(props: GuestRowProps) {
props.guest.memory.balloon > 0 && props.guest.memory.balloon > 0 &&
props.guest.memory.balloon !== total props.guest.memory.balloon !== total
) { ) {
lines.push(`Balloon: ${formatBytes(props.guest.memory.balloon)}`); lines.push(`Balloon: ${formatBytes(props.guest.memory.balloon, 0)}`);
} }
if (props.guest.memory.swapTotal && props.guest.memory.swapTotal > 0) { if (props.guest.memory.swapTotal && props.guest.memory.swapTotal > 0) {
const swapUsed = props.guest.memory.swapUsed ?? 0; const swapUsed = props.guest.memory.swapUsed ?? 0;
lines.push(`Swap: ${formatBytes(swapUsed)} / ${formatBytes(props.guest.memory.swapTotal)}`); lines.push(`Swap: ${formatBytes(swapUsed, 0)} / ${formatBytes(props.guest.memory.swapTotal, 0)}`);
} }
return lines.length > 0 ? lines : undefined; return lines.length > 0 ? lines : undefined;
}); });
@ -584,10 +584,10 @@ export function GuestRow(props: GuestRowProps) {
<Show when={showGuestMetrics()} fallback={<span class="text-sm text-gray-400">-</span>}> <Show when={showGuestMetrics()} fallback={<span class="text-sm text-gray-400">-</span>}>
<MetricBar <MetricBar
value={cpuPercent()} value={cpuPercent()}
label={`${cpuPercent().toFixed(0)}%`} label={formatPercent(cpuPercent())}
sublabel={ sublabel={
props.guest.cpus props.guest.cpus
? `${((props.guest.cpu || 0) * props.guest.cpus).toFixed(1)}/${props.guest.cpus} cores` ? `${props.guest.cpus} ${props.guest.cpus === 1 ? 'core' : 'cores'}`
: undefined : undefined
} }
type="cpu" type="cpu"
@ -601,7 +601,7 @@ export function GuestRow(props: GuestRowProps) {
<Show when={showGuestMetrics()} fallback={<span class="text-sm text-gray-400">-</span>}> <Show when={showGuestMetrics()} fallback={<span class="text-sm text-gray-400">-</span>}>
<MetricBar <MetricBar
value={memPercent()} value={memPercent()}
label={`${memPercent().toFixed(0)}%`} label={formatPercent(memPercent())}
sublabel={memoryUsageLabel()} sublabel={memoryUsageLabel()}
type="memory" type="memory"
/> />
@ -621,10 +621,10 @@ export function GuestRow(props: GuestRowProps) {
> >
<MetricBar <MetricBar
value={diskPercent()} value={diskPercent()}
label={`${diskPercent().toFixed(0)}%`} label={formatPercent(diskPercent())}
sublabel={ sublabel={
props.guest.disk props.guest.disk
? `${formatBytes(props.guest.disk.used)}/${formatBytes(props.guest.disk.total)}` ? `${formatBytes(props.guest.disk.used, 0)}/${formatBytes(props.guest.disk.total, 0)}`
: undefined : undefined
} }
type="disk" type="disk"

View file

@ -95,7 +95,7 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
? clampPercent((memoryUsed / memoryTotal) * 100) ? clampPercent((memoryUsed / memoryTotal) * 100)
: 0; : 0;
const memoryLabel = const memoryLabel =
memoryTotal > 0 ? `${formatBytes(memoryUsed)} / ${formatBytes(memoryTotal)}` : undefined; memoryTotal > 0 ? `${formatBytes(memoryUsed, 0)} / ${formatBytes(memoryTotal, 0)}` : undefined;
let diskPercent = 0; let diskPercent = 0;
let diskLabel: string | undefined; let diskLabel: string | undefined;
@ -110,7 +110,7 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
); );
if (totals.total > 0) { if (totals.total > 0) {
diskPercent = clampPercent((totals.used / totals.total) * 100); diskPercent = clampPercent((totals.used / totals.total) * 100);
diskLabel = `${formatBytes(totals.used)} / ${formatBytes(totals.total)}`; diskLabel = `${formatBytes(totals.used, 0)} / ${formatBytes(totals.total, 0)}`;
} }
} }

View file

@ -533,9 +533,9 @@ const DockerContainerRow: Component<{
const memPercent = () => Math.max(0, Math.min(100, container.memoryPercent ?? 0)); const memPercent = () => Math.max(0, Math.min(100, container.memoryPercent ?? 0));
const memUsageLabel = () => { const memUsageLabel = () => {
if (!container.memoryUsageBytes) return undefined; if (!container.memoryUsageBytes) return undefined;
const used = formatBytes(container.memoryUsageBytes); const used = formatBytes(container.memoryUsageBytes, 0);
const limit = container.memoryLimitBytes const limit = container.memoryLimitBytes
? formatBytes(container.memoryLimitBytes) ? formatBytes(container.memoryLimitBytes, 0)
: undefined; : undefined;
return limit ? `${used} / ${limit}` : used; return limit ? `${used} / ${limit}` : used;
}; };
@ -695,7 +695,7 @@ const DockerContainerRow: Component<{
{statusLabel()} {statusLabel()}
</span> </span>
</td> </td>
<td class="px-2 py-0.5"> <td class="px-2 py-0.5 min-w-[160px]">
<Show <Show
when={isRunning() && container.cpuPercent && container.cpuPercent > 0} when={isRunning() && container.cpuPercent && container.cpuPercent > 0}
fallback={<span class="text-xs text-gray-400"></span>} fallback={<span class="text-xs text-gray-400"></span>}
@ -703,7 +703,7 @@ const DockerContainerRow: Component<{
<MetricBar value={cpuPercent()} label={formatPercent(cpuPercent())} type="cpu" /> <MetricBar value={cpuPercent()} label={formatPercent(cpuPercent())} type="cpu" />
</Show> </Show>
</td> </td>
<td class="px-2 py-0.5"> <td class="px-2 py-0.5 min-w-[220px]">
<Show <Show
when={isRunning() && container.memoryUsageBytes && container.memoryUsageBytes > 0} when={isRunning() && container.memoryUsageBytes && container.memoryUsageBytes > 0}
fallback={<span class="text-xs text-gray-400"></span>} fallback={<span class="text-xs text-gray-400"></span>}
@ -1132,8 +1132,8 @@ const DockerServiceRow: Component<{
{badge.label} {badge.label}
</span> </span>
</td> </td>
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500"></td> <td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500 min-w-[150px]"></td>
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500"></td> <td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500 min-w-[210px]"></td>
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap"> <td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
<span class="font-semibold text-gray-900 dark:text-gray-100"> <span class="font-semibold text-gray-900 dark:text-gray-100">
{(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0} {(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0}
@ -1451,8 +1451,8 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
} }
> >
<Card padding="none" class="overflow-hidden"> <Card padding="none" class="overflow-hidden">
<ScrollableTable minWidth="900px"> <ScrollableTable minWidth="1024px">
<table class="w-full min-w-[900px] table-fixed border-collapse whitespace-nowrap"> <table class="w-full min-w-[1024px] table-fixed border-collapse whitespace-nowrap">
<thead> <thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600"> <tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
<th class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[24%]"> <th class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[24%]">
@ -1467,10 +1467,10 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]"> <th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]">
Status Status
</th> </th>
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]"> <th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%] min-w-[150px]">
CPU CPU
</th> </th>
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[11%]"> <th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[17%] min-w-[210px]">
Memory Memory
</th> </th>
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]"> <th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">

View file

@ -2,7 +2,7 @@ import type { Component } from 'solid-js';
import { For, Show, createMemo, createSignal, createEffect, on, onMount, onCleanup } from 'solid-js'; import { For, Show, createMemo, createSignal, createEffect, on, onMount, onCleanup } from 'solid-js';
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import type { Host } from '@/types/api'; import type { Host } from '@/types/api';
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; import { formatBytes, formatPercent, formatRelativeTime, formatUptime } from '@/utils/format';
import { Card } from '@/components/shared/Card'; import { Card } from '@/components/shared/Card';
import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { EmptyState } from '@/components/shared/EmptyState'; import { EmptyState } from '@/components/shared/EmptyState';
@ -211,8 +211,8 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
{(host) => { {(host) => {
const cpuPercent = () => host.cpuUsage ?? 0; const cpuPercent = () => host.cpuUsage ?? 0;
const memPercent = () => host.memory?.usage ?? 0; const memPercent = () => host.memory?.usage ?? 0;
const memUsed = () => formatBytes(host.memory?.used ?? 0); const memUsed = () => formatBytes(host.memory?.used ?? 0, 0);
const memTotal = () => formatBytes(host.memory?.total ?? 0); const memTotal = () => formatBytes(host.memory?.total ?? 0, 0);
// Drawer state // Drawer state
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false); const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false);
@ -301,7 +301,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>} fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>}
> >
<MetricBar <MetricBar
label={`${cpuPercent().toFixed(1)}%`} label={formatPercent(cpuPercent())}
value={cpuPercent()} value={cpuPercent()}
type="cpu" type="cpu"
/> />
@ -413,14 +413,14 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-medium truncate">{disk.mountpoint || disk.device}</span> <span class="font-medium truncate">{disk.mountpoint || disk.device}</span>
<span class="text-[10px] text-gray-500 dark:text-gray-400"> <span class="text-[10px] text-gray-500 dark:text-gray-400">
{formatBytes(disk.used ?? 0)} / {formatBytes(disk.total ?? 0)} {formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
</span> </span>
</div> </div>
<Show when={diskPercent() > 0}> <Show when={diskPercent() > 0}>
<div class="mt-0.5"> <div class="mt-0.5">
<MetricBar <MetricBar
value={diskPercent()} value={diskPercent()}
label={`${diskPercent().toFixed(1)}%`} label={formatPercent(diskPercent())}
type="disk" type="disk"
/> />
</div> </div>

View file

@ -2,7 +2,7 @@ import { Component, For, Show, createSignal, createMemo, createEffect } from 'so
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import { useWebSocket } from '@/App'; import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts'; import { getAlertStyles } from '@/utils/alerts';
import { formatBytes } from '@/utils/format'; import { formatBytes, formatPercent } from '@/utils/format';
import type { Storage as StorageType, CephCluster } from '@/types/api'; import type { Storage as StorageType, CephCluster } from '@/types/api';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary'; import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector'; import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
@ -858,7 +858,7 @@ const Storage: Component = () => {
const used = Math.max(0, cluster.usedBytes || 0); const used = Math.max(0, cluster.usedBytes || 0);
const percent = total > 0 ? (used / total) * 100 : 0; const percent = total > 0 ? (used / total) * 100 : 0;
parts.push( parts.push(
`${formatBytes(used)} / ${formatBytes(total)} (${percent.toFixed(1)}%)`, `${formatBytes(used, 0)} / ${formatBytes(total, 0)} (${formatPercent(percent)})`,
); );
if ( if (
Number.isFinite(cluster.numOsds) && Number.isFinite(cluster.numOsds) &&
@ -883,7 +883,7 @@ const Storage: Component = () => {
if (totals.total > 0) { if (totals.total > 0) {
const percent = (totals.used / totals.total) * 100; const percent = (totals.used / totals.total) * 100;
parts.push( parts.push(
`${formatBytes(totals.used)} / ${formatBytes(totals.total)} (${percent.toFixed(1)}%)`, `${formatBytes(totals.used, 0)} / ${formatBytes(totals.total, 0)} (${formatPercent(percent)})`,
); );
} }
} }
@ -900,7 +900,7 @@ const Storage: Component = () => {
if (!pool) return ''; if (!pool) return '';
const total = Math.max(1, pool.storedBytes + pool.availableBytes); const total = Math.max(1, pool.storedBytes + pool.availableBytes);
const percent = total > 0 ? (pool.storedBytes / total) * 100 : 0; const percent = total > 0 ? (pool.storedBytes / total) * 100 : 0;
return `${pool.name}: ${percent.toFixed(1)}%`; return `${pool.name}: ${formatPercent(percent)}`;
}) })
.filter(Boolean) .filter(Boolean)
.join(', '); .join(', ');
@ -917,7 +917,7 @@ const Storage: Component = () => {
const total = Math.max(1, item.total || 0); const total = Math.max(1, item.total || 0);
const used = Math.max(0, item.used || 0); const used = Math.max(0, item.used || 0);
const percent = total > 0 ? (used / total) * 100 : 0; const percent = total > 0 ? (used / total) * 100 : 0;
return `${item.name}: ${percent.toFixed(1)}%`; return `${item.name}: ${formatPercent(percent)}`;
}) })
.filter(Boolean) .filter(Boolean)
.join(', '); .join(', ');
@ -1124,18 +1124,18 @@ const Storage: Component = () => {
/> />
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none"> <span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="whitespace-nowrap px-0.5"> <span class="whitespace-nowrap px-0.5">
{usagePercent.toFixed(0)}% ( {formatPercent(usagePercent)} (
{formatBytes(storage.used || 0)}/ {formatBytes(storage.used || 0, 0)}/
{formatBytes(storage.total || 0)}) {formatBytes(storage.total || 0, 0)})
</span> </span>
</span> </span>
</div> </div>
</td> </td>
<td class="p-0.5 px-1.5 text-xs hidden sm:table-cell whitespace-nowrap"> <td class="p-0.5 px-1.5 text-xs hidden sm:table-cell whitespace-nowrap">
{formatBytes(storage.free || 0)} {formatBytes(storage.free || 0, 0)}
</td> </td>
<td class="p-0.5 px-1.5 text-xs whitespace-nowrap"> <td class="p-0.5 px-1.5 text-xs whitespace-nowrap">
{formatBytes(storage.total || 0)} {formatBytes(storage.total || 0, 0)}
</td> </td>
<td class="p-0.5 px-1.5"></td> <td class="p-0.5 px-1.5"></td>
</tr> </tr>

View file

@ -195,12 +195,12 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
if (item.type === 'pve') { if (item.type === 'pve') {
const node = item.data as Node; const node = item.data as Node;
if (!node.disk) return undefined; if (!node.disk) return undefined;
return `${formatBytes(node.disk.used)}/${formatBytes(node.disk.total)}`; return `${formatBytes(node.disk.used, 0)}/${formatBytes(node.disk.total, 0)}`;
} }
const pbs = item.data as PBSInstance; const pbs = item.data as PBSInstance;
if (!pbs.datastores || pbs.datastores.length === 0) return undefined; if (!pbs.datastores || pbs.datastores.length === 0) return undefined;
const totals = getPbsTotals(pbs); const totals = getPbsTotals(pbs);
return `${formatBytes(totals.used)}/${formatBytes(totals.total)}`; return `${formatBytes(totals.used, 0)}/${formatBytes(totals.total, 0)}`;
}; };
const getTemperatureValue = (item: SortableItem) => { const getTemperatureValue = (item: SortableItem) => {
@ -583,9 +583,9 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
label={formatPercent(memoryPercentValue ?? 0)} label={formatPercent(memoryPercentValue ?? 0)}
sublabel={ sublabel={
isPVE && node!.memory isPVE && node!.memory
? `${formatBytes(node!.memory.used)}/${formatBytes(node!.memory.total)}` ? `${formatBytes(node!.memory.used, 0)}/${formatBytes(node!.memory.total, 0)}`
: isPBS && pbs!.memoryTotal : isPBS && pbs!.memoryTotal
? `${formatBytes(pbs!.memoryUsed)}/${formatBytes(pbs!.memoryTotal)}` ? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}`
: undefined : undefined
} }
type="memory" type="memory"

View file

@ -17,19 +17,12 @@ export function formatSpeed(bytesPerSecond: number, decimals = 0): string {
export function formatPercent(value: number): string { export function formatPercent(value: number): string {
if (!Number.isFinite(value)) return '0%'; if (!Number.isFinite(value)) return '0%';
const abs = Math.abs(value); const abs = Math.abs(value);
if (abs === 0) return '0%';
if (abs >= 10) { if (abs < 0.5) {
return `${Math.round(value)}%`; return '0%';
} }
return `${Math.round(value)}%`;
if (abs >= 1) {
return `${value.toFixed(1)}%`;
}
// Preserve tiny signals without overly long labels
return `${value.toFixed(2)}%`;
} }
export function formatUptime(seconds: number): string { export function formatUptime(seconds: number): string {