From 5205d5909dca022bfce366ca6e23cd855f2b79e2 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Sun, 7 Dec 2025 19:13:43 -0800 Subject: [PATCH] feat: disk info collection --- admin/app/services/system_service.ts | 60 ++++++++++++++++++++-- admin/app/utils/fs.ts | 45 ++++++++++++++++ admin/inertia/pages/settings/system.tsx | 25 ++++----- admin/types/system.ts | 68 ++++++++++++++++++++----- install/collect_disk_info.sh | 25 +++++++++ install/install_nomad.sh | 20 ++++++++ install/management_compose.yaml | 1 + install/uninstall_nomad.sh | 14 +++++ 8 files changed, 227 insertions(+), 31 deletions(-) create mode 100644 install/collect_disk_info.sh diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 5d9c816..eb90fca 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -4,13 +4,15 @@ import { DockerService } from '#services/docker_service' import { ServiceSlim } from '../../types/services.js' import logger from '@adonisjs/core/services/logger' import si from 'systeminformation' -import { SystemInformationResponse } from '../../types/system.js' +import { NomadDiskInfo, NomadDiskInfoRaw, SystemInformationResponse } from '../../types/system.js' import { readFileSync } from 'fs' -import { join } from 'path' +import path, { join } from 'path' +import { getAllFilesystems, getFile } from '../utils/fs.js' @inject() export class SystemService { private static appVersion: string | null = null + private static diskInfoFile = '/storage/nomad-disk-info.json' constructor(private dockerService: DockerService) {} @@ -75,16 +77,28 @@ export class SystemService { async getSystemInfo(): Promise { try { - const [cpu, mem, os, disk, currentLoad, fsSize, uptime] = await Promise.all([ + const [cpu, mem, os, currentLoad, fsSize, uptime] = await Promise.all([ si.cpu(), si.mem(), si.osInfo(), - si.diskLayout(), si.currentLoad(), si.fsSize(), si.time(), ]) + const diskInfoRawString = await getFile( + path.join(process.cwd(), SystemService.diskInfoFile), + 'string' + ) + + const diskInfo = ( + diskInfoRawString + ? JSON.parse(diskInfoRawString.toString()) + : { diskLayout: { blockdevices: [] }, fsSize: [] } + ) as NomadDiskInfoRaw + + const disk = this.calculateDiskUsage(diskInfo) + return { cpu, mem, @@ -99,4 +113,42 @@ export class SystemService { return undefined } } + + private calculateDiskUsage(diskInfo: NomadDiskInfoRaw): NomadDiskInfo[] { + const { diskLayout, fsSize } = diskInfo + + if (!diskLayout?.blockdevices || !fsSize) { + return [] + } + + return diskLayout.blockdevices + .filter((disk) => disk.type === 'disk') // Only physical disks + .map((disk) => { + const filesystems = getAllFilesystems(disk, fsSize) + + // Across all partitions + const totalUsed = filesystems.reduce((sum, p) => sum + (p.used || 0), 0) + const totalSize = filesystems.reduce((sum, p) => sum + (p.size || 0), 0) + const percentUsed = totalSize > 0 ? (totalUsed / totalSize) * 100 : 0 + + return { + name: disk.name, + model: disk.model || 'Unknown', + vendor: disk.vendor || '', + rota: disk.rota || false, + tran: disk.tran || '', + size: disk.size, + totalUsed, + totalSize, + percentUsed: Math.round(percentUsed * 100) / 100, + filesystems: filesystems.map((p) => ({ + fs: p.fs, + mount: p.mount, + used: p.used, + size: p.size, + percentUsed: p.use, + })), + } + }) + } } diff --git a/admin/app/utils/fs.ts b/admin/app/utils/fs.ts index 0580344..6f27847 100644 --- a/admin/app/utils/fs.ts +++ b/admin/app/utils/fs.ts @@ -2,6 +2,7 @@ import { mkdir, readdir, readFile, stat, unlink } from 'fs/promises' import { join } from 'path' import { FileEntry } from '../../types/files.js' import { createReadStream } from 'fs' +import { LSBlockDevice, NomadDiskInfoRaw } from '../../types/system.js' export async function listDirectoryContents(path: string): Promise { const entries = await readdir(path, { withFileTypes: true }) @@ -104,3 +105,47 @@ export async function deleteFileIfExists(path: string): Promise { } } } + +export function getAllFilesystems( + device: LSBlockDevice, + fsSize: NomadDiskInfoRaw['fsSize'] +): NomadDiskInfoRaw['fsSize'] { + const filesystems: NomadDiskInfoRaw['fsSize'] = [] + const seen = new Set() + + function traverse(dev: LSBlockDevice) { + // Try to find matching filesystem + const fs = fsSize.find((f) => matchesDevice(f.fs, dev.name)) + + if (fs && !seen.has(fs.fs)) { + filesystems.push(fs) + seen.add(fs.fs) + } + + // Traverse children recursively + if (dev.children) { + dev.children.forEach((child) => traverse(child)) + } + } + + traverse(device) + return filesystems +} + +export function matchesDevice(fsPath: string, deviceName: string): boolean { + // Remove /dev/ and /dev/mapper/ prefixes + const normalized = fsPath.replace('/dev/mapper/', '').replace('/dev/', '') + + // Direct match + if (normalized === deviceName) { + return true + } + + // LVM volumes use dashes instead of slashes + // e.g., ubuntu--vg-ubuntu--lv matches the device name + if (fsPath.includes(deviceName)) { + return true + } + + return false +} diff --git a/admin/inertia/pages/settings/system.tsx b/admin/inertia/pages/settings/system.tsx index 64ce6a6..41c28fd 100644 --- a/admin/inertia/pages/settings/system.tsx +++ b/admin/inertia/pages/settings/system.tsx @@ -32,19 +32,6 @@ export default function SettingsPage(props: { const uptimeMinutes = info?.uptime.uptime ? Math.floor(info.uptime.uptime / 60) : 0 - const diskData = info?.disk.map((disk) => { - const usedBytes = (disk.size || 0) * 0.65 // Estimate - you'd get this from mount points - const usedPercent = disk.size ? (usedBytes / disk.size) * 100 : 0 - - return { - label: disk.name || 'Unknown', - value: usedPercent, - total: formatBytes(disk.size || 0), - used: formatBytes(usedBytes), - type: disk.type, - } - }) - return ( @@ -194,9 +181,17 @@ export default function SettingsPage(props: {
- {diskData && diskData.length > 0 ? ( + {info?.disk && info.disk.length > 0 ? ( ({ + label: disk.name || 'Unknown', + value: disk.percentUsed || 0, + total: disk.totalSize ? formatBytes(disk.totalSize) : 'N/A', + used: disk.totalUsed ? formatBytes(disk.totalUsed) : 'N/A', + subtext: `${formatBytes(disk.totalUsed || 0)} / ${formatBytes( + disk.totalSize || 0 + )}`, + }))} progressiveBarColor={true} statuses={[ { diff --git a/admin/types/system.ts b/admin/types/system.ts index 4a67774..e9b5cb2 100644 --- a/admin/types/system.ts +++ b/admin/types/system.ts @@ -1,18 +1,62 @@ -import { Systeminformation } from "systeminformation" - +import { Systeminformation } from 'systeminformation' export type SystemInformationResponse = { - cpu: Systeminformation.CpuData - mem: Systeminformation.MemData - os: Systeminformation.OsData - disk: Systeminformation.DiskLayoutData[] - currentLoad: Systeminformation.CurrentLoadData - fsSize: Systeminformation.FsSizeData[] - uptime: Systeminformation.TimeData + cpu: Systeminformation.CpuData + mem: Systeminformation.MemData + os: Systeminformation.OsData + disk: NomadDiskInfo[] + currentLoad: Systeminformation.CurrentLoadData + fsSize: Systeminformation.FsSizeData[] + uptime: Systeminformation.TimeData } // Type inferrence is not working properly with usePage and shared props, so we define this type manually export type UsePageProps = { - appVersion: string - environment: string -} \ No newline at end of file + appVersion: string + environment: string +} + +export type LSBlockDevice = { + name: string + size: string + type: string + model: string | null + serial: string | null + vendor: string | null + rota: boolean | null + tran: string | null + children?: LSBlockDevice[] +} + +export type NomadDiskInfoRaw = { + diskLayout: { + blockdevices: LSBlockDevice[] + } + fsSize: { + fs: string + size: number + used: number + available: number + use: number + mount: string + }[] +} + +export type NomadDiskInfo = { + name: string + model: string + vendor: string + rota: boolean + tran: string + size: string + totalUsed: number + totalSize: number + percentUsed: number + filesystems: { + fs: string + mount: string + used: number + size: number + percentUsed: number + }[] +} diff --git a/install/collect_disk_info.sh b/install/collect_disk_info.sh new file mode 100644 index 0000000..b38fdc5 --- /dev/null +++ b/install/collect_disk_info.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +while true; do + DISK_LAYOUT=$(lsblk --json -o NAME,SIZE,TYPE,MODEL,SERIAL,VENDOR,ROTA,TRAN) + + # Get filesystem usage excluding pseudo filesystems + FS_SIZE=$(df -B1 -x tmpfs -x devtmpfs -x squashfs | tail -n +2 | \ + awk 'BEGIN {print "["} + { + if (NR > 1) printf "," + gsub(/%/, "", $5) + printf "{\"fs\":\"%s\",\"size\":%s,\"used\":%s,\"available\":%s,\"use\":%s,\"mount\":\"%s\"}", + $1, $2, $3, $4, $5, $6 + } + END {print "]"}') + + cat > /tmp/nomad-disk-info.json << EOF +{ +"diskLayout": $DISK_LAYOUT, +"fsSize": $FS_SIZE +} +EOF + + sleep 300 +done \ No newline at end of file diff --git a/install/install_nomad.sh b/install/install_nomad.sh index 3112f61..c12a914 100644 --- a/install/install_nomad.sh +++ b/install/install_nomad.sh @@ -36,6 +36,7 @@ START_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project- STOP_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/stop_nomad.sh" UPDATE_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/update_nomad.sh" WAIT_FOR_IT_SCRIPT_URL="https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" +COLLECT_DISK_INFO_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/collect_disk_info.sh" script_option_debug='true' accepted_terms='false' @@ -286,6 +287,24 @@ download_entrypoint_script() { echo -e "${GREEN}#${RESET} entrypoint script downloaded successfully to $entrypoint_script_path.\\n" } +download_and_start_collect_disk_info_script() { + local collect_disk_info_script_path="${NOMAD_DIR}/collect_disk_info.sh" + + echo -e "${YELLOW}#${RESET} Downloading collect_disk_info script...\\n" + if ! curl -fsSL "$COLLECT_DISK_INFO_SCRIPT_URL" -o "$collect_disk_info_script_path"; then + echo -e "${RED}#${RESET} Failed to download the collect_disk_info script. Please check the URL and try again." + exit 1 + fi + chmod +x "$collect_disk_info_script_path" + echo -e "${GREEN}#${RESET} collect_disk_info script downloaded successfully to $collect_disk_info_script_path.\\n" + + # Start script in background and store PID for easy removal on uninstall + echo -e "${YELLOW}#${RESET} Starting collect_disk_info script in the background...\\n" + nohup bash "$collect_disk_info_script_path" > /dev/null 2>&1 & + echo $! > "${NOMAD_DIR}/nomad-collect-disk-info.pid" + echo -e "${GREEN}#${RESET} collect_disk_info script started successfully.\\n" +} + download_helper_scripts() { local start_script_path="${NOMAD_DIR}/start_nomad.sh" local stop_script_path="${NOMAD_DIR}/stop_nomad.sh" @@ -358,6 +377,7 @@ create_nomad_directory download_wait_for_it_script download_entrypoint_script download_helper_scripts +download_and_start_collect_disk_info_script download_management_compose_file start_management_containers success_message diff --git a/install/management_compose.yaml b/install/management_compose.yaml index a65cd75..5ffc762 100644 --- a/install/management_compose.yaml +++ b/install/management_compose.yaml @@ -7,6 +7,7 @@ services: - "8080:8080" volumes: - /opt/project-nomad/storage:/app/storage + - /tmp/nomad-disk-info.json:/app/storage/disk-info.json - /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon - ./entrypoint.sh:/usr/local/bin/entrypoint.sh - ./wait-for-it.sh:/usr/local/bin/wait-for-it.sh diff --git a/install/uninstall_nomad.sh b/install/uninstall_nomad.sh index a2b959d..836d257 100644 --- a/install/uninstall_nomad.sh +++ b/install/uninstall_nomad.sh @@ -17,6 +17,7 @@ NOMAD_DIR="/opt/project-nomad" MANAGEMENT_COMPOSE_FILE="${NOMAD_DIR}/compose.yml" +COLLECT_DISK_INFO_PID="/var/run/nomad-collect-disk-info.pid" ################################################################################################################################################################################################### # # @@ -75,6 +76,16 @@ ensure_docker_installed() { fi } +try_remove_disk_info_script() { + echo "Checking for running collect-disk-info script..." + if [ -f "$COLLECT_DISK_INFO_PID" ]; then + echo "Stopping collect-disk-info script..." + kill "$(cat "$COLLECT_DISK_INFO_PID")" + rm -f "$COLLECT_DISK_INFO_PID" + echo "collect-disk-info script stopped." + fi +} + uninstall_nomad() { echo "Stopping and removing Project N.O.M.A.D. management containers..." docker compose -f "${MANAGEMENT_COMPOSE_FILE}" down @@ -90,6 +101,9 @@ uninstall_nomad() { echo "Containers should be stopped now." + # Try to stop the collect-disk-info script if it's running + try_remove_disk_info_script + echo "Removing Project N.O.M.A.D. files..." rm -rf "${NOMAD_DIR}"