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() { ))}
- {section === 'storage' && } + {section === 'storage' && } {section === 'proxy' && } - {section === 'growing' && } {section === 'sdk' && } {section === 'sdi' && }
@@ -1412,6 +1410,136 @@ function Settings() { ); } +// ──────────────────────────────────────────────────────────────────────────── +// Storage — unified view: live mount/bucket health on top, then the two +// existing editors (S3 bucket + growing-files SMB landing zone) stacked. +// ──────────────────────────────────────────────────────────────────────────── + +function StorageSection() { + return ( + <> + + + + + ); +} + +function formatBytes(n) { + if (n == null || isNaN(n)) return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let v = n, i = 0; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`; +} + +function HealthPill({ ok, label, detail }) { + const cls = ok ? 'badge success' : 'badge warning'; + return ( + + + {label} + + ); +} + +function MountHealthStrip() { + const [data, setData] = React.useState(null); + const [error, setError] = React.useState(null); + const [refreshing, setRefresh] = React.useState(false); + + const load = React.useCallback(() => { + setRefresh(true); + window.ZAMPP_API.fetch('/storage/overview') + .then(d => { setData(d); setError(null); }) + .catch(e => setError(e.message || String(e))) + .finally(() => setRefresh(false)); + }, []); + + React.useEffect(() => { + load(); + // Light auto-refresh so free-space + reachability stay current while the + // operator is on the page. 15s is plenty — these are diagnostic, not real-time. + const t = setInterval(load, 15_000); + return () => clearInterval(t); + }, [load]); + + if (error) { + return ( + unavailable}> + + + ); + } + if (!data) { + return ( + +
Probing…
+
+ ); + } + + const g = data.growing; + const s = data.s3; + const growingHealthy = g.enabled ? (g.exists && g.writable) : true; + + return ( + + {refreshing ? '…' : 'Refresh'} + + }> + {/* ── Growing-files row ─────────────────────────────────────────────── */} +
+
+ Growing files + {g.enabled + ? + : disabled} + {g.enabled && g.exists && ( + + )} + {g.free_bytes != null && ( + + {formatBytes(g.free_bytes)} free + + )} +
+
+ Container{g.container_path || '—'} + Host{g.host_path || '—'} + SMB{g.smb_url || '—'} + Promote idle{g.promote_after_seconds}s + {g.error && <>Error{g.error}} +
+
+ +
+ + {/* ── S3 bucket row ─────────────────────────────────────────────────── */} +
+
+ S3 bucket + + {s.head_latency_ms != null && ( + {s.head_latency_ms} ms + )} + {s.probe_method && {s.probe_method}} +
+
+ Endpoint{s.endpoint || '(AWS default)'} + Bucket{s.bucket || '—'} + Region{s.region || '—'} + {s.error && <>Error{s.error}} +
+
+ + ); +} + function S3SettingsCard() { const [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' }); const [loading, setLoading] = React.useState(true);