import type { Client } from "ssh2"; import { execCommand, toFixedNum } from "./common-utils.js"; const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/; export interface DfRow { filesystem: string; type: string; mount: string; parts: string[]; } export interface DiskFilesystem { filesystem: string; type: string; mount: string; percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman: string | null; usedBytes: number | null; totalBytes: number | null; availableBytes: number | null; label?: string; } export interface MonitoredMount { path: string; label?: string; } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } // Parses `df -T -P`-style output: Filesystem, Type, then the size columns, // with Mounted-on last. The Type column (e.g. ext4, nfs4, cifs) is what lets // callers filter network shares out by filesystem type rather than guessing // from the source path. export function parseDfLines(output: string): DfRow[] { return output .split("\n") .map((l) => l.trim()) .filter(Boolean) .map((line) => { const parts = line.split(/\s+/); return { filesystem: parts[0] || "", type: parts[1] || "", mount: parts[6] || "", parts, }; }) .filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type)); } // Finds the index of the most-utilized real filesystem in a `df -T -B1`-style // row set (parts[2] = total bytes, parts[3] = used bytes), so a nearly-full // secondary mount (e.g. /data) isn't hidden behind a healthy root filesystem. export function findWorstMountIndex(bytesRows: DfRow[]): { index: number; usedBytes: number; totalBytes: number; } { let worstIndex = -1; let worstUsedBytes = -1; let worstTotalBytes = 0; bytesRows.forEach((row, index) => { const totalBytes = Number(row.parts[2]); const usedBytes = Number(row.parts[3]); if ( !Number.isFinite(totalBytes) || !Number.isFinite(usedBytes) || totalBytes <= 0 ) { return; } const usedRatio = usedBytes / totalBytes; const worstRatio = worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1; if (usedRatio > worstRatio) { worstIndex = index; worstUsedBytes = usedBytes; worstTotalBytes = totalBytes; } }); return { index: worstIndex, usedBytes: worstUsedBytes, totalBytes: worstTotalBytes, }; } // Merges the `df -T -B1` and `df -T -h` row sets into one filesystem list. // Byte rows drive the maths; human rows only supply the display strings, // matched by mount point so a mismatched row count can't shift the columns. export function buildFilesystemList( bytesRows: DfRow[], humanRows: DfRow[], ): DiskFilesystem[] { const aligned = humanRows.length === bytesRows.length; return bytesRows .map((row, index) => { const totalBytes = Number(row.parts[2]); const usedBytes = Number(row.parts[3]); const availableBytes = Number(row.parts[4]); if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null; const humanRow = aligned ? humanRows[index] : humanRows.find((h) => h.mount === row.mount); const percent = Number.isFinite(usedBytes) ? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100)) : null; return { filesystem: row.filesystem, type: row.type, mount: row.mount, percent: toFixedNum(percent, 0), usedHuman: humanRow?.parts[3] || null, totalHuman: humanRow?.parts[2] || null, availableHuman: humanRow?.parts[4] || null, usedBytes: Number.isFinite(usedBytes) ? usedBytes : null, totalBytes, availableBytes: Number.isFinite(availableBytes) ? availableBytes : null, }; }) .filter((fs): fs is DiskFilesystem => fs !== null); } // The headline disk figure should be the root filesystem - that is what users // mean by "the server's disk". Only when there is no root mount (containers, // chroots) do we fall back to the most-utilized mount. export function selectPrimaryFilesystem( filesystems: DiskFilesystem[], ): DiskFilesystem | null { if (filesystems.length === 0) return null; const root = filesystems.find((fs) => fs.mount === "/"); if (root) return root; let best = filesystems[0]; for (const fs of filesystems) { const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1); const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1); if (ratio > bestRatio) best = fs; } return best; } // Excluded mounts are user-configured per host: an exact mount-path match // (e.g. "/mnt/nas") or a filesystem-type substring match (e.g. "nfs" matches // nfs/nfs4, "cifs" matches cifs/smb3), so network shares can be dropped from // the headline percent and the filesystem list without hiding local disks. export function filterExcludedFilesystems( filesystems: DiskFilesystem[], excludedMounts?: string[] | null, ): DiskFilesystem[] { if (!excludedMounts || excludedMounts.length === 0) return filesystems; const normalized = excludedMounts .map((entry) => entry.trim().toLowerCase()) .filter(Boolean); if (normalized.length === 0) return filesystems; return filesystems.filter((fs) => { const mount = fs.mount.toLowerCase(); const type = fs.type.toLowerCase(); return !normalized.some( (entry) => mount === entry || (type && type.includes(entry)), ); }); } export function mergeMonitoredFilesystems( filesystems: DiskFilesystem[], monitored: MonitoredMount[], customFilesystems: Array, ): DiskFilesystem[] { const result = [...filesystems]; monitored.forEach((entry, index) => { const path = entry.path.trim(); const custom = customFilesystems[index]; if (!path || !custom) return; const existing = result.find((fs) => fs.mount === path); if (existing) { existing.label = entry.label?.trim() || undefined; return; } result.push({ ...custom, mount: path, label: entry.label?.trim() || undefined, }); }); return result; } export async function collectDiskMetrics( client: Client, excludedMounts?: string[] | null, monitoredMounts?: MonitoredMount[] | null, ): Promise<{ percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman: string | null; mount: string | null; filesystems: DiskFilesystem[]; }> { try { const [diskOutHuman, diskOutBytes] = await Promise.all([ execCommand(client, "df -hT -P | tail -n +2"), execCommand(client, "df -TB1 -P | tail -n +2"), ]); const humanRows = parseDfLines(diskOutHuman.stdout); const bytesRows = parseDfLines(diskOutBytes.stdout); let detected = buildFilesystemList(bytesRows, humanRows); const monitored = (monitoredMounts ?? []).filter((entry) => Boolean(entry.path.trim()), ); if (monitored.length > 0) { const customFilesystems = await Promise.all( monitored.map(async (entry) => { const path = shellQuote(entry.path.trim()); try { const [customHuman, customBytes] = await Promise.all([ execCommand(client, `df -hT -P -- ${path} | tail -n +2`), execCommand(client, `df -TB1 -P -- ${path} | tail -n +2`), ]); return ( buildFilesystemList( parseDfLines(customBytes.stdout), parseDfLines(customHuman.stdout), )[0] ?? null ); } catch { return null; } }), ); detected = mergeMonitoredFilesystems( detected, monitored, customFilesystems, ); } const filesystems = filterExcludedFilesystems(detected, excludedMounts); const primary = selectPrimaryFilesystem(filesystems); return { percent: primary?.percent ?? null, usedHuman: primary?.usedHuman ?? null, totalHuman: primary?.totalHuman ?? null, availableHuman: primary?.availableHuman ?? null, mount: primary?.mount ?? null, filesystems, }; } catch { return { percent: null, usedHuman: null, totalHuman: null, availableHuman: null, mount: null, filesystems: [], }; } }