// 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 } 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 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(); try { await s3Client.send(new HeadBucketCommand({ Bucket: bucket })); out.reachable = true; out.method = 'HeadBucket'; } catch (headErr) { // Fall back to a 0-key list for stores that don't expose HeadBucket. try { await s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 })); out.reachable = true; out.method = 'ListObjectsV2'; } catch (listErr) { out.error = listErr.message || headErr.message; } } out.head_latency_ms = Date.now() - started; 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); // 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_bytes: mount.free_bytes, total_bytes: mount.total_bytes, error: mount.error, }, 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;