homeio
PublicForked from doctor-io/homeio
monitor.tsx
"use client";
import { formatBytesCompact, formatUptimeShort } from "@/lib/client/format";
import { hasAnyValue, seriesStats } from "@/lib/client/metrics-history";
import {
calculateDockerTotals,
containerToProcess,
findAppForContainer,
getStatusBadgeColor,
} from "@/lib/client/monitor-utils";
import { useInstalledApps } from "@/modules/apps/hooks/useInstalledApps";
import { useStoreCatalog } from "@/modules/apps/hooks/useStoreCatalog";
import type { MetricsHistoryRange, SystemMetricsSnapshot } from "@/lib/shared/contracts/system";
import {
METRICS_HISTORY_RANGE_MS,
METRICS_HISTORY_SAMPLE_MS,
toHistoryPoint,
} from "@/lib/shared/metrics-history";
import { useMetricsHistory } from "@/modules/system/hooks/useMetricsHistory";
import { useNetworkOverview } from "@/modules/system/hooks/useNetworkOverview";
import {
NetworkInterfaceCard,
formatMbps,
} from "@/modules/system/components/network-interface-card";
import {
MetricHistoryChart,
type ChartSeries,
} from "@/modules/system/components/metric-history-chart";
import { useDockerStats } from "@/modules/system/hooks/useDockerStats";
import { useSystemMetrics } from "@/modules/system/hooks/useSystemMetrics";
import {
DiskMonitorCard,
formatMegabytesPerSecond,
} from "@/modules/system/components/disk-monitor-card";
import { useDiskMonitor } from "@/modules/system/hooks/useDiskMonitor";
import {
Activity,
AlertTriangle,
ArrowDown,
ArrowUp,
Container,
Cpu,
Gauge,
Globe,
HardDrive,
MemoryStick,
Network,
Router,
Search,
Thermometer,
} from "@/components/icons/platform-icons";
import { PANEL_INSET } from "@/lib/ui/surface-tokens";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
type MonitorTab = "processes" | "network" | "disks";
type SortKey = "cpu" | "memory" | "network" | "disk";
const TABS: { id: MonitorTab; label: string }[] = [
{ id: "processes", label: "Processes" },
{ id: "network", label: "Network" },
{ id: "disks", label: "Disks" },
];
// ── Metric card ───────────────────────────────────────────────────────────────
function MetricCard({
label,
icon: Icon,
value,
sub,
color,
}: {
label: string;
icon: React.ComponentType<{ className?: string }>;
value: string;
sub: string;
color: string;
}) {
return (
<div className={cn(PANEL_INSET, "flex flex-col gap-2 px-4 py-3")}>
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground/50">
{label}
</span>
<div className={cn("flex size-6 items-center justify-center rounded-md border border-glass-border/60 bg-background/55", color)}>
<Icon className="size-3" />
</div>
</div>
<div className="truncate font-mono text-2xl font-bold tabular-nums text-foreground" title={value}>
{value}
</div>
<div className="text-[11px] text-muted-foreground/70">{sub}</div>
</div>
);
}
// ── History card ──────────────────────────────────────────────────────────────
const RANGE_OPTIONS: { id: MetricsHistoryRange; label: string }[] = [
{ id: "15m", label: "15m" },
{ id: "1h", label: "1h" },
{ id: "24h", label: "24h" },
];
function formatPercent(value: number) {
return `${value.toFixed(1)}%`;
}
function formatCelsius(value: number) {
return `${value.toFixed(0)} °C`;
}
function RangeSwitch({
range,
onChange,
}: {
range: MetricsHistoryRange;
onChange: (range: MetricsHistoryRange) => void;
}) {
return (
<div className="flex items-center gap-0.5" role="group" aria-label="History range">
{RANGE_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
aria-pressed={range === option.id}
className={cn(
"rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors",
range === option.id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-background/50 hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
);
}
function HistoryCard({
icon: Icon,
title,
iconColor,
rows,
children,
className,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
iconColor: string;
rows: { label: string; value: string }[];
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn(PANEL_INSET, "flex flex-col gap-2 p-3", className)}>
<div className="flex items-center gap-2">
<Icon className={cn("size-3.5", iconColor)} />
<span className="text-xs font-semibold text-foreground">{title}</span>
</div>
{children}
<div className="divide-y divide-glass-border/40">
{rows.map((r) => (
<InfoRow key={r.label} label={r.label} value={r.value} mono />
))}
</div>
</div>
);
}
// ── Container avatar ──────────────────────────────────────────────────────────
/** The app's icon when the container belongs to an installed app, else its initial. */
function ContainerAvatar({ name, logoUrl }: { name: string; logoUrl: string | null }) {
const [failed, setFailed] = useState(false);
if (logoUrl && !failed) {
return (
<div className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-glass-border bg-white/90">
{/* eslint-disable-next-line @next/next/no-img-element -- store logos are remote and unsized */}
<img src={logoUrl} alt="" className="size-full object-contain p-0.5" onError={() => setFailed(true)} />
</div>
);
}
return (
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg border border-glass-border bg-background/55 text-xs font-bold text-primary">
{name[0]?.toUpperCase() ?? "C"}
</div>
);
}
// ── Disk usage card ───────────────────────────────────────────────────────────
function DiskUsageCard({ storage }: { storage: SystemMetricsSnapshot["storage"] }) {
return (
<div className={cn(PANEL_INSET, "flex flex-col gap-2 p-3")}>
<div className="flex items-center gap-2">
<HardDrive className="size-3.5 text-chart-4" />
<span className="text-xs font-semibold text-foreground">Disk Usage</span>
</div>
{storage ? (
<>
<div className="h-1.5 overflow-hidden rounded-full bg-background/65">
<div className="h-full rounded-full bg-chart-4 transition-all duration-300" style={{ width: `${storage.usedPercent.toFixed(1)}%` }} />
</div>
<div className="divide-y divide-glass-border/40">
<InfoRow label="Used" value={formatBytesCompact(storage.usedBytes)} mono />
<InfoRow label="Total" value={formatBytesCompact(storage.totalBytes)} mono />
<InfoRow label="Usage" value={`${storage.usedPercent.toFixed(1)}%`} mono />
</div>
</>
) : (
<p className="py-4 text-center text-xs text-muted-foreground/60">Storage data unavailable</p>
)}
</div>
);
}
// ── Info row ──────────────────────────────────────────────────────────────────
function InfoRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">{label}</span>
<span className={cn("text-xs font-medium text-foreground", mono && "font-mono")}>{value}</span>
</div>
);
}
// ── Monitor ───────────────────────────────────────────────────────────────────
type MonitorProps = {
/** Opens the disk manager, where partitioning and wiping live; the Disks tab only watches. */
onOpenDiskManager?: () => void;
};
export function Monitor({ onOpenDiskManager }: MonitorProps = {}) {
const [tab, setTab] = useState<MonitorTab>("processes");
const [query, setQuery] = useState("");
const [sortBy, setSortBy] = useState<SortKey>("cpu");
const [range, setRange] = useState<MetricsHistoryRange>("15m");
const { data: systemMetrics } = useSystemMetrics();
const { stats: dockerStats, daemonAvailable, isConnected: dockerConnected } = useDockerStats();
const dockerTotals = useMemo(() => calculateDockerTotals(dockerStats), [dockerStats]);
const livePoint = useMemo(
() =>
systemMetrics ? toHistoryPoint(systemMetrics, Date.parse(systemMetrics.timestamp)) : null,
[systemMetrics],
);
const history = useMetricsHistory(range, livePoint);
// Re-rendered with every live frame, so the time axis keeps sliding.
const now = livePoint?.t ?? Date.now();
const rangeMs = METRICS_HISTORY_RANGE_MS[range];
const chartProps = {
points: history.points,
rangeMs,
now,
gapMs: Math.max(3 * history.intervalSeconds * 1000, 3 * METRICS_HISTORY_SAMPLE_MS),
rangeLabel: range === "15m" ? "15 min" : range === "1h" ? "1 h" : "24 h",
// 15 min fills the width from the first sample; 1 h and 24 h keep their
// real scale so the three views are told apart.
fitToData: range === "15m",
formatTime: (t: number) =>
new Date(t).toLocaleTimeString([], range === "24h"
? { hour: "2-digit", minute: "2-digit" }
: { hour: "2-digit", minute: "2-digit", second: "2-digit" }),
};
const cpuStats = seriesStats(history.points, "cpuPercent");
const memoryStats = seriesStats(history.points, "memoryPercent");
const temperatureStats = seriesStats(history.points, "temperatureCelsius");
const downloadStats = seriesStats(history.points, "downloadMbps");
const uploadStats = seriesStats(history.points, "uploadMbps");
const diskReadStats = seriesStats(history.points, "diskReadMBps");
const diskWriteStats = seriesStats(history.points, "diskWriteMBps");
const diskMonitor = useDiskMonitor(tab === "disks");
const network = useNetworkOverview(tab === "network");
const interfaces = network.data?.interfaces ?? [];
// Up with an address, or carrying the default route: the ones worth a card.
// The rest (unplugged ports, OS plumbing) are listed in one line each.
// An IPv6 link-local address (fe80::) is on every interface and says nothing.
// VPN tunnels such as tailscale0 report their state as unknown, not up, so
// anything not known to be down counts when it has an address.
const activeInterfaces = interfaces.filter(
(iface) =>
iface.isDefault ||
(iface.up !== false &&
(iface.ip4 !== null || (iface.ip6 !== null && !iface.ip6.toLowerCase().startsWith("fe80")))),
);
const otherInterfaces = interfaces.filter((iface) => !activeInterfaces.includes(iface));
const defaultInterface = interfaces.find((iface) => iface.isDefault) ?? null;
const dnsServers = network.data?.dnsServers ?? [];
const liveTemperature = livePoint?.temperatureCelsius ?? null;
// A VM or a board without a sensor never reports one: the chart says so.
const showTemperature =
liveTemperature !== null || hasAnyValue(history.points, "temperatureCelsius");
const cpuSeries: ChartSeries[] = [{ key: "cpuPercent", label: "CPU", className: "text-primary" }];
const memorySeries: ChartSeries[] = [
{ key: "memoryPercent", label: "Memory", className: "text-chart-2" },
];
const temperatureSeries: ChartSeries[] = [
{ key: "temperatureCelsius", label: "Temperature", className: "text-status-amber" },
];
const diskSeries: ChartSeries[] = [
{ key: "diskReadMBps", label: "Read", className: "text-chart-4" },
{ key: "diskWriteMBps", label: "Write", className: "text-status-amber" },
];
const networkSeries: ChartSeries[] = [
{ key: "downloadMbps", label: "Down", className: "text-status-green" },
{ key: "uploadMbps", label: "Up", className: "text-sky-400" },
];
const orDash = (value: number | null, format: (v: number) => string) =>
value === null ? "--" : format(value);
const dockerProcesses = useMemo(() => dockerStats.map(containerToProcess), [dockerStats]);
// Same queries as the desktop's app grid, so the icons come from its cache.
const installedApps = useInstalledApps();
const installedCatalog = useStoreCatalog({ installedOnly: true });
const logoByContainer = useMemo(() => {
const apps = installedApps.data ?? [];
const catalogLogos = new Map((installedCatalog.data?.apps ?? []).map((app) => [app.id, app.logoUrl]));
return new Map(
dockerStats.map((container) => {
const app = findAppForContainer(container.name, apps);
return [container.name, app ? (catalogLogos.get(app.id) ?? app.logoUrl ?? null) : null] as const;
}),
);
}, [dockerStats, installedApps.data, installedCatalog.data]);
const filteredProcesses = useMemo(() => {
const q = query.trim().toLowerCase();
const filtered = q ? dockerProcesses.filter((p) => p.name.toLowerCase().includes(q)) : dockerProcesses;
return [...filtered].sort((a, b) => b[sortBy] - a[sortBy]);
}, [dockerProcesses, query, sortBy]);
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex shrink-0 items-center gap-3 border-b border-glass-border/60 px-4 py-2.5">
<div className="flex items-center gap-0.5">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
"rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors",
tab === t.id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-background/50 hover:text-foreground",
)}
>
{t.label}
</button>
))}
</div>
<div className="flex-1" />
{tab === "processes" && (
<div className="relative w-52">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/60" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search containers…"
className="w-full rounded-lg border border-glass-border bg-background/55 py-1.5 pl-8 pr-3 text-xs text-foreground outline-none placeholder:text-muted-foreground/50 focus:border-primary/40"
/>
</div>
)}
</div>
{/* ── Processes tab ── */}
{tab === "processes" && (
<div className="flex-1 overflow-y-auto p-3">
{/* Metric summary */}
<div className="mb-3 grid grid-cols-4 gap-2">
<MetricCard
label="CPU"
icon={Cpu}
value={`${systemMetrics?.cpu.normalizedPercent?.toFixed(1) ?? "--"}%`}
sub={
liveTemperature === null
? "System average"
: `System average · ${formatCelsius(liveTemperature)}`
}
color="text-primary"
/>
<MetricCard
label="Memory"
icon={MemoryStick}
value={`${systemMetrics?.memory.usedPercent?.toFixed(1) ?? "--"}%`}
sub={
systemMetrics?.memory.usedBytes && systemMetrics?.memory.totalBytes
? `${(systemMetrics.memory.usedBytes / 1024 ** 3).toFixed(1)} / ${(systemMetrics.memory.totalBytes / 1024 ** 3).toFixed(1)} GB`
: "--"
}
color="text-chart-2"
/>
<MetricCard
label="Containers"
icon={Container}
value={`${dockerStats.length}`}
sub={
daemonAvailable === false
? "daemon unreachable"
: `${dockerStats.filter((c) => c.state === "running").length} running`
}
color={daemonAvailable === false ? "text-status-amber" : "text-chart-4"}
/>
<MetricCard
label="Load"
icon={Gauge}
value={systemMetrics ? systemMetrics.cpu.oneMinute.toFixed(2) : "--"}
sub={
systemMetrics
? `5m ${systemMetrics.cpu.fiveMinute.toFixed(2)} · 15m ${systemMetrics.cpu.fifteenMinute.toFixed(2)}`
: "--"
}
color="text-sky-400"
/>
</div>
{/* Resource charts */}
<div className="mb-2 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground/70">
History
</span>
<RangeSwitch range={range} onChange={setRange} />
</div>
<div className="mb-3 grid grid-cols-2 gap-2">
<HistoryCard
icon={Cpu}
title="CPU"
iconColor="text-primary"
rows={[
{ label: "Current", value: orDash(livePoint?.cpuPercent ?? null, formatPercent) },
{ label: "Average", value: orDash(cpuStats.average, formatPercent) },
{ label: "Peak", value: orDash(cpuStats.peak, formatPercent) },
]}
>
<MetricHistoryChart {...chartProps} series={cpuSeries} max={100} formatValue={formatPercent} />
</HistoryCard>
<HistoryCard
icon={MemoryStick}
title="Memory"
iconColor="text-chart-2"
rows={[
{ label: "Used", value: orDash(livePoint?.memoryPercent ?? null, formatPercent) },
{ label: "Peak", value: orDash(memoryStats.peak, formatPercent) },
{ label: "In use", value: systemMetrics?.memory.usedBytes ? `${(systemMetrics.memory.usedBytes / 1024 ** 3).toFixed(1)} GB` : "--" },
]}
>
<MetricHistoryChart {...chartProps} series={memorySeries} max={100} formatValue={formatPercent} />
</HistoryCard>
<HistoryCard
icon={Network}
title="Network"
iconColor="text-status-green"
rows={[
{ label: "Download", value: orDash(livePoint?.downloadMbps ?? null, formatMbps) },
{ label: "Upload", value: orDash(livePoint?.uploadMbps ?? null, formatMbps) },
{ label: "Peak download", value: orDash(downloadStats.peak, formatMbps) },
]}
>
<MetricHistoryChart {...chartProps} series={networkSeries} formatValue={formatMbps} />
</HistoryCard>
<HistoryCard
icon={Thermometer}
title="Temperature"
iconColor="text-status-amber"
rows={[
{ label: "Current", value: orDash(liveTemperature, formatCelsius) },
{ label: "Average", value: orDash(temperatureStats.average, formatCelsius) },
{ label: "Max", value: orDash(temperatureStats.peak, formatCelsius) },
]}
>
<MetricHistoryChart
{...chartProps}
series={temperatureSeries}
fitMin
formatValue={formatCelsius}
emptyLabel={showTemperature ? undefined : "No temperature sensor on this machine"}
/>
</HistoryCard>
</div>
{/* Container table */}
<div className={cn(PANEL_INSET, "overflow-hidden")}>
<div className="flex items-center justify-between border-b border-glass-border/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<Container className="size-3.5 text-primary" />
<span className="text-xs font-semibold text-foreground">Docker Containers</span>
{dockerConnected && daemonAvailable !== false && (
<span className="flex items-center gap-1 text-[11px] text-status-green">
<span className="size-1.5 rounded-full bg-status-green" />
Live
</span>
)}
</div>
{daemonAvailable === false && (
<span className="flex items-center gap-1.5 text-xs text-status-amber">
<AlertTriangle className="size-3.5" />
Docker daemon unreachable
</span>
)}
{daemonAvailable !== false && dockerStats.length === 0 && (
<span className="text-xs text-muted-foreground/60">No containers running</span>
)}
</div>
<div className="grid grid-cols-[2.2fr_0.7fr_0.8fr_0.8fr_0.8fr_0.8fr] gap-2 border-b border-glass-border/40 px-4 py-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/50">
<span>Container</span>
<span>Status</span>
{(["cpu", "memory", "network", "disk"] as SortKey[]).map((key, i) => (
<button
key={key}
onClick={() => setSortBy(key)}
className={cn(
"text-left transition-colors hover:text-foreground",
sortBy === key ? "text-primary" : "",
)}
>
{["CPU %", "Mem MB", "Net MB", "Disk MB"][i]}
</button>
))}
</div>
{filteredProcesses.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{daemonAvailable === false
? "Cannot connect to Docker daemon"
: query
? "No containers match your search"
: "No containers running"}
</div>
) : (
<div className="divide-y divide-glass-border/40">
{filteredProcesses.map((p) => (
<div
key={`${p.name}-${p.pid}`}
className="grid grid-cols-[2.2fr_0.7fr_0.8fr_0.8fr_0.8fr_0.8fr] gap-2 px-4 py-2.5 text-xs transition-colors hover:bg-background/30"
>
<div className="flex min-w-0 items-center gap-2.5">
<ContainerAvatar name={p.name} logoUrl={logoByContainer.get(p.name) ?? null} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{p.name}</p>
<p className="font-mono text-[11px] text-muted-foreground/60">{p.pid}</p>
</div>
</div>
<span className={cn("flex items-center")}>
<span className={cn("rounded-md px-2 py-0.5 text-[11px] font-medium", getStatusBadgeColor(p.status))}>
{p.status}
</span>
</span>
<span className="flex items-center font-mono text-foreground">{p.cpu.toFixed(1)}%</span>
<span className="flex items-center font-mono text-foreground">{Math.round(p.memory)} MB</span>
<span className="flex items-center font-mono text-foreground">{p.network.toFixed(1)} MB</span>
<span className="flex items-center font-mono text-foreground">{p.disk.toFixed(1)} MB</span>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* ── Disks tab ── */}
{tab === "disks" && (
<div className="flex-1 overflow-y-auto p-3">
<div className="flex flex-col gap-2">
<DiskUsageCard storage={systemMetrics?.storage} />
<div className="mt-1 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground/70">
Activity
</span>
<RangeSwitch range={range} onChange={setRange} />
</div>
<HistoryCard
icon={HardDrive}
title="All disks"
iconColor="text-chart-4"
rows={[
{ label: "Read now", value: orDash(livePoint?.diskReadMBps ?? null, formatMegabytesPerSecond) },
{ label: "Write now", value: orDash(livePoint?.diskWriteMBps ?? null, formatMegabytesPerSecond) },
{ label: "Peak read", value: orDash(diskReadStats.peak, formatMegabytesPerSecond) },
{ label: "Peak write", value: orDash(diskWriteStats.peak, formatMegabytesPerSecond) },
]}
>
<MetricHistoryChart {...chartProps} series={diskSeries} formatValue={formatMegabytesPerSecond} />
</HistoryCard>
<div className="mt-1 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground/70">
Disks{diskMonitor.data?.disks.length ? ` · ${diskMonitor.data.disks.length}` : ""}
</span>
{onOpenDiskManager ? (
<button
type="button"
onClick={onOpenDiskManager}
className="rounded-md px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-background/50 hover:text-foreground"
>
Manage disks…
</button>
) : null}
</div>
{diskMonitor.isLoading ? (
<p className={cn(PANEL_INSET, "px-4 py-4 text-xs text-muted-foreground/60")}>Detecting disks…</p>
) : diskMonitor.error ? (
<p className={cn(PANEL_INSET, "px-4 py-4 text-xs text-status-amber")}>
{diskMonitor.error instanceof Error ? diskMonitor.error.message : "Could not load disks"}
</p>
) : !diskMonitor.data || diskMonitor.data.disks.length === 0 ? (
<p className={cn(PANEL_INSET, "px-4 py-4 text-xs text-muted-foreground/60")}>
{diskMonitor.data?.unavailableReason ?? "No disks found"}
</p>
) : (
<div className="grid grid-cols-2 gap-2">
{diskMonitor.data.disks.map((disk) => (
<DiskMonitorCard key={disk.name} disk={disk} inContainer={diskMonitor.data?.inContainer} />
))}
</div>
)}
</div>
</div>
)}
{/* ── Network tab ── */}
{tab === "network" && (
<div className="flex-1 overflow-y-auto p-3">
<div className="flex flex-col gap-2">
<div className="grid grid-cols-4 gap-2">
<MetricCard
label="Download"
icon={ArrowDown}
value={orDash(livePoint?.downloadMbps ?? null, (v) => v.toFixed(2))}
sub="Mbps now"
color="text-status-green"
/>
<MetricCard
label="Upload"
icon={ArrowUp}
value={orDash(livePoint?.uploadMbps ?? null, (v) => v.toFixed(2))}
sub="Mbps now"
color="text-sky-400"
/>
<MetricCard
label="Gateway"
icon={Router}
value={network.data?.gateway ?? "--"}
sub={defaultInterface ? `via ${defaultInterface.iface}` : "No default route"}
color="text-primary"
/>
<MetricCard
label="DNS"
icon={Globe}
value={dnsServers[0] ?? "--"}
sub={
dnsServers.length > 1
? `+ ${dnsServers.slice(1).join(", ")}`
: dnsServers.length === 1
? "1 server"
: "None found"
}
color="text-chart-4"
/>
</div>
{network.data?.inContainer ? (
<p className={cn(PANEL_INSET, "px-4 py-2.5 text-xs text-muted-foreground")}>
Homeio runs in Docker, so these are the container's interfaces, gateway and DNS, not the
host's. Run it with <code className="font-mono text-foreground/80">network_mode: host</code> to
see the host network.
</p>
) : null}
<div className="mt-1 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground/70">
Throughput
</span>
<RangeSwitch range={range} onChange={setRange} />
</div>
<HistoryCard
icon={Network}
title={defaultInterface ? `Default interface · ${defaultInterface.iface}` : "Default interface"}
iconColor="text-status-green"
rows={[
{ label: "Peak download", value: orDash(downloadStats.peak, formatMbps) },
{ label: "Peak upload", value: orDash(uploadStats.peak, formatMbps) },
{ label: "Average download", value: orDash(downloadStats.average, formatMbps) },
]}
>
<MetricHistoryChart {...chartProps} series={networkSeries} formatValue={formatMbps} />
</HistoryCard>
<div className="mt-1 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground/70">
Interfaces{activeInterfaces.length > 0 ? ` · ${activeInterfaces.length} active` : ""}
</span>
</div>
{network.isLoading ? (
<p className={cn(PANEL_INSET, "px-4 py-4 text-xs text-muted-foreground/60")}>Loading interfaces…</p>
) : network.error ? (
<p className={cn(PANEL_INSET, "px-4 py-4 text-xs text-status-amber")}>
{network.error instanceof Error ? network.error.message : "Could not load network interfaces"}
</p>
) : activeInterfaces.length === 0 ? (
<p className={cn(PANEL_INSET, "px-4 py-4 text-xs text-muted-foreground/60")}>No active network interface</p>
) : (
<div className="grid grid-cols-2 gap-2">
{activeInterfaces.map((iface) => (
<NetworkInterfaceCard
key={iface.iface}
iface={iface}
wifi={
iface.kind === "wireless" &&
systemMetrics?.wifi.connected &&
systemMetrics.wifi.iface === iface.iface
? { ssid: systemMetrics.wifi.ssid, signalPercent: systemMetrics.wifi.signalPercent }
: null
}
/>
))}
</div>
)}
{otherInterfaces.length > 0 ? (
<details className={cn(PANEL_INSET, "group px-4 py-2.5")}>
<summary className="cursor-pointer list-none text-xs text-muted-foreground hover:text-foreground">
<span className="mr-1.5 inline-block transition-transform group-open:rotate-90">›</span>
{otherInterfaces.length} other interface{otherInterfaces.length > 1 ? "s" : ""} (down or without an address)
</summary>
<div className="mt-2 divide-y divide-glass-border/40">
{otherInterfaces.map((iface) => (
<div key={iface.iface} className="flex items-center justify-between py-1.5 text-xs">
<span className="font-mono text-foreground/80">{iface.iface}</span>
<span className="text-muted-foreground/70">
{iface.kind === "wireless" ? "Wi-Fi" : iface.kind === "vpn" ? "VPN" : iface.kind === "wired" ? "Wired" : "Other"}
{" · "}
{iface.up === true ? "up, no address" : iface.up === false ? "down" : "unknown"}
</span>
</div>
))}
</div>
</details>
) : null}
<div className={cn(PANEL_INSET, "overflow-hidden")}>
<div className="flex items-center gap-2 border-b border-glass-border/50 px-4 py-3">
<Container className="size-3.5 text-primary" />
<span className="text-xs font-semibold text-foreground">Container Network Activity</span>
</div>
{daemonAvailable === false ? (
<p className="px-4 py-4 text-xs text-muted-foreground/60">Docker daemon unreachable</p>
) : (
<div className="grid grid-cols-2 divide-x divide-glass-border/50">
{[
{ label: "MB Received", value: (dockerTotals.totalNetworkRx / 1024 ** 2).toFixed(1) },
{ label: "MB Sent", value: (dockerTotals.totalNetworkTx / 1024 ** 2).toFixed(1) },
].map(({ label, value }) => (
<div key={label} className="flex flex-col items-center gap-1 py-4">
<span className="font-mono text-2xl font-bold tabular-nums text-foreground">{value}</span>
<span className="text-[11px] text-muted-foreground/60">{label}</span>
</div>
))}
</div>
)}
</div>
<div className={cn(PANEL_INSET, "overflow-hidden")}>
<div className="flex items-center gap-2 border-b border-glass-border/50 px-4 py-3">
<Activity className="size-3.5 text-primary" />
<span className="text-xs font-semibold text-foreground">System Info</span>
</div>
<div className="divide-y divide-glass-border/40 px-4">
<InfoRow label="Hostname" value={systemMetrics?.hostname ?? "--"} mono />
<InfoRow label="Platform" value={systemMetrics?.platform ?? "--"} mono />
<InfoRow
label="Uptime"
value={systemMetrics?.uptimeSeconds ? formatUptimeShort(systemMetrics.uptimeSeconds) : "--"}
mono
/>
<InfoRow
label="Containers"
value={daemonAvailable === false ? "unavailable" : `${dockerStats.length} total`}
mono
/>
</div>
</div>
</div>
</div>
)}
</div>
);
}