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.
This commit is contained in:
parent
b508b203e3
commit
32d829f796
2 changed files with 72 additions and 6 deletions
|
|
@ -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 ./
|
||||
|
|
|
|||
|
|
@ -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 "<N> blocks of size 1024. <M> 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 || '',
|
||||
|
|
|
|||
Loading…
Reference in a new issue