| @@ -774,6 +774,15 @@ input.input-error { | |||
| text-transform: uppercase; | |||
| letter-spacing: .08em; | |||
| } | |||
| .ingest-summary-card .sidebar-section { | |||
| margin: 0; | |||
| padding: 0; | |||
| border: none; | |||
| } | |||
| .ingest-summary-kv .v { | |||
| font-family: var(--mono); | |||
| font-size: 11px; | |||
| } | |||
| .apply-btn { | |||
| background: var(--accent); | |||
| border-color: transparent; | |||
| @@ -1046,6 +1055,7 @@ input.input-error { | |||
| <div class="tab-bar"> | |||
| <button class="tab-btn active" data-tab="overview" type="button">Overview</button> | |||
| <button class="tab-btn" data-tab="control" type="button">Transmission Control</button> | |||
| <button class="tab-btn" data-tab="ingest" type="button">Ingest</button> | |||
| <button class="tab-btn" data-tab="diagnostics" type="button">Diagnostics & Health</button> | |||
| <button class="tab-btn" data-tab="activity" type="button">Activity & Logs</button> | |||
| </div> | |||
| @@ -1282,6 +1292,76 @@ input.input-error { | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="stack"> | |||
| <div class="card panel" data-panel-key="shortcuts"> | |||
| <div class="panel-head" data-panel> | |||
| <h2>Shortcuts</h2> | |||
| <div class="meta">keyboard</div> | |||
| <span class="chevron">▼</span> | |||
| </div> | |||
| <div class="panel-body"> | |||
| <div class="section-note">Fast control reference. Shortcuts stay out of the main operator path.</div> | |||
| <div class="shortcuts-grid"> | |||
| <div> | |||
| <div class="shortcut-line"><span class="name">Start TX</span><span class="keys"><span class="kbd">t</span></span></div> | |||
| <div class="shortcut-line"><span class="name">Stop TX</span><span class="keys"><span class="kbd">Shift</span><span class="kbd">t</span></span></div> | |||
| <div class="shortcut-line"><span class="name">Refresh</span><span class="keys"><span class="kbd">r</span></span></div> | |||
| </div> | |||
| <div> | |||
| <div class="shortcut-line"><span class="name">Next Freq Preset</span><span class="keys"><span class="kbd">]</span></span></div> | |||
| <div class="shortcut-line"><span class="name">Prev Freq Preset</span><span class="keys"><span class="kbd">[</span></span></div> | |||
| <div class="shortcut-line"><span class="name">Apply Draft</span><span class="keys"><span class="kbd">Enter</span></span></div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="card panel" data-panel-key="danger"> | |||
| <div class="panel-head" data-panel> | |||
| <h2>Danger Zone</h2> | |||
| <div class="meta">tx control</div> | |||
| <span class="chevron">▼</span> | |||
| </div> | |||
| <div class="panel-body"> | |||
| <div class="section-note">Fast emergency controls. Nothing hidden here — just clearer separation from normal controls.</div> | |||
| <div class="actions-row" style="margin-top:0"> | |||
| <button class="danger-btn" id="danger-stop" type="button">Emergency Stop TX</button> | |||
| <button class="danger-btn" id="danger-refresh" type="button">Hard Refresh Runtime</button> | |||
| <button class="danger-btn secondary" id="danger-reset-fault" type="button">Reset Fault</button> | |||
| </div> | |||
| <div class="section-note reset-hint" id="reset-hint"> | |||
| Reset Fault moves the runtime back to DEGRADED while the queue settles before running again. | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </section> | |||
| <section class="tab-panel" data-tab-panel="ingest"> | |||
| <div class="tab-columns one"> | |||
| <div class="stack"> | |||
| <div class="card sidebar-card ingest-summary-card"> | |||
| <div class="sidebar-section"> | |||
| <div class="sidebar-title">Active Ingest Summary</div> | |||
| <div class="section-note">Runtime snapshot of active ingest state and source. Deep runtime metrics stay in Diagnostics.</div> | |||
| <div class="kv ingest-summary-kv"> | |||
| <div class="k">State</div><div class="v" id="ingest-summary-state">--</div> | |||
| <div class="k">Source</div><div class="v" id="ingest-summary-source">--</div> | |||
| <div class="k">Signal</div><div class="v" id="ingest-summary-signal">--</div> | |||
| <div class="k">Detail</div><div class="v" id="ingest-summary-detail">--</div> | |||
| <div class="k">Origin</div><div class="v" id="ingest-summary-origin">--</div> | |||
| <div class="k">Last Chunk</div><div class="v" id="ingest-summary-last">--</div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="card panel" data-panel-key="ingest"> | |||
| <div class="panel-head" data-panel> | |||
| <div class="led on-blue" style="width:6px;height:6px"></div> | |||
| @@ -1506,52 +1586,6 @@ input.input-error { | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="stack"> | |||
| <div class="card panel" data-panel-key="shortcuts"> | |||
| <div class="panel-head" data-panel> | |||
| <h2>Shortcuts</h2> | |||
| <div class="meta">keyboard</div> | |||
| <span class="chevron">▼</span> | |||
| </div> | |||
| <div class="panel-body"> | |||
| <div class="section-note">Fast control reference. Shortcuts stay out of the main operator path.</div> | |||
| <div class="shortcuts-grid"> | |||
| <div> | |||
| <div class="shortcut-line"><span class="name">Start TX</span><span class="keys"><span class="kbd">t</span></span></div> | |||
| <div class="shortcut-line"><span class="name">Stop TX</span><span class="keys"><span class="kbd">Shift</span><span class="kbd">t</span></span></div> | |||
| <div class="shortcut-line"><span class="name">Refresh</span><span class="keys"><span class="kbd">r</span></span></div> | |||
| </div> | |||
| <div> | |||
| <div class="shortcut-line"><span class="name">Next Freq Preset</span><span class="keys"><span class="kbd">]</span></span></div> | |||
| <div class="shortcut-line"><span class="name">Prev Freq Preset</span><span class="keys"><span class="kbd">[</span></span></div> | |||
| <div class="shortcut-line"><span class="name">Apply Draft</span><span class="keys"><span class="kbd">Enter</span></span></div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="card panel" data-panel-key="danger"> | |||
| <div class="panel-head" data-panel> | |||
| <h2>Danger Zone</h2> | |||
| <div class="meta">tx control</div> | |||
| <span class="chevron">▼</span> | |||
| </div> | |||
| <div class="panel-body"> | |||
| <div class="section-note">Fast emergency controls. Nothing hidden here — just clearer separation from normal controls.</div> | |||
| <div class="actions-row" style="margin-top:0"> | |||
| <button class="danger-btn" id="danger-stop" type="button">Emergency Stop TX</button> | |||
| <button class="danger-btn" id="danger-refresh" type="button">Hard Refresh Runtime</button> | |||
| <button class="danger-btn secondary" id="danger-reset-fault" type="button">Reset Fault</button> | |||
| </div> | |||
| <div class="section-note reset-hint" id="reset-hint"> | |||
| Reset Fault moves the runtime back to DEGRADED while the queue settles before running again. | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -2271,6 +2305,18 @@ function ageString(ts) { | |||
| return h + 'h ago'; | |||
| } | |||
| function ageFromTimestamp(value) { | |||
| if (!value) return '--'; | |||
| if (typeof value === 'number') return ageString(value); | |||
| const ts = Date.parse(String(value)); | |||
| if (Number.isNaN(ts)) return '--'; | |||
| return ageString(ts); | |||
| } | |||
| function joinSummaryParts(parts) { | |||
| return parts.filter((part) => String(part || '').trim() !== '').join(' · '); | |||
| } | |||
| function updateText(id, text) { | |||
| const el = $(id); | |||
| if (el && el.textContent !== String(text)) el.textContent = text; | |||
| @@ -2409,6 +2455,11 @@ function render() { | |||
| const engine = runtime.engine || {}; | |||
| const driver = runtime.driver || {}; | |||
| const audioStream = runtime.audioStream || null; | |||
| const hasIngestRuntime = !!runtime.ingest; | |||
| const ingest = runtime.ingest || {}; | |||
| const ingestActive = ingest.active || {}; | |||
| const ingestSource = ingest.source || {}; | |||
| const ingestRuntime = ingest.runtime || {}; | |||
| const appliedRaw = engine.appliedFrequencyMHz; | |||
| const appliedFreq = Number.isFinite(Number(appliedRaw)) ? Number(appliedRaw) : null; | |||
| @@ -2533,6 +2584,45 @@ function render() { | |||
| updateText('rds-meta', sectionHasErrors('rds') ? 'Validation error' : (rdsDirty ? `${Object.keys(getSectionPatch('rds')).length} unsaved` : 'PS + RT')); | |||
| updateText('ingest-meta', state.ingestSaving ? 'Saving' : (state.ingestDirty ? 'Unsaved changes' : 'Saved config')); | |||
| const configuredKind = String(cfg.ingest?.kind || ingestFieldValue('kind') || 'none').toLowerCase(); | |||
| const activeKind = String(ingestActive.kind || configuredKind || 'none').toLowerCase(); | |||
| const runtimeStateLabel = String(ingestRuntime.state || '').toLowerCase(); | |||
| const sourceStateLabel = String(ingestSource.state || '').toLowerCase(); | |||
| const stateSummary = hasIngestRuntime ? (joinSummaryParts([ | |||
| runtimeStateLabel ? runtimeStateLabel.toUpperCase() : '', | |||
| sourceStateLabel ? `source ${sourceStateLabel.toUpperCase()}` : '', | |||
| ingestRuntime.prebuffering ? 'PREBUFFERING' : '', | |||
| ingestRuntime.writeBlocked ? 'WRITE-BLOCKED' : '', | |||
| ]) || '--') : '--'; | |||
| const sourceSummary = joinSummaryParts([ | |||
| activeKind || 'none', | |||
| ingestActive.transport || '', | |||
| ingestActive.codec || '', | |||
| Number.isFinite(Number(ingestActive.sampleRateHz)) ? `${Number(ingestActive.sampleRateHz)} Hz` : '', | |||
| Number.isFinite(Number(ingestActive.channels)) ? `${Number(ingestActive.channels)} ch` : '', | |||
| ]) || '--'; | |||
| const signalSummary = hasIngestRuntime ? (joinSummaryParts([ | |||
| ingestSource.connected ? 'connected' : 'disconnected', | |||
| Number.isFinite(Number(ingestSource.bufferedSeconds)) ? `${Number(ingestSource.bufferedSeconds).toFixed(2)}s buffered` : '', | |||
| Number.isFinite(Number(ingestSource.reconnects)) ? `${Number(ingestSource.reconnects)} reconnects` : '', | |||
| ]) || '--') : '--'; | |||
| const detailSummary = hasIngestRuntime ? (ingestSource.streamTitle || ingestActive.detail || ingestSource.lastError || '--') : '--'; | |||
| const origin = ingestActive.origin || {}; | |||
| const originSummary = hasIngestRuntime ? (joinSummaryParts([ | |||
| origin.kind || '', | |||
| origin.endpoint || '', | |||
| origin.streamName || '', | |||
| origin.mode || '', | |||
| origin.sdpPath || '', | |||
| ]) || '--') : '--'; | |||
| const lastChunkSummary = hasIngestRuntime ? ageFromTimestamp(ingestRuntime.lastChunkAt || ingestSource.lastChunkAt) : '--'; | |||
| updateText('ingest-summary-state', stateSummary); | |||
| updateText('ingest-summary-source', sourceSummary); | |||
| updateText('ingest-summary-signal', signalSummary); | |||
| updateText('ingest-summary-detail', detailSummary); | |||
| updateText('ingest-summary-origin', originSummary); | |||
| updateText('ingest-summary-last', lastChunkSummary); | |||
| updateText('info-backend', cfg.backend?.kind || cfg.backend || '--'); | |||
| updateText('info-freq', fmtFreq(cfg.fm?.frequencyMHz)); | |||
| updateText('info-preemph', cfg.fm?.preEmphasisTauUS ? `${cfg.fm.preEmphasisTauUS} µs` : 'Off'); | |||
| @@ -3147,3 +3237,4 @@ init(); | |||
| </script> | |||
| </body> | |||
| </html> | |||