"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 (
);
}
// ── 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 (
{RANGE_OPTIONS.map((option) => (
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}
))}
);
}
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 (
{title}
{children}
{rows.map((r) => (
))}
);
}
// ── 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 (
{/* eslint-disable-next-line @next/next/no-img-element -- store logos are remote and unsized */}
setFailed(true)} />
);
}
return (
{name[0]?.toUpperCase() ?? "C"}
);
}
// ── Disk usage card ───────────────────────────────────────────────────────────
function DiskUsageCard({ storage }: { storage: SystemMetricsSnapshot["storage"] }) {
return (
Disk Usage
{storage ? (
<>
>
) : (
Storage data unavailable
)}
);
}
// ── Info row ──────────────────────────────────────────────────────────────────
function InfoRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
{label}
{value}
);
}
// ── 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("processes");
const [query, setQuery] = useState("");
const [sortBy, setSortBy] = useState("cpu");
const [range, setRange] = useState("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 (
{/* Header */}
{TABS.map((t) => (
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}
))}
{tab === "processes" && (
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"
/>
)}
{/* ── Processes tab ── */}
{tab === "processes" && (
{/* Metric summary */}
c.state === "running").length} running`
}
color={daemonAvailable === false ? "text-status-amber" : "text-chart-4"}
/>
{/* Resource charts */}
History
{/* Container table */}
Docker Containers
{dockerConnected && daemonAvailable !== false && (
Live
)}
{daemonAvailable === false && (
Docker daemon unreachable
)}
{daemonAvailable !== false && dockerStats.length === 0 && (
No containers running
)}
Container
Status
{(["cpu", "memory", "network", "disk"] as SortKey[]).map((key, i) => (
setSortBy(key)}
className={cn(
"text-left transition-colors hover:text-foreground",
sortBy === key ? "text-primary" : "",
)}
>
{["CPU %", "Mem MB", "Net MB", "Disk MB"][i]}
))}
{filteredProcesses.length === 0 ? (
{daemonAvailable === false
? "Cannot connect to Docker daemon"
: query
? "No containers match your search"
: "No containers running"}
) : (
{filteredProcesses.map((p) => (
{p.status}
{p.cpu.toFixed(1)}%
{Math.round(p.memory)} MB
{p.network.toFixed(1)} MB
{p.disk.toFixed(1)} MB
))}
)}
)}
{/* ── Disks tab ── */}
{tab === "disks" && (
Activity
Disks{diskMonitor.data?.disks.length ? ` · ${diskMonitor.data.disks.length}` : ""}
{onOpenDiskManager ? (
Manage disks…
) : null}
{diskMonitor.isLoading ? (
Detecting disks…
) : diskMonitor.error ? (
{diskMonitor.error instanceof Error ? diskMonitor.error.message : "Could not load disks"}
) : !diskMonitor.data || diskMonitor.data.disks.length === 0 ? (
{diskMonitor.data?.unavailableReason ?? "No disks found"}
) : (
{diskMonitor.data.disks.map((disk) => (
))}
)}
)}
{/* ── Network tab ── */}
{tab === "network" && (
v.toFixed(2))}
sub="Mbps now"
color="text-status-green"
/>
v.toFixed(2))}
sub="Mbps now"
color="text-sky-400"
/>
1
? `+ ${dnsServers.slice(1).join(", ")}`
: dnsServers.length === 1
? "1 server"
: "None found"
}
color="text-chart-4"
/>
{network.data?.inContainer ? (
Homeio runs in Docker, so these are the container's interfaces, gateway and DNS, not the
host's. Run it with network_mode: host to
see the host network.
) : null}
Throughput
Interfaces{activeInterfaces.length > 0 ? ` · ${activeInterfaces.length} active` : ""}
{network.isLoading ? (
Loading interfaces…
) : network.error ? (
{network.error instanceof Error ? network.error.message : "Could not load network interfaces"}
) : activeInterfaces.length === 0 ? (
No active network interface
) : (
{activeInterfaces.map((iface) => (
))}
)}
{otherInterfaces.length > 0 ? (
›
{otherInterfaces.length} other interface{otherInterfaces.length > 1 ? "s" : ""} (down or without an address)
{otherInterfaces.map((iface) => (
{iface.iface}
{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"}
))}
) : null}
Container Network Activity
{daemonAvailable === false ? (
Docker daemon unreachable
) : (
{[
{ label: "MB Received", value: (dockerTotals.totalNetworkRx / 1024 ** 2).toFixed(1) },
{ label: "MB Sent", value: (dockerTotals.totalNetworkTx / 1024 ** 2).toFixed(1) },
].map(({ label, value }) => (
{value}
{label}
))}
)}
)}
);
}