dragonflight/services/capture/src/capture-manager.js

58 lines
2.1 KiB
JavaScript
Raw Normal View History

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>
2026-05-31 14:50:31 -04:00
import { spawn, execFileSync } from 'child_process';
import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';
/**
* Reads stderr lines from a spawned process until it finds a JSON line.
* Non-JSON lines (e.g. [board] log messages from the bridge) are logged
* and skipped. Resolves with the parsed JSON object when a JSON line arrives.
* Rejects if the process exits before emitting JSON, or if timeoutMs elapses.
*/
function readFirstStderrLine(proc, timeoutMs = 35_000) {
return new Promise((resolve, reject) => {
let buf = '';
let settled = false;
const settle = (fn) => { if (settled) return; settled = true; fn(); };
const timer = setTimeout(() => {
settle(() => reject(new Error(`deltacast-capture: timed out waiting for format JSON after ${timeoutMs}ms`)));
}, timeoutMs);
proc.stderr.setEncoding('utf8');
proc.stderr.on('data', (chunk) => {
buf += chunk;
let nl;
// Process all complete lines in the buffer
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
// Skip non-JSON log lines emitted by the bridge (e.g. "[board] waiting...")
if (!line.startsWith('{')) {
console.error(`[deltacast-bridge] ${line}`);
continue;
}
clearTimeout(timer);
try {
const parsed = JSON.parse(line);
if (parsed.error) {
settle(() => reject(new Error(`deltacast-capture: ${parsed.error}`)));
} else {
settle(() => resolve(parsed));
}
} catch (e) {
settle(() => reject(new Error(`deltacast-capture: invalid JSON on stderr: ${line}`)));
}
return;
2026-05-17 07:39:19 -04:00
}
});
proc.on('exit', (code) => {
clearTimeout(timer);
settle(() => reject(new Error(`deltacast-capture: exited with code ${code} before emitting format JSON`)));
});
});
}