dragonflight/services/web-ui/public/settings.html

753 lines
32 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<title>Settings — Z-AMPP</title>
<link rel="stylesheet" href="css/common.css">
<style>
.settings-layout {
display: flex;
flex-direction: column;
gap: 32px;
max-width: 780px;
}
.settings-section {
background: oklch(13% 0.018 250 / 0.6);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.settings-section-header {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 24px 16px;
border-bottom: 1px solid var(--border);
}
.settings-section-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.settings-section-icon.s3 { background: oklch(20% 0.08 195 / 0.5); color: oklch(68% 0.18 195); }
.settings-section-icon.gpu { background: oklch(20% 0.08 266 / 0.5); color: oklch(70% 0.18 266); }
.settings-section-icon.sdi { background: oklch(20% 0.08 25 / 0.5); color: oklch(72% 0.18 40); }
.settings-section-icon.ampp { background: oklch(20% 0.08 148 / 0.5); color: oklch(68% 0.18 148); }
.settings-section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.005em;
}
.settings-section-desc {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 2px;
}
.settings-section-body {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* Hardware table */
.hw-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.hw-table thead tr {
border-bottom: 1px solid var(--border);
}
.hw-table th {
padding: 6px 10px;
text-align: left;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.hw-table th:first-child { padding-left: 0; }
.hw-table tbody tr {
border-bottom: 1px solid oklch(28% 0.04 260 / 0.3);
}
.hw-table tbody tr:last-child { border-bottom: none; }
.hw-table td {
padding: 10px 10px;
vertical-align: middle;
color: var(--text-primary);
}
.hw-table td:first-child { padding-left: 0; }
.hw-node-name {
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.hw-caps {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.hw-cap-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
font-family: var(--font-mono);
}
.hw-cap-badge.gpu { background: oklch(20% 0.08 266 / 0.4); color: oklch(70% 0.18 266); border: 1px solid oklch(45% 0.20 266 / 0.3); }
.hw-cap-badge.bmd { background: oklch(20% 0.08 25 / 0.4); color: oklch(72% 0.18 40); border: 1px solid oklch(62% 0.22 25 / 0.3); }
.hw-cap-badge.none { background: var(--bg-surface); color: var(--text-tertiary); border: 1px solid var(--border); }
/* Divider between subsections */
.settings-divider {
border: none;
border-top: 1px solid var(--border);
margin: 0 -24px;
}
/* Inline save feedback */
.save-feedback {
font-size: 12px;
font-weight: 500;
color: oklch(68% 0.18 148);
opacity: 0;
transition: opacity 400ms ease;
min-width: 80px;
}
.save-feedback.visible { opacity: 1; }
/* Test result inline */
.test-result {
font-size: 12px;
padding: 6px 12px;
border-radius: 6px;
display: none;
}
.test-result.ok { display: block; background: oklch(68% 0.18 148 / 0.1); border: 1px solid oklch(68% 0.18 148 / 0.3); color: oklch(68% 0.18 148); }
.test-result.error { display: block; background: oklch(62% 0.22 25 / 0.1); border: 1px solid oklch(62% 0.22 25 / 0.3); color: oklch(72% 0.18 40); }
.form-row-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
}
/* Secret key row with show/hide toggle */
.input-with-action {
display: flex;
gap: 8px;
}
.input-with-action input {
flex: 1;
}
</style>
</head>
<body>
<div class="shell">
<nav class="sidebar" aria-label="Main navigation">
<div class="sidebar-brand">
<img src="img/dragon-logo.png?v=1" alt="Wild Dragon" class="sidebar-logo">
<span class="sidebar-brand-name">Z-AMPP</span>
</div>
<nav class="sidebar-nav">
<a href="home.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
Home
</a>
<a href="index.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
Library
</a>
<a href="projects.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
Projects
</a>
<a href="upload.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
Ingest
</a>
<a href="recorders.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
Recorders
</a>
<a href="capture.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
Capture
</a>
<a href="jobs.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
Jobs
</a>
<a href="editor.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
Editor
</a>
<div class="sidebar-section-label">Admin</div>
<a href="users.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
Users
</a>
<a href="tokens.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="5" width="14" height="4" rx="1"/><rect x="1" y="10" width="14" height="4" rx="1"/><path d="M4 7h1M4 12h1"/></svg>
Containers
</a>
<a href="cluster.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2"/><circle cx="2" cy="3" r="1.5"/><circle cx="14" cy="3" r="1.5"/><circle cx="2" cy="13" r="1.5"/><circle cx="14" cy="13" r="1.5"/><path d="M3.1 4.1L6.5 6.5M12.9 4.1L9.5 6.5M3.1 11.9L6.5 9.5M12.9 11.9L9.5 9.5"/></svg>
Cluster
</a>
<a href="settings.html" class="nav-item active">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
Settings
</a>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="sidebar-user-avatar" id="userAvatar">?</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name" id="userName"></div>
<div class="sidebar-user-role" id="userRole"></div>
</div>
<button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
</button>
</div>
</div>
</nav>
<div class="main">
<header class="topbar">
<div class="topbar-left">
<span class="page-title">Settings</span>
</div>
</header>
<div class="page-content">
<div class="settings-layout">
<!-- ── S3 / Object Storage ───────────────────────────────────── -->
<div class="settings-section">
<div class="settings-section-header">
<div class="settings-section-icon s3">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<ellipse cx="8" cy="4" rx="6" ry="2.5"/>
<path d="M2 4v4c0 1.4 2.7 2.5 6 2.5S14 9.4 14 8V4"/>
<path d="M2 8v4c0 1.4 2.7 2.5 6 2.5S14 13.4 14 12V8"/>
</svg>
</div>
<div>
<div class="settings-section-title">S3 / Object Storage</div>
<div class="settings-section-desc">S3-compatible bucket for media asset storage (Garage, MinIO, AWS S3, etc.)</div>
</div>
</div>
<div class="settings-section-body">
<div style="font-size:13px;color:var(--text-secondary);line-height:1.6;padding:12px 14px;background:oklch(11% 0.018 250 / 0.5);border:1px solid var(--border);border-radius:8px;">
<strong style="color:var(--text-primary);">Initial setup required:</strong>
Configure your S3-compatible object store below. Changes take effect immediately without restarting the API.
Use <strong>Test Connection</strong> to verify credentials before saving.
</div>
<div class="form-row">
<div class="form-group" style="flex:2;">
<label class="form-label" for="s3Endpoint">Endpoint URL</label>
<input type="url" id="s3Endpoint" placeholder="http://192.168.1.10:9000" autocomplete="off">
<div class="form-hint">Full URL including protocol and port. Leave blank to use AWS S3.</div>
</div>
<div class="form-group" style="flex:1;">
<label class="form-label" for="s3Region">Region</label>
<input type="text" id="s3Region" placeholder="us-east-1" autocomplete="off">
<div class="form-hint">Required for AWS; use any value for self-hosted.</div>
</div>
</div>
<div class="form-group">
<label class="form-label" for="s3Bucket">Bucket name</label>
<input type="text" id="s3Bucket" placeholder="mam" autocomplete="off">
</div>
<div class="form-group">
<label class="form-label" for="s3AccessKey">Access key ID</label>
<input type="text" id="s3AccessKey" placeholder="Access key" autocomplete="off" spellcheck="false">
</div>
<div class="form-group">
<label class="form-label" for="s3SecretKey">Secret access key</label>
<div class="input-with-action">
<input type="password" id="s3SecretKey" placeholder="Leave blank to keep current secret" autocomplete="off" spellcheck="false">
<button class="btn btn-ghost btn-sm" id="s3SecretToggle" onclick="toggleS3SecretVisibility()" style="flex-shrink:0;">Show</button>
</div>
<div class="form-hint" id="s3SecretHint" style="display:none;">
A secret key is currently saved. Enter a new value to replace it.
</div>
</div>
<div class="form-row-actions">
<button class="btn btn-primary btn-sm" onclick="saveS3()">Save &amp; Apply</button>
<button class="btn btn-ghost btn-sm" onclick="testS3()">Test connection</button>
<span class="save-feedback" id="s3Feedback">Saved</span>
</div>
<div class="test-result" id="s3TestResult"></div>
</div>
</div>
<!-- ── GPU / Transcoding ─────────────────────────────────────── -->
<div class="settings-section">
<div class="settings-section-header">
<div class="settings-section-icon gpu">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<rect x="1" y="4" width="14" height="8" rx="1.5"/>
<path d="M4 4V2M8 4V2M12 4V2M4 12v2M8 12v2M12 12v2"/>
<path d="M4 8h2M7 8h2M10 8h2"/>
</svg>
</div>
<div>
<div class="settings-section-title">GPU / Transcoding</div>
<div class="settings-section-desc">NVIDIA NVENC acceleration for proxy generation and transcoding jobs</div>
</div>
</div>
<div class="settings-section-body">
<!-- Node hardware inventory -->
<div>
<div style="font-size:11px;font-weight:600;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary);margin-bottom:10px;">
Node hardware inventory
<button class="btn btn-ghost btn-sm" onclick="loadHardware()" style="margin-left:8px;font-size:11px;padding:2px 8px;">Refresh</button>
</div>
<div id="hwTableWrap">
<div style="color:var(--text-tertiary);font-size:13px;">Loading…</div>
</div>
</div>
<hr class="settings-divider">
<!-- GPU transcoding toggle -->
<div class="form-group" style="margin:0;">
<label class="toggle">
<input type="checkbox" id="gpuEnabled" onchange="onGpuToggle()">
<div class="toggle-track"></div>
<span class="toggle-label">Enable GPU-accelerated transcoding</span>
</label>
<div class="form-hint">When enabled, proxy generation and transcode jobs use NVENC instead of CPU ffmpeg.</div>
</div>
<!-- GPU settings (shown when enabled) -->
<div id="gpuFields" style="display:none;">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="gpuCodec">Encoder</label>
<select id="gpuCodec">
<option value="h264_nvenc">H.264 (h264_nvenc)</option>
<option value="hevc_nvenc">H.265 / HEVC (hevc_nvenc)</option>
<option value="av1_nvenc">AV1 (av1_nvenc) — Ada+ only</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="gpuPreset">Quality preset</label>
<select id="gpuPreset">
<option value="p1">p1 — fastest</option>
<option value="p2">p2 — fast</option>
<option value="p3">p3 — balanced</option>
<option value="p4" selected>p4 — medium (default)</option>
<option value="p5">p5 — slow</option>
<option value="p6">p6 — slower</option>
<option value="p7">p7 — slowest / highest quality</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="gpuBitrate">Proxy bitrate (Mbps)</label>
<input type="number" id="gpuBitrate" value="8" min="1" max="100" step="1">
</div>
</div>
<div class="form-group">
<label class="form-label" for="gpuNode">Transcoding node</label>
<select id="gpuNode">
<option value="">Auto (first online node with GPU)</option>
</select>
<div class="form-hint">Force all GPU jobs to a specific cluster node, or leave on Auto.</div>
</div>
</div>
<div class="form-row-actions">
<button class="btn btn-primary btn-sm" onclick="saveTranscoding()">Save GPU settings</button>
<span class="save-feedback" id="gpuFeedback">Saved</span>
</div>
</div>
</div>
<!-- ── SDI Capture Service ───────────────────────────────────── -->
<div class="settings-section">
<div class="settings-section-header">
<div class="settings-section-icon sdi">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<rect x="1" y="4" width="10" height="8" rx="1"/>
<path d="M11 7l4-2v6l-4-2"/>
<circle cx="5.5" cy="8" r="1.5"/>
</svg>
</div>
<div>
<div class="settings-section-title">SDI Capture Service</div>
<div class="settings-section-desc">Route SDI capture to a remote node with a Blackmagic DeckLink card</div>
</div>
</div>
<div class="settings-section-body">
<div style="font-size:13px;color:var(--text-secondary);line-height:1.6;padding:12px 14px;background:oklch(11% 0.018 250 / 0.5);border:1px solid var(--border);border-radius:8px;">
<strong style="color:var(--text-primary);">Multi-node capture routing:</strong>
By default the Capture page talks to the local <code>capture</code> sidecar.
Set a remote URL here to forward all SDI capture API calls to a secondary MAM node
running <code>--profile capture</code> (e.g. a machine with a DeckLink card).
Leave blank to use the local capture service.
</div>
<div class="form-group">
<label class="form-label" for="captureUrl">Remote capture service URL</label>
<input type="url" id="captureUrl" placeholder="http://10.0.0.26:7437" autocomplete="off">
<div class="form-hint">Base URL of the capture service on the remote node. Leave blank for local sidecar.</div>
</div>
<div class="form-row-actions">
<button class="btn btn-primary btn-sm" onclick="saveCaptureService()">Save</button>
<button class="btn btn-ghost btn-sm" onclick="testCaptureService()">Test connection</button>
<span class="save-feedback" id="captureFeedback">Saved</span>
</div>
<div class="test-result" id="captureTestResult"></div>
</div>
</div>
<!-- ── AMPP Integration ──────────────────────────────────────── -->
<div class="settings-section">
<div class="settings-section-header">
<div class="settings-section-icon ampp">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<path d="M8 1l2 5h5l-4 3 1.5 5L8 11l-4.5 3L5 9 1 6h5z"/>
</svg>
</div>
<div>
<div class="settings-section-title">AMPP Integration</div>
<div class="settings-section-desc">Grass Valley AMPP platform connectivity for asset sync</div>
</div>
</div>
<div class="settings-section-body">
<div class="form-group">
<label class="form-label" for="amppUrl">AMPP base URL</label>
<input type="url" id="amppUrl" placeholder="https://ampp.example.com" autocomplete="off">
</div>
<div class="form-group">
<label class="form-label" for="amppToken">API token</label>
<input type="password" id="amppToken" placeholder="Leave blank to keep current token" autocomplete="off">
<div class="form-hint" id="amppTokenHint" style="display:none;">
A token is currently saved. Enter a new value to replace it.
</div>
</div>
<div class="form-row-actions">
<button class="btn btn-primary btn-sm" onclick="saveAmpp()">Save</button>
<button class="btn btn-ghost btn-sm" onclick="testAmpp()">Test connection</button>
<span class="save-feedback" id="amppFeedback">Saved</span>
</div>
<div class="test-result" id="amppTestResult"></div>
</div>
</div>
</div><!-- /settings-layout -->
</div><!-- /page-content -->
</div><!-- /main -->
</div><!-- /shell -->
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js?v=6"></script>
<script src="js/topbar-strip.js?v=1"></script>
<script>
const API = '/api/v1';
async function api(path, opts = {}) {
const r = await fetch(API + path, Object.assign({ credentials: 'include', headers: { 'Content-Type': 'application/json' } }, opts));
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.error || `HTTP ${r.status}`); }
return r.json();
}
function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── S3 / Object Storage ────────────────────────────────────────────────────
async function loadS3() {
try {
const d = await api('/settings/s3');
document.getElementById('s3Endpoint').value = d.s3_endpoint || '';
document.getElementById('s3Bucket').value = d.s3_bucket || '';
document.getElementById('s3AccessKey').value = d.s3_access_key || '';
document.getElementById('s3Region').value = d.s3_region || '';
if (d.s3_secret_key_exists) {
document.getElementById('s3SecretHint').style.display = '';
}
} catch (_) {}
}
async function saveS3() {
try {
const body = {
s3_endpoint: document.getElementById('s3Endpoint').value.trim(),
s3_bucket: document.getElementById('s3Bucket').value.trim(),
s3_access_key: document.getElementById('s3AccessKey').value.trim(),
s3_region: document.getElementById('s3Region').value.trim(),
};
const secret = document.getElementById('s3SecretKey').value.trim();
if (secret) body.s3_secret_key = secret;
await api('/settings/s3', { method: 'PUT', body: JSON.stringify(body) });
document.getElementById('s3SecretKey').value = '';
document.getElementById('s3SecretHint').style.display = '';
flashFeedback('s3Feedback');
hideTestResult('s3TestResult');
} catch (e) { toast('Save failed: ' + e.message, 'error'); }
}
async function testS3() {
const el = document.getElementById('s3TestResult');
try {
const body = {
s3_endpoint: document.getElementById('s3Endpoint').value.trim(),
s3_bucket: document.getElementById('s3Bucket').value.trim(),
s3_access_key: document.getElementById('s3AccessKey').value.trim(),
s3_region: document.getElementById('s3Region').value.trim(),
};
const secret = document.getElementById('s3SecretKey').value.trim();
if (secret) body.s3_secret_key = secret;
const d = await api('/settings/s3/test', { method: 'POST', body: JSON.stringify(body) });
showTestResult(el, true, d.message || 'Connection successful');
} catch (e) { showTestResult(el, false, e.message); }
}
function toggleS3SecretVisibility() {
const inp = document.getElementById('s3SecretKey');
const btn = document.getElementById('s3SecretToggle');
if (inp.type === 'password') {
inp.type = 'text';
btn.textContent = 'Hide';
} else {
inp.type = 'password';
btn.textContent = 'Show';
}
}
// ── Hardware inventory ─────────────────────────────────────────────────────
async function loadHardware() {
const wrap = document.getElementById('hwTableWrap');
try {
const { nodes } = await api('/settings/hardware');
if (!nodes.length) {
wrap.innerHTML = '<div style="color:var(--text-tertiary);font-size:13px;">No cluster nodes registered yet.</div>';
return;
}
wrap.innerHTML = `<table class="hw-table">
<thead><tr><th>Node</th><th>Role</th><th>Hardware</th><th>Status</th></tr></thead>
<tbody>${nodes.map(n => {
const gpus = (n.capabilities.gpus || []);
const bmds = (n.capabilities.blackmagic || []);
const badges = [
...gpus.map((g, i) => `<span class="hw-cap-badge gpu">GPU ${i}</span>`),
...bmds.map((b, i) => `<span class="hw-cap-badge bmd">DeckLink ${i}</span>`),
].join('') || '<span class="hw-cap-badge none">none</span>';
return `<tr>
<td><div class="hw-node-name">
<span class="status-dot ${n.online ? 'status-dot--recording' : 'status-dot--idle'}"></span>
${esc(n.hostname)}
</div><div style="font-size:11px;color:var(--text-tertiary);font-family:var(--font-mono);">${esc(n.ip_address || '')}</div></td>
<td><span class="badge badge-idle" style="font-size:11px">${esc(n.role)}</span></td>
<td><div class="hw-caps">${badges}</div></td>
<td><span class="badge ${n.online ? 'badge-ready' : 'badge-error'}" style="font-size:11px">${n.online ? 'Online' : 'Offline'}</span></td>
</tr>`;
}).join('')}</tbody>
</table>`;
// Populate GPU node selector
const sel = document.getElementById('gpuNode');
const cur = sel.value;
sel.innerHTML = '<option value="">Auto (first online node with GPU)</option>';
nodes.filter(n => n.online && (n.capabilities.gpus || []).length > 0).forEach(n => {
const gpuCount = n.capabilities.gpus.length;
sel.innerHTML += `<option value="${esc(n.id)}">${esc(n.hostname)} (${gpuCount} GPU${gpuCount > 1 ? 's' : ''})</option>`;
});
if (cur) sel.value = cur;
} catch (e) {
wrap.innerHTML = `<div style="color:var(--status-red);font-size:13px;">Failed to load hardware: ${esc(e.message)}</div>`;
}
}
// ── GPU / Transcoding ──────────────────────────────────────────────────────
async function loadTranscoding() {
try {
const d = await api('/settings/transcoding');
document.getElementById('gpuEnabled').checked = d.gpu_transcode_enabled === 'true';
document.getElementById('gpuCodec').value = d.gpu_codec || 'h264_nvenc';
document.getElementById('gpuPreset').value = d.gpu_preset || 'p4';
document.getElementById('gpuBitrate').value = d.gpu_bitrate_mbps || '8';
document.getElementById('gpuNode').value = d.gpu_node || '';
onGpuToggle();
} catch (_) {}
}
function onGpuToggle() {
document.getElementById('gpuFields').style.display =
document.getElementById('gpuEnabled').checked ? 'block' : 'none';
}
async function saveTranscoding() {
try {
await api('/settings/transcoding', {
method: 'PUT',
body: JSON.stringify({
gpu_transcode_enabled: String(document.getElementById('gpuEnabled').checked),
gpu_codec: document.getElementById('gpuCodec').value,
gpu_preset: document.getElementById('gpuPreset').value,
gpu_bitrate_mbps: document.getElementById('gpuBitrate').value,
gpu_node: document.getElementById('gpuNode').value,
}),
});
flashFeedback('gpuFeedback');
} catch (e) { toast('Save failed: ' + e.message, 'error'); }
}
// ── Capture service ────────────────────────────────────────────────────────
async function loadCaptureService() {
try {
const d = await api('/settings/capture-service');
document.getElementById('captureUrl').value = d.capture_service_url || '';
} catch (_) {}
}
async function saveCaptureService() {
try {
await api('/settings/capture-service', {
method: 'PUT',
body: JSON.stringify({ capture_service_url: document.getElementById('captureUrl').value.trim() }),
});
flashFeedback('captureFeedback');
hideTestResult('captureTestResult');
} catch (e) { toast('Save failed: ' + e.message, 'error'); }
}
async function testCaptureService() {
const url = document.getElementById('captureUrl').value.trim();
const el = document.getElementById('captureTestResult');
if (!url) { showTestResult(el, false, 'Enter a URL first'); return; }
try {
const r = await fetch(url + '/health', { signal: AbortSignal.timeout(5000) });
const body = await r.json().catch(() => ({}));
if (r.ok) showTestResult(el, true, `Connected — ${body.hostname || url} (${body.role || 'unknown'} node)`);
else showTestResult(el, false, `HTTP ${r.status}`);
} catch (e) { showTestResult(el, false, e.message); }
}
// ── AMPP ───────────────────────────────────────────────────────────────────
async function loadAmpp() {
try {
const d = await api('/settings/ampp');
document.getElementById('amppUrl').value = d.ampp_base_url || '';
if (d.ampp_token_exists) {
document.getElementById('amppTokenHint').style.display = '';
}
} catch (_) {}
}
async function saveAmpp() {
try {
const body = { ampp_base_url: document.getElementById('amppUrl').value.trim() };
const token = document.getElementById('amppToken').value.trim();
if (token) body.ampp_token = token;
await api('/settings/ampp', { method: 'PUT', body: JSON.stringify(body) });
document.getElementById('amppToken').value = '';
document.getElementById('amppTokenHint').style.display = '';
flashFeedback('amppFeedback');
hideTestResult('amppTestResult');
} catch (e) { toast('Save failed: ' + e.message, 'error'); }
}
async function testAmpp() {
const el = document.getElementById('amppTestResult');
try {
const d = await api('/settings/ampp/test', { method: 'POST' });
showTestResult(el, true, d.message || 'Connection successful');
} catch (e) { showTestResult(el, false, e.message); }
}
// ── Utilities ──────────────────────────────────────────────────────────────
function flashFeedback(id) {
const el = document.getElementById(id);
el.classList.add('visible');
setTimeout(() => el.classList.remove('visible'), 2500);
}
function showTestResult(el, ok, msg) {
el.className = `test-result ${ok ? 'ok' : 'error'}`;
el.textContent = msg;
}
function hideTestResult(id) {
const el = document.getElementById(id);
el.className = 'test-result';
}
function toast(msg, type = 'info') {
const el = document.createElement('div');
el.className = `toast toast--${type}`;
el.innerHTML = `<div class="toast-body"><div class="toast-title">${esc(msg)}</div></div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
// ── Init ───────────────────────────────────────────────────────────────────
Promise.all([loadS3(), loadHardware(), loadTranscoding(), loadCaptureService(), loadAmpp()]);
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>