From 32d829f7960cce55a3634b4792125186ab52e81c Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 4 Jun 2026 15:20:38 +0000 Subject: [PATCH] fix(storage): report real SMB share free space, not local overlay Mount-health card showed ~31GB free for the growing SMB share when the NAS actually has multi-TB. mam-api never mounts the CIFS share, so df on the container's /growing path reported the local overlay filesystem. Now query the share's true capacity via 'smbclient -c du' (no mount needed) using the configured credentials; falls back to the local df + surfaces the probe error if the share is unreachable. Added smbclient to the mam-api image. --- services/mam-api/Dockerfile | 7 ++- services/mam-api/src/routes/storage.js | 71 ++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/services/mam-api/Dockerfile b/services/mam-api/Dockerfile index 558a29d..33ba9d5 100644 --- a/services/mam-api/Dockerfile +++ b/services/mam-api/Dockerfile @@ -1,7 +1,10 @@ FROM node:22-slim -# unzip/tar needed for SDK upload extraction (see routes/sdk.js) +# unzip/tar → SDK upload extraction (see routes/sdk.js) +# smbclient → query the growing-files SMB share's real free space for the +# storage/Mount-health card (mam-api never mounts the share, so +# `df` would report the local overlay, not the NAS quota). RUN apt-get update \ - && apt-get install -y --no-install-recommends unzip tar ca-certificates \ + && apt-get install -y --no-install-recommends unzip tar ca-certificates smbclient \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package*.json ./ diff --git a/services/mam-api/src/routes/storage.js b/services/mam-api/src/routes/storage.js index 8c365e5..ec53dad 100644 --- a/services/mam-api/src/routes/storage.js +++ b/services/mam-api/src/routes/storage.js @@ -5,12 +5,13 @@ import express from 'express'; import fs from 'node:fs'; import { promisify } from 'node:util'; -import { exec as execCb } from 'node:child_process'; +import { exec as execCb, execFile as execFileCb } from 'node:child_process'; import { HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'; import pool from '../db/pool.js'; import { s3Client, getS3Bucket } from '../s3/client.js'; const exec = promisify(execCb); +const execFile = promisify(execFileCb); const router = express.Router(); // Defaults mirrored from settings.js so the overview never returns nulls. @@ -103,6 +104,42 @@ async function probeS3Bucket() { return out; } +// Query the growing-files SMB share's real capacity WITHOUT mounting it. +// mam-api never mounts the CIFS share, so df on the container path reports the +// local overlay (tens of GB), not the multi-TB NAS volume. `smbclient -c du` +// returns " blocks of size 1024. blocks available" for the share, which +// is the actual free/total the operator cares about. +async function probeSmbShare({ mount, username, password, vers }) { + const out = { reachable: false, free_bytes: null, total_bytes: null, error: null }; + if (!mount) { out.error = 'no smb mount configured'; return out; } + // Normalize smb://host/share or \\host\share → //host/share for smbclient. + let unc = String(mount).trim().replace(/\\/g, '/').replace(/^smb:\/\//i, '//'); + if (!unc.startsWith('//')) unc = '//' + unc.replace(/^\/+/, ''); + const user = `${username || ''}%${password || ''}`; + const args = [ + unc, '-U', user, + ...(vers ? ['-m', `SMB${String(vers).replace(/\./g, '_').replace(/_0$/, '')}`] : []), + '-c', 'du', + ]; + try { + // execFile avoids shell-quoting the password. 6s cap so a dead NAS can't hang. + const { stdout } = await execFile('smbclient', args, { timeout: 6000 }); + // " 1890828485120 blocks of size 1024. 1890776477696 blocks available" + const m = stdout.match(/(\d+)\s+blocks of size\s+(\d+)\.\s+(\d+)\s+blocks available/i); + if (m) { + const blockSize = parseInt(m[2], 10) || 1024; + out.total_bytes = parseInt(m[1], 10) * blockSize; + out.free_bytes = parseInt(m[3], 10) * blockSize; + out.reachable = true; + } else { + out.error = 'could not parse smbclient du output'; + } + } catch (err) { + out.error = (err.stderr ? String(err.stderr).trim() : err.message).slice(0, 200); + } + return out; +} + // GET /api/v1/storage/overview // Consolidated read-only view of the storage subsystem for the admin UI. router.get('/overview', async (req, res, next) => { @@ -115,6 +152,29 @@ router.get('/overview', async (req, res, next) => { const containerPath = growingRaw.growing_path || '/growing'; const mount = await probeGrowingPath(containerPath); + // Real capacity comes from the SMB share itself, NOT the local container + // path (mam-api never mounts the share, so df reports the tiny overlay). + // Query the NAS quota directly via smbclient when a mount is configured. + let smbFree = null, smbTotal = null, smbReachable = false, capacityError = mount.error; + if (growingEnabled) { + const creds = await readSettings(['growing_smb_username', 'growing_smb_password', 'growing_smb_vers']); + const smb = await probeSmbShare({ + mount: growingRaw.growing_smb_mount, + username: creds.growing_smb_username, + password: creds.growing_smb_password, + vers: creds.growing_smb_vers, + }); + if (smb.reachable) { + smbFree = smb.free_bytes; smbTotal = smb.total_bytes; smbReachable = true; capacityError = null; + } else { + // Fall back to the local-path df numbers, but surface why the share + // probe failed so the card can show it. + smbFree = mount.free_bytes; smbTotal = mount.total_bytes; capacityError = smb.error || mount.error; + } + } else { + smbFree = mount.free_bytes; smbTotal = mount.total_bytes; + } + // S3 — bucket name comes from the live client (env or DB-loaded), not // a fresh DB read, so we report exactly what the running client uses. const s3 = await probeS3Bucket(); @@ -132,9 +192,12 @@ router.get('/overview', async (req, res, next) => { promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8, exists: mount.exists, writable: mount.writable, - free_bytes: mount.free_bytes, - total_bytes: mount.total_bytes, - error: mount.error, + // free/total now reflect the actual SMB share (smbclient du) when a + // mount is configured; smb_reachable says whether that probe succeeded. + free_bytes: smbFree, + total_bytes: smbTotal, + smb_reachable: smbReachable, + error: capacityError, }, s3: { endpoint: s3SettingsRaw.s3_endpoint || process.env.S3_ENDPOINT || '',