feat(settings/growing): storage warning, SMB auth + CIFS mount, per-recorder growing
Implements docs/superpowers/specs/2026-05-31-storage-settings-growing-smb-design.md.
1. Storage warning banner at the top of Settings → Storage (set-once /
path-change-corrupts-data warning).
2. Growing-files SMB credentials + system CIFS mount (Approach A):
- settings.js: new global keys growing_smb_mount / growing_smb_username /
growing_smb_vers; growing_smb_password is write-only (GET returns only
growing_smb_password_exists; growing_smb_password_clear:true removes it).
- GrowingSettingsCard: SMB mount/username/password (masked, "saved" state) +
CIFS version fields.
- capture Dockerfile: add cifs-utils + util-linux.
- capture-manager: on growing start, mount //host/share at /growing using a
root-only credentials file (creds never on the command line); unmount on
stop; mount failure falls back to S3 streaming so a recording is never lost.
- recorders.js: pass GROWING_SMB_* env; don't host-bind /growing when a CIFS
mount is configured (an empty mountpoint is required).
3. Per-recorder growing mode (global toggle removed):
- Removed the global "capture writes to local SMB share first" checkbox; the
growing card is now SMB-infrastructure-only.
- recorders.js reads the per-recorder recorders.growing_enabled column
(already present from migration 014) instead of the global setting;
RECORDER_FIELDS += growing_enabled.
- New-recorder modal: "Growing-files mode" toggle.
- storage.js overview: "enabled" now means the SMB landing zone is configured
(mount source set), surfaced as smb_mount; health strip labels updated.
No DB migration required (recorders.growing_enabled exists; new settings are
key/value rows).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3122dfd1b9
commit
5968d4f681
7 changed files with 266 additions and 36 deletions
|
|
@ -67,10 +67,14 @@ RUN /usr/local/bin/ffmpeg -hide_banner -encoders 2>&1 | grep -E 'nvenc' \
|
|||
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
|
||||
FROM node:20-bookworm
|
||||
|
||||
# Runtime deps for compiled ffmpeg libs
|
||||
# Runtime deps for compiled ffmpeg libs.
|
||||
# cifs-utils provides mount.cifs so growing-files capture can mount the SMB
|
||||
# landing-zone share inside the (privileged) container at start (Approach A).
|
||||
# util-linux supplies mount/umount/mountpoint.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libx264-164 libx265-199 libvpx7 libopus0 libmp3lame0 \
|
||||
libsrt1.5-openssl libzmq5 libstdc++6 libc++1 libc++abi1 \
|
||||
cifs-utils util-linux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy compiled ffmpeg/ffprobe
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { spawn } from 'child_process';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { spawn, execFileSync } from 'child_process';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { createUploadStream } from './s3/client.js';
|
||||
|
|
@ -9,11 +9,76 @@ const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
|||
// Growing-files mode: writes the master to a local SMB-backed share that the
|
||||
// editor can mount, instead of streaming to S3 in real time. The promotion
|
||||
// worker uploads the finalized file to S3 after the recording stops.
|
||||
// Toggled per-process by `GROWING_ENABLED=true` on the capture container
|
||||
// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container
|
||||
// (see routes/recorders.js where the env is composed).
|
||||
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
|
||||
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
|
||||
|
||||
// Approach A: when a CIFS source is supplied, this (privileged) container mounts
|
||||
// the SMB landing-zone share at GROWING_PATH itself, using credentials supplied
|
||||
// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount
|
||||
// (the host-bound /growing volume is used instead, or S3 streaming if growing
|
||||
// is off).
|
||||
const GROWING_SMB_MOUNT = process.env.GROWING_SMB_MOUNT || '';
|
||||
const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || '';
|
||||
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
|
||||
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';
|
||||
const SMB_CREDS_FILE = '/run/smb-creds';
|
||||
|
||||
// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
|
||||
// mounted, or a host bind-mount is present).
|
||||
function isMounted(path) {
|
||||
try { execFileSync('mountpoint', ['-q', path]); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
// Mount the CIFS growing share at GROWING_PATH. Credentials go in a root-only
|
||||
// file (NOT the command line) so they never appear in `ps`/process listings.
|
||||
// Returns true on success (or if already mounted), false on failure — callers
|
||||
// fall back to S3 streaming so a recording is never lost.
|
||||
function mountGrowingShare() {
|
||||
if (!GROWING_SMB_MOUNT) return false;
|
||||
try {
|
||||
if (isMounted(GROWING_PATH)) {
|
||||
console.log('[capture] growing share already mounted at', GROWING_PATH);
|
||||
return true;
|
||||
}
|
||||
try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {}
|
||||
writeFileSync(
|
||||
SMB_CREDS_FILE,
|
||||
`username=${GROWING_SMB_USERNAME}\npassword=${GROWING_SMB_PASSWORD}\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
const opts = [
|
||||
`credentials=${SMB_CREDS_FILE}`,
|
||||
'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
|
||||
`vers=${GROWING_SMB_VERS}`,
|
||||
].join(',');
|
||||
execFileSync('mount', ['-t', 'cifs', GROWING_SMB_MOUNT, GROWING_PATH, '-o', opts],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
console.log('[capture] mounted CIFS growing share', GROWING_SMB_MOUNT, '->', GROWING_PATH);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
|
||||
console.error('[capture] CIFS mount failed (falling back to S3 streaming):', stderr);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort unmount on session stop. Ignores "not mounted".
|
||||
function unmountGrowingShare() {
|
||||
if (!GROWING_SMB_MOUNT) return;
|
||||
try {
|
||||
if (isMounted(GROWING_PATH)) {
|
||||
execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
console.log('[capture] unmounted growing share at', GROWING_PATH);
|
||||
}
|
||||
} catch (err) {
|
||||
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
|
||||
console.warn('[capture] growing share unmount failed (ignored):', stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Codec catalogue ──────────────────────────────────────────────────────
|
||||
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
|
||||
// / pix_fmt are layered on top from the per-recorder configuration.
|
||||
|
|
@ -283,7 +348,15 @@ class CaptureManager {
|
|||
|
||||
// Growing-files: write master to the local SMB share instead of streaming
|
||||
// to S3. Path is relative to the container's GROWING_PATH mount.
|
||||
const growingPath = GROWING_ENABLED
|
||||
//
|
||||
// Approach A: if a CIFS source is configured, mount it now. A mount failure
|
||||
// is non-fatal — we fall back to S3 streaming so the recording is never
|
||||
// lost.
|
||||
let growingActive = GROWING_ENABLED;
|
||||
if (growingActive && GROWING_SMB_MOUNT) {
|
||||
if (!mountGrowingShare()) growingActive = false; // fall back to S3
|
||||
}
|
||||
const growingPath = growingActive
|
||||
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
|
||||
: null;
|
||||
if (growingPath) {
|
||||
|
|
@ -455,6 +528,11 @@ class CaptureManager {
|
|||
if (processes.proxy) processes.proxy.kill('SIGINT');
|
||||
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
|
||||
|
||||
// Release the CIFS mount (best-effort) once the ffmpeg writers are done with
|
||||
// it. The promotion worker reads the staged file from the host/S3 side, not
|
||||
// through this container's mount, so unmounting here is safe.
|
||||
unmountGrowingShare();
|
||||
|
||||
try {
|
||||
const uploadPromises = [currentSession.uploads.hires];
|
||||
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ const RECORDER_FIELDS = [
|
|||
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
|
||||
'proxy_container',
|
||||
'project_id', 'node_id', 'device_index',
|
||||
'growing_enabled',
|
||||
];
|
||||
|
||||
function pickRecorderFields(body) {
|
||||
|
|
@ -363,14 +364,25 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
const externalMamApiUrl = `http://${process.env.NODE_IP || '172.18.91.200'}:${process.env.PORT_MAM_API || 47432}`;
|
||||
const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';
|
||||
|
||||
// Growing-files mode is a global setting (settings table). When on, the
|
||||
// capture container writes the master to its /growing/ mount instead of
|
||||
// streaming it to S3 — Premiere can mount the SMB share and edit it live.
|
||||
const growingRow = await pool.query(
|
||||
`SELECT value FROM settings WHERE key = 'growing_enabled'`
|
||||
);
|
||||
const growingEnabled =
|
||||
growingRow.rows[0]?.value === 'true' || growingRow.rows[0]?.value === true;
|
||||
// Growing-files mode is a PER-RECORDER setting (recorders.growing_enabled).
|
||||
// When on, the capture container writes the master to its /growing/ mount
|
||||
// instead of streaming it to S3 — editors can mount the SMB share and cut it
|
||||
// live. The SMB share itself (mount source + credentials) is shared
|
||||
// infrastructure configured globally in Settings → Storage.
|
||||
const growingEnabled = recorder.growing_enabled === true;
|
||||
|
||||
// Shared growing-files SMB infrastructure (global settings). Used to mount
|
||||
// the CIFS share inside the capture container (services/capture mounts it
|
||||
// with these credentials when GROWING_SMB_MOUNT is set).
|
||||
const growingInfra = {};
|
||||
{
|
||||
const r = await pool.query(
|
||||
`SELECT key, value FROM settings WHERE key = ANY($1)`,
|
||||
[['growing_smb_mount', 'growing_smb_username', 'growing_smb_password', 'growing_smb_vers']]
|
||||
);
|
||||
for (const { key, value } of r.rows) growingInfra[key] = value;
|
||||
}
|
||||
const smbMount = growingEnabled ? (growingInfra.growing_smb_mount || '') : '';
|
||||
|
||||
// Operator-supplied clip name wins over the auto-timestamped fallback.
|
||||
// The Recorders UI passes this on the start request when the user types
|
||||
|
|
@ -455,6 +467,13 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
|
||||
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
|
||||
`GROWING_PATH=/growing`,
|
||||
// SMB mount details for the in-container CIFS mount (Approach A). Empty
|
||||
// GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume
|
||||
// (or to S3 streaming if growing isn't enabled).
|
||||
`GROWING_SMB_MOUNT=${smbMount}`,
|
||||
`GROWING_SMB_USERNAME=${growingInfra.growing_smb_username || ''}`,
|
||||
`GROWING_SMB_PASSWORD=${growingInfra.growing_smb_password || ''}`,
|
||||
`GROWING_SMB_VERS=${growingInfra.growing_smb_vers || '3.0'}`,
|
||||
];
|
||||
|
||||
// Deltacast: pass port count so the capture container can enumerate
|
||||
|
|
@ -530,7 +549,15 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
|||
for (const d of dcEntries) hostBinds.push(`/dev/${d}:/dev/${d}`);
|
||||
} catch (_) { /* no /dev/deltacast* nodes on this host */ }
|
||||
}
|
||||
if (growingEnabled) hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
|
||||
// /growing handling:
|
||||
// - SMB mount configured → DON'T host-bind; the capture container mounts
|
||||
// the CIFS share at /growing itself (Approach A). A bind-mount here
|
||||
// would shadow the in-container mount.
|
||||
// - growing on but no SMB mount → legacy host bind-mount fallback.
|
||||
// - growing off → no /growing mount at all.
|
||||
if (growingEnabled && !smbMount) {
|
||||
hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
|
||||
}
|
||||
|
||||
const localEnv = [...env];
|
||||
if (useGpu) {
|
||||
|
|
|
|||
|
|
@ -258,21 +258,45 @@ router.put('/transcoding', async (req, res, next) => {
|
|||
// while it's still being written; the promotion worker later moves the
|
||||
// finalized file to S3 and flips the asset to status='ready'.
|
||||
|
||||
const GROWING_KEYS = ['growing_enabled', 'growing_path', 'growing_smb_url', 'growing_promote_after_seconds'];
|
||||
// Growing-files mode is now a PER-RECORDER setting (recorders.growing_enabled);
|
||||
// the legacy global `growing_enabled` key is no longer read at recorder start.
|
||||
// These global keys describe the shared SMB landing-zone infrastructure only:
|
||||
// - growing_path container mount point (default /growing)
|
||||
// - growing_smb_url smb://... display string for editors (Premiere)
|
||||
// - growing_smb_mount //host/share CIFS source the capture container mounts
|
||||
// - growing_smb_username SMB user for the system-side CIFS mount
|
||||
// - growing_smb_password SMB password (WRITE-ONLY; never returned)
|
||||
// - growing_smb_vers CIFS protocol version (default 3.0)
|
||||
// - growing_promote_after_seconds idle threshold before S3 promotion
|
||||
const GROWING_KEYS = [
|
||||
'growing_path', 'growing_smb_url', 'growing_smb_mount',
|
||||
'growing_smb_username', 'growing_smb_vers', 'growing_promote_after_seconds',
|
||||
];
|
||||
// growing_smb_password is handled separately: stored on PUT but NEVER returned
|
||||
// on GET (only a *_exists flag), mirroring s3_secret_key.
|
||||
|
||||
router.get('/growing', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT key, value FROM settings WHERE key = ANY($1)`,
|
||||
[GROWING_KEYS]
|
||||
[[...GROWING_KEYS, 'growing_smb_password']]
|
||||
);
|
||||
const out = {
|
||||
growing_enabled: 'false',
|
||||
growing_path: '/growing',
|
||||
growing_smb_url: '',
|
||||
growing_smb_mount: '',
|
||||
growing_smb_username: '',
|
||||
growing_smb_vers: '3.0',
|
||||
growing_promote_after_seconds: '8',
|
||||
growing_smb_password_exists: false,
|
||||
};
|
||||
for (const { key, value } of result.rows) out[key] = value;
|
||||
for (const { key, value } of result.rows) {
|
||||
if (key === 'growing_smb_password') {
|
||||
out.growing_smb_password_exists = !!(value && value.length);
|
||||
} else {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
res.json(out);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
|
@ -290,6 +314,19 @@ router.put('/growing', async (req, res, next) => {
|
|||
);
|
||||
}
|
||||
}
|
||||
// SMB password is write-only. A non-empty value sets/replaces it. To remove
|
||||
// it, send growing_smb_password_clear:true. A blank/omitted password field
|
||||
// leaves the stored value untouched (so operators don't retype it on every
|
||||
// save).
|
||||
if (req.body.growing_smb_password_clear === true) {
|
||||
await pool.query(`DELETE FROM settings WHERE key = 'growing_smb_password'`);
|
||||
} else if (typeof req.body.growing_smb_password === 'string' && req.body.growing_smb_password.length > 0) {
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES ('growing_smb_password', $1, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
|
||||
[req.body.growing_smb_password]
|
||||
);
|
||||
}
|
||||
res.json({ message: 'Growing-files settings saved' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@ 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_enabled: 'false',
|
||||
growing_path: '/growing',
|
||||
growing_smb_url: '',
|
||||
growing_smb_mount: '',
|
||||
growing_promote_after_seconds: '8',
|
||||
};
|
||||
|
||||
|
|
@ -100,7 +102,9 @@ 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;
|
||||
// "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);
|
||||
|
||||
|
|
@ -117,6 +121,7 @@ router.get('/overview', async (req, res, next) => {
|
|||
// 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,
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ function NewRecorderModal({ open, onClose }) {
|
|||
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
|
||||
const codecUsesBitrate = BITRATE_CODECS.has(recCodec);
|
||||
const [proxyOn, setProxyOn] = React.useState(true);
|
||||
const [growingOn, setGrowingOn] = React.useState(false);
|
||||
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [submitErr, setSubmitErr] = React.useState(null);
|
||||
|
|
@ -206,6 +207,7 @@ function NewRecorderModal({ open, onClose }) {
|
|||
source_type: sourceType.toLowerCase(),
|
||||
project_id: projectId || undefined,
|
||||
generate_proxy: proxyOn,
|
||||
growing_enabled: growingOn,
|
||||
recording_codec: recCodec,
|
||||
recording_container: recContainer,
|
||||
// Framerate + resolution are auto-detected from the source signal/stream.
|
||||
|
|
@ -473,6 +475,20 @@ function NewRecorderModal({ open, onClose }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-toggle-row">
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={growingOn} onChange={e => setGrowingOn(e.target.checked)} />
|
||||
<span className="switch-track"><span className="switch-knob" /></span>
|
||||
</label>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>Growing-files mode</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
|
||||
Write the live master to the SMB share so editors can cut while it's still recording.
|
||||
Requires the SMB share to be configured in Settings → Storage.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{proxyOn && (
|
||||
<div className="modal-section">
|
||||
<div className="modal-section-head"><span>Proxy</span></div>
|
||||
|
|
|
|||
|
|
@ -1749,6 +1749,7 @@ function Settings() {
|
|||
function StorageSection() {
|
||||
return (
|
||||
<>
|
||||
<StorageWarningBanner />
|
||||
<MountHealthStrip />
|
||||
<S3SettingsCard />
|
||||
<GrowingSettingsCard />
|
||||
|
|
@ -1756,6 +1757,27 @@ function StorageSection() {
|
|||
);
|
||||
}
|
||||
|
||||
// Set-once deployment warning. Storage paths are written into asset rows and
|
||||
// the S3 layout at ingest time; changing them after assets exist orphans files
|
||||
// and can corrupt the library's view of where masters/proxies live.
|
||||
function StorageWarningBanner() {
|
||||
return (
|
||||
<div role="alert" style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||
padding: '14px 16px', marginBottom: 14, borderRadius: 10,
|
||||
border: '1px solid var(--danger)',
|
||||
background: 'color-mix(in srgb, var(--danger) 12%, transparent)',
|
||||
}}>
|
||||
<Icon name="alert" size={20} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: 1 }} />
|
||||
<div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '0.02em', lineHeight: 1.5, color: 'var(--text-1)' }}>
|
||||
WARNING — THESE SETTINGS ARE MEANT TO BE SET ONCE AT INITIAL DEPLOYMENT.
|
||||
CHANGING STORAGE PATHS MID-CYCLE CAN CAUSE DATABASE CORRUPTION AND FILE LOSS.
|
||||
PLEASE USE WITH CAUTION.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (n == null || isNaN(n)) return '·';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
|
@ -1828,8 +1850,8 @@ function MountHealthStrip() {
|
|||
<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>}
|
||||
? <HealthPill ok={growingHealthy} label={growingHealthy ? 'configured' : 'unreachable'} detail={g.error || ''} />
|
||||
: <span className="badge neutral">not configured</span>}
|
||||
{g.enabled && g.exists && (
|
||||
<HealthPill ok={g.writable} label={g.writable ? 'writable' : 'read-only'} />
|
||||
)}
|
||||
|
|
@ -1842,7 +1864,8 @@ function MountHealthStrip() {
|
|||
<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>SMB mount</span><span className="mono">{g.smb_mount || '·'}</span>
|
||||
<span>SMB (editors)</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>
|
||||
|
|
@ -2036,35 +2059,75 @@ function GpuSettingsCard() {
|
|||
|
||||
function GrowingSettingsCard() {
|
||||
const [cfg, setCfg] = React.useState(null);
|
||||
const [pwd, setPwd] = React.useState(''); // new password to set; '' = leave unchanged
|
||||
const [pwdExists, setPwdExists] = React.useState(false);
|
||||
const [clearPwd, setClearPwd] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [msg, setMsg] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.ZAMPP_API.fetch('/settings/growing').then(setCfg).catch(() => setCfg({
|
||||
growing_enabled: 'false', growing_path: '/growing', growing_smb_url: '', growing_promote_after_seconds: '8',
|
||||
}));
|
||||
window.ZAMPP_API.fetch('/settings/growing')
|
||||
.then(d => { setCfg(d); setPwdExists(!!d.growing_smb_password_exists); })
|
||||
.catch(() => setCfg({
|
||||
growing_path: '/growing', growing_smb_url: '', growing_smb_mount: '',
|
||||
growing_smb_username: '', growing_smb_vers: '3.0', growing_promote_after_seconds: '8',
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
setSaving(true); setMsg(null);
|
||||
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(cfg) })
|
||||
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })
|
||||
const body = {
|
||||
growing_path: cfg.growing_path,
|
||||
growing_smb_url: cfg.growing_smb_url,
|
||||
growing_smb_mount: cfg.growing_smb_mount,
|
||||
growing_smb_username: cfg.growing_smb_username,
|
||||
growing_smb_vers: cfg.growing_smb_vers,
|
||||
growing_promote_after_seconds: cfg.growing_promote_after_seconds,
|
||||
};
|
||||
if (clearPwd) body.growing_smb_password_clear = true;
|
||||
else if (pwd) body.growing_smb_password = pwd;
|
||||
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(body) })
|
||||
.then(() => {
|
||||
setSaving(false); setMsg({ ok: true, text: 'Saved.' });
|
||||
if (clearPwd) { setPwdExists(false); setClearPwd(false); }
|
||||
else if (pwd) { setPwdExists(true); setPwd(''); }
|
||||
})
|
||||
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
|
||||
};
|
||||
|
||||
if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…"><div style={{color:'var(--text-3)'}}>…</div></SettingsCard>;
|
||||
const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
|
||||
const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true;
|
||||
const mountConfigured = !!(cfg.growing_smb_mount && cfg.growing_smb_mount.trim());
|
||||
|
||||
return (
|
||||
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"
|
||||
tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}>
|
||||
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="Shared SMB landing zone. Enable per-recorder under Ingest → Recorders."
|
||||
tag={mountConfigured ? <span className="badge success">configured</span> : <span className="badge neutral">not configured</span>}>
|
||||
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
|
||||
<SField label="Enable growing-file capture">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
|
||||
<input type="checkbox" checked={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} />
|
||||
<span style={{ color: 'var(--text-2)' }}>Capture writes to the local SMB share first; Premier can edit while it's still growing.</span>
|
||||
</label>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginBottom: 10, lineHeight: 1.5 }}>
|
||||
Growing-file mode is enabled <strong style={{ color: 'var(--text-2)' }}>per recorder</strong> (New recorder → Growing-files mode).
|
||||
These settings describe the SMB share that capture mounts and writes the live master to.
|
||||
</div>
|
||||
<SField label="SMB mount source (CIFS)">
|
||||
<input className="field-input mono" value={cfg.growing_smb_mount || ''} onChange={e => set('growing_smb_mount', e.target.value)} placeholder="//10.0.0.25/mam-growing" />
|
||||
</SField>
|
||||
<SField label="SMB username">
|
||||
<input className="field-input mono" value={cfg.growing_smb_username || ''} onChange={e => set('growing_smb_username', e.target.value)} placeholder="capture" autoComplete="off" />
|
||||
</SField>
|
||||
<SField label="SMB password">
|
||||
<input className="field-input mono" type="password" autoComplete="new-password"
|
||||
value={pwd}
|
||||
disabled={clearPwd}
|
||||
onChange={e => setPwd(e.target.value)}
|
||||
placeholder={pwdExists ? '•••••••• (saved — leave blank to keep)' : 'Enter SMB password'} />
|
||||
{pwdExists && (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: 'var(--text-3)', marginTop: 4 }}>
|
||||
<input type="checkbox" checked={clearPwd} onChange={e => { setClearPwd(e.target.checked); if (e.target.checked) setPwd(''); }} />
|
||||
Remove saved password
|
||||
</label>
|
||||
)}
|
||||
</SField>
|
||||
<SField label="CIFS protocol version">
|
||||
<input className="field-input mono" value={cfg.growing_smb_vers || ''} onChange={e => set('growing_smb_vers', e.target.value)} placeholder="3.0" />
|
||||
</SField>
|
||||
<SField label="Container mount path">
|
||||
<input className="field-input mono" value={cfg.growing_path || ''} onChange={e => set('growing_path', e.target.value)} placeholder="/growing" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue