// 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 " 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) => { 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;