2026-05-20 14:21:18 -04:00
|
|
|
<!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">
|
2026-05-21 22:35:33 -04:00
|
|
|
<title>Settings — Dragonflight</title>
|
2026-05-21 13:35:04 -04:00
|
|
|
<link rel="stylesheet" href="/dist/app.css">
|
2026-05-20 14:21:18 -04:00
|
|
|
<style>
|
2026-05-21 16:40:28 -04:00
|
|
|
body { margin: 0; }
|
2026-05-20 14:21:18 -04:00
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 15:55:34 -04:00
|
|
|
.settings-section-icon.s3 { background: oklch(20% 0.08 195 / 0.5); color: oklch(68% 0.18 195); }
|
2026-05-21 22:35:33 -04:00
|
|
|
.settings-section-icon.gpu { background: oklch(20% 0.08 32 / 0.5); color: oklch(68% 0.18 32); }
|
2026-05-20 15:55:34 -04:00
|
|
|
.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); }
|
2026-05-20 14:21:18 -04:00
|
|
|
|
|
|
|
|
.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 {
|
2026-05-21 22:35:33 -04:00
|
|
|
border-bottom: 1px solid oklch(28% 0.04 32 / 0.3);
|
2026-05-20 14:21:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 22:35:33 -04:00
|
|
|
.hw-cap-badge.gpu { background: oklch(20% 0.08 32 / 0.4); color: oklch(68% 0.18 32); border: 1px solid oklch(62% 0.22 32 / 0.3); }
|
2026-05-20 14:21:18 -04:00
|
|
|
.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;
|
|
|
|
|
}
|
2026-05-20 15:55:34 -04:00
|
|
|
|
|
|
|
|
/* Secret key row with show/hide toggle */
|
|
|
|
|
.input-with-action {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.input-with-action input {
|
|
|
|
|
flex: 1;
|
|
|
|
|
}
|
2026-05-20 14:21:18 -04:00
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
2026-05-21 16:40:28 -04:00
|
|
|
<div class="wd-shell" style="display:flex;min-height:100vh;">
|
2026-05-21 13:35:04 -04:00
|
|
|
<nav class="wd-sidebar" aria-label="Main navigation">
|
|
|
|
|
<div class="wd-sidebar-header">
|
2026-05-21 22:35:33 -04:00
|
|
|
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
|
|
|
|
|
<span class="wd-sidebar-brand">Dragonflight</span>
|
2026-05-20 14:21:18 -04:00
|
|
|
</div>
|
2026-05-21 13:35:04 -04:00
|
|
|
<div class="wd-sidebar-nav">
|
|
|
|
|
<a href="home.html" class="wd-nav-item">
|
2026-05-20 14:21:18 -04:00
|
|
|
<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>
|
2026-05-21 13:35:04 -04:00
|
|
|
<a href="index.html" class="wd-nav-item">
|
2026-05-20 14:21:18 -04:00
|
|
|
<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>
|
2026-05-21 13:35:04 -04:00
|
|
|
<a href="projects.html" class="wd-nav-item">
|
2026-05-20 14:21:18 -04:00
|
|
|
<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>
|
2026-05-21 13:35:04 -04:00
|
|
|
<a href="upload.html" class="wd-nav-item">
|
2026-05-20 14:21:18 -04:00
|
|
|
<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>
|
2026-05-21 13:35:04 -04:00
|
|
|
<a href="recorders.html" class="wd-nav-item">
|
2026-05-20 14:21:18 -04:00
|
|
|
<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>
|
2026-05-21 13:35:04 -04:00
|
|
|
<a href="capture.html" class="wd-nav-item">
|
2026-05-20 14:21:18 -04:00
|
|
|
<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>
|
2026-05-21 13:35:04 -04:00
|
|
|
<a href="jobs.html" class="wd-nav-item">
|
2026-05-20 14:21:18 -04:00
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
|
|
|
|
|
Jobs
|
|
|
|
|
</a>
|
2026-05-21 13:35:04 -04:00
|
|
|
<a href="editor.html" class="wd-nav-item">
|
2026-05-20 14:21:18 -04:00
|
|
|
<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>
|
2026-05-21 13:35:04 -04:00
|
|
|
<div class="wd-sidebar-section">Admin</div>
|
|
|
|
|
<a href="users.html" class="wd-nav-item">
|
2026-05-20 14:21:18 -04:00
|
|
|
<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>
|
2026-05-21 13:35:04 -04:00
|
|
|
<a href="tokens.html" class="wd-nav-item">
|
2026-05-20 14:21:18 -04:00
|
|
|
<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>
|
2026-05-21 13:35:04 -04:00
|
|
|
<a href="containers.html" class="wd-nav-item">
|
2026-05-20 14:21:18 -04:00
|
|
|
<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>
|
2026-05-21 13:35:04 -04:00
|
|
|
<a href="cluster.html" class="wd-nav-item">
|
2026-05-20 14:21:18 -04:00
|
|
|
<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>
|
2026-05-21 13:35:04 -04:00
|
|
|
<a href="settings.html" class="wd-nav-item is-active">
|
2026-05-20 14:21:18 -04:00
|
|
|
<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>
|
2026-05-21 13:35:04 -04:00
|
|
|
</div>
|
|
|
|
|
<div class="wd-sidebar-footer">
|
|
|
|
|
<div class="wd-sidebar-user">
|
|
|
|
|
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
|
|
|
|
|
<div class="wd-sidebar-user-info">
|
|
|
|
|
<div class="wd-sidebar-user-name" id="userName">—</div>
|
|
|
|
|
<div class="wd-sidebar-user-role" id="userRole"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><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>
|
|
|
|
|
|
2026-05-21 16:40:28 -04:00
|
|
|
<main style="flex:1;min-width:0;overflow:auto;padding:20px 24px 32px;">
|
|
|
|
|
<header style="display:flex;align-items:center;height:48px;border-bottom:1px solid var(--border-faint);margin:0 -24px 20px;padding:0 24px;">
|
|
|
|
|
<span style="font:600 14px/1 var(--font);color:var(--text-primary);letter-spacing:-0.005em;">Settings</span>
|
2026-05-20 14:21:18 -04:00
|
|
|
</header>
|
2026-05-21 16:40:28 -04:00
|
|
|
<div class="settings-layout">
|
2026-05-20 14:21:18 -04:00
|
|
|
|
2026-05-20 15:55:34 -04:00
|
|
|
<!-- ── 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 & 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>
|
|
|
|
|
|
2026-05-20 14:21:18 -04:00
|
|
|
<!-- ── 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>
|
|
|
|
|
|
2026-05-21 16:40:28 -04:00
|
|
|
</div><!-- /settings-layout -->
|
|
|
|
|
</main>
|
|
|
|
|
</div><!-- /wd-shell -->
|
2026-05-20 14:21:18 -04:00
|
|
|
|
|
|
|
|
<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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 15:55:34 -04:00
|
|
|
// ── 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';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 14:21:18 -04:00
|
|
|
// ── 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 ───────────────────────────────────────────────────────────────────
|
2026-05-20 15:55:34 -04:00
|
|
|
Promise.all([loadS3(), loadHardware(), loadTranscoding(), loadCaptureService(), loadAmpp()]);
|
2026-05-20 14:21:18 -04:00
|
|
|
</script>
|
|
|
|
|
<script src="js/auth-guard.js"></script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|