feat(admin): unified Storage settings page with mount/bucket health diagnostics
- Collapses S3 + Growing-files nav into single 'Storage' section - Adds GET /api/v1/storage/overview with fs/df probes + HeadBucket check - MountHealthStrip shows green/red pills, free space, S3 latency - Reuses existing S3SettingsCard + GrowingSettingsCard below health strip
This commit is contained in:
parent
1535bbaefa
commit
64d739b40d
3 changed files with 281 additions and 7 deletions
|
|
@ -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);
|
||||
|
|
|
|||
144
services/mam-api/src/routes/storage.js
Normal file
144
services/mam-api/src/routes/storage.js
Normal file
|
|
@ -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;
|
||||
|
|
@ -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() {
|
|||
))}
|
||||
</nav>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{section === 'storage' && <S3SettingsCard />}
|
||||
{section === 'storage' && <StorageSection />}
|
||||
{section === 'proxy' && <GpuSettingsCard />}
|
||||
{section === 'growing' && <GrowingSettingsCard />}
|
||||
{section === 'sdk' && <SdkSettingsCard />}
|
||||
{section === 'sdi' && <SdiSettingsCard />}
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<MountHealthStrip />
|
||||
<S3SettingsCard />
|
||||
<GrowingSettingsCard />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={cls} title={detail || ''} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'currentColor', display: 'inline-block' }} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SettingsCard icon="hdd" title="Mount health" sub="Live diagnostics for the storage subsystem"
|
||||
tag={<span className="badge warning">unavailable</span>}>
|
||||
<SettingsMsg msg={{ ok: false, text: 'Could not load /storage/overview: ' + error }} />
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<SettingsCard icon="hdd" title="Mount health" sub="Live diagnostics for the storage subsystem">
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Probing…</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
const g = data.growing;
|
||||
const s = data.s3;
|
||||
const growingHealthy = g.enabled ? (g.exists && g.writable) : true;
|
||||
|
||||
return (
|
||||
<SettingsCard icon="hdd" title="Mount health" sub="Live diagnostics for the storage subsystem"
|
||||
tag={
|
||||
<button className="btn ghost sm" onClick={load} disabled={refreshing} title="Re-probe now"
|
||||
style={{ padding: '2px 8px' }}>
|
||||
{refreshing ? '…' : 'Refresh'}
|
||||
</button>
|
||||
}>
|
||||
{/* ── Growing-files row ─────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 12.5 }}>Growing files</strong>
|
||||
{g.enabled
|
||||
? <HealthPill ok={growingHealthy} label={growingHealthy ? 'mounted' : 'unreachable'} detail={g.error || ''} />
|
||||
: <span className="badge neutral">disabled</span>}
|
||||
{g.enabled && g.exists && (
|
||||
<HealthPill ok={g.writable} label={g.writable ? 'writable' : 'read-only'} />
|
||||
)}
|
||||
{g.free_bytes != null && (
|
||||
<span className="badge neutral" title={g.total_bytes ? `of ${formatBytes(g.total_bytes)} total` : ''}>
|
||||
{formatBytes(g.free_bytes)} free
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
|
||||
<span>Container</span><span className="mono">{g.container_path || '—'}</span>
|
||||
<span>Host</span><span className="mono">{g.host_path || '—'}</span>
|
||||
<span>SMB</span><span className="mono">{g.smb_url || '—'}</span>
|
||||
<span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span>
|
||||
{g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border-1, rgba(255,255,255,0.06))', margin: '10px 0' }} />
|
||||
|
||||
{/* ── S3 bucket row ─────────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 12.5 }}>S3 bucket</strong>
|
||||
<HealthPill ok={s.reachable} label={s.reachable ? 'reachable' : 'unreachable'} detail={s.error || ''} />
|
||||
{s.head_latency_ms != null && (
|
||||
<span className="badge neutral">{s.head_latency_ms} ms</span>
|
||||
)}
|
||||
{s.probe_method && <span className="badge neutral">{s.probe_method}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
|
||||
<span>Endpoint</span><span className="mono">{s.endpoint || '(AWS default)'}</span>
|
||||
<span>Bucket</span><span className="mono">{s.bucket || '—'}</span>
|
||||
<span>Region</span><span className="mono">{s.region || '—'}</span>
|
||||
{s.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{s.error}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue