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.
217 lines
8.9 KiB
JavaScript
217 lines
8.9 KiB
JavaScript
// Storage admin endpoints — unified diagnostics for the growing-files mount
|
|
// and the S3 object-storage bucket. Read-only; the actual settings editors
|
|
// continue to live under /settings/s3 and /settings/growing.
|
|
|
|
import express from 'express';
|
|
import fs from 'node:fs';
|
|
import { promisify } from 'node:util';
|
|
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.
|
|
// Growing-file mode is now per-recorder; "enabled" here means the shared SMB
|
|
// landing zone is CONFIGURED (a mount source is set), not a global on/off.
|
|
const GROWING_DEFAULTS = {
|
|
growing_path: '/growing',
|
|
growing_smb_url: '',
|
|
growing_smb_mount: '',
|
|
growing_promote_after_seconds: '8',
|
|
};
|
|
|
|
async function readSettings(keys) {
|
|
const result = await pool.query(
|
|
`SELECT key, value FROM settings WHERE key = ANY($1)`,
|
|
[keys]
|
|
);
|
|
const out = {};
|
|
for (const { key, value } of result.rows) out[key] = value;
|
|
return out;
|
|
}
|
|
|
|
// Probe a filesystem path: does it exist, is it writable, how much free space.
|
|
// All checks are best-effort — any failure becomes { ok: false, error }.
|
|
async function probeGrowingPath(path) {
|
|
const result = { path, exists: false, writable: false, free_bytes: null, total_bytes: null, error: null };
|
|
if (!path) { result.error = 'no path configured'; return result; }
|
|
|
|
try {
|
|
const stat = fs.statSync(path);
|
|
result.exists = stat.isDirectory();
|
|
if (!result.exists) { result.error = 'path is not a directory'; return result; }
|
|
} catch (err) {
|
|
result.error = err.code === 'ENOENT' ? 'path does not exist' : err.message;
|
|
return result;
|
|
}
|
|
|
|
try {
|
|
fs.accessSync(path, fs.constants.W_OK);
|
|
result.writable = true;
|
|
} catch (err) {
|
|
result.error = 'not writable: ' + err.message;
|
|
}
|
|
|
|
// df -P returns POSIX-portable output: "Filesystem 1024-blocks Used Available Capacity Mounted-on"
|
|
try {
|
|
const { stdout } = await exec(`df -PB1 -- ${JSON.stringify(path)}`, { timeout: 3000 });
|
|
const lines = stdout.trim().split('\n');
|
|
if (lines.length >= 2) {
|
|
const cols = lines[1].split(/\s+/);
|
|
// cols: [fs, total, used, available, capacity, mountpoint]
|
|
result.total_bytes = parseInt(cols[1], 10) || null;
|
|
result.free_bytes = parseInt(cols[3], 10) || null;
|
|
}
|
|
} catch (_err) {
|
|
// df not available or path inaccessible — leave free_bytes null.
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function probeS3Bucket() {
|
|
const bucket = getS3Bucket();
|
|
const out = { bucket, reachable: false, head_latency_ms: null, method: null, error: null };
|
|
if (!bucket) { out.error = 'no bucket configured'; return out; }
|
|
|
|
const started = Date.now();
|
|
// Hard cap the whole probe so the admin "Mount health" card never hangs on
|
|
// "Probing…" when S3 is slow/unreachable. Without this, the SDK's default
|
|
// retry/backoff can block the request for tens of seconds.
|
|
const withTimeout = (p, ms) => Promise.race([
|
|
p,
|
|
new Promise((_, rej) => setTimeout(() => rej(new Error('probe timed out after ' + ms + 'ms')), ms)),
|
|
]);
|
|
try {
|
|
await withTimeout(s3Client.send(new HeadBucketCommand({ Bucket: bucket })), 5000);
|
|
out.reachable = true;
|
|
out.method = 'HeadBucket';
|
|
} catch (headErr) {
|
|
// Fall back to a 0-key list for stores that don't expose HeadBucket.
|
|
try {
|
|
await withTimeout(s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 })), 5000);
|
|
out.reachable = true;
|
|
out.method = 'ListObjectsV2';
|
|
} catch (listErr) {
|
|
out.error = listErr.message || headErr.message;
|
|
}
|
|
}
|
|
out.head_latency_ms = Date.now() - started;
|
|
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) => {
|
|
try {
|
|
// Growing files — merge defaults with whatever's in `settings`.
|
|
const growingRaw = { ...GROWING_DEFAULTS, ...(await readSettings(Object.keys(GROWING_DEFAULTS))) };
|
|
// "enabled" now means the shared SMB landing zone is configured (a mount
|
|
// source is set). Per-recorder toggles decide which recorders actually use it.
|
|
const growingEnabled = !!(growingRaw.growing_smb_mount && growingRaw.growing_smb_mount.trim());
|
|
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();
|
|
const s3SettingsRaw = await readSettings(['s3_endpoint', 's3_region']);
|
|
|
|
res.json({
|
|
growing: {
|
|
enabled: growingEnabled,
|
|
container_path: containerPath,
|
|
// host_path isn't authoritatively known to the API container, but the
|
|
// existing deploy uses this symlink — surface it for operator context.
|
|
host_path: '/mnt/NVME/MAM/wild-dragon-growing',
|
|
smb_url: growingRaw.growing_smb_url || '',
|
|
smb_mount: growingRaw.growing_smb_mount || '',
|
|
promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8,
|
|
exists: mount.exists,
|
|
writable: mount.writable,
|
|
// 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 || '',
|
|
bucket: s3.bucket,
|
|
region: s3SettingsRaw.s3_region || process.env.S3_REGION || 'us-east-1',
|
|
reachable: s3.reachable,
|
|
head_latency_ms: s3.head_latency_ms,
|
|
probe_method: s3.method,
|
|
error: s3.error,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
export default router;
|