diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 01b18b7..837c3ef 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -31,6 +31,7 @@ import schedulesRouter from './routes/schedules.js'; import metricsRouter from './routes/metrics.js'; import commentsRouter from './routes/comments.js'; import importsRouter from './routes/imports.js'; +import storageRouter from './routes/storage.js'; import { startSchedulerLoop } from './scheduler.js'; import { startCleanupLoop } from './tasks/cleanupTempSegments.js'; @@ -86,6 +87,7 @@ app.use('/api/v1/schedules', schedulesRouter); app.use('/api/v1/metrics', metricsRouter); app.use('/api/v1/assets/:assetId/comments', commentsRouter); app.use('/api/v1/imports', importsRouter); +app.use('/api/v1/storage', storageRouter); // ── Error handler ───────────────────────────────────────────────────────────── app.use(errorHandler); diff --git a/services/mam-api/src/routes/storage.js b/services/mam-api/src/routes/storage.js new file mode 100644 index 0000000..e14a4f7 --- /dev/null +++ b/services/mam-api/src/routes/storage.js @@ -0,0 +1,144 @@ +// 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 { requireAuth } from '../middleware/auth.js'; +import { s3Client, getS3Bucket } from '../s3/client.js'; + +const exec = promisify(execCb); +const router = express.Router(); +router.use(requireAuth); + +// Defaults mirrored from settings.js so the overview never returns nulls. +const GROWING_DEFAULTS = { + growing_enabled: 'false', + growing_path: '/growing', + growing_smb_url: '', + 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))) }; + const growingEnabled = growingRaw.growing_enabled === 'true' || growingRaw.growing_enabled === true; + 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 || '', + 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; diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index f972226..ac67747 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -1374,11 +1374,10 @@ function Settings() { const [section, setSection] = React.useState('storage'); const SECTIONS = [ - { id: 'storage', label: 'S3 / Object storage', icon: 'hdd' }, - { id: 'proxy', label: 'Proxy encoding', icon: 'gpu' }, - { id: 'growing', label: 'Growing files (SMB)', icon: 'hdd' }, - { id: 'sdk', label: 'Capture SDKs', icon: 'video' }, - { id: 'sdi', label: 'SDI capture', icon: 'video' }, + { id: 'storage', label: 'Storage', icon: 'hdd' }, + { id: 'proxy', label: 'Proxy encoding', icon: 'gpu' }, + { id: 'sdk', label: 'Capture SDKs', icon: 'video' }, + { id: 'sdi', label: 'SDI capture', icon: 'video' }, ]; return ( @@ -1400,9 +1399,8 @@ function Settings() { ))}