Explorar el Código

control: improve runtime visibility and stop UX

main
Jan hace 1 mes
padre
commit
9a3a37e31c
Se han modificado 5 ficheros con 50 adiciones y 9 borrados
  1. +2
    -0
      cmd/fmrtx/main.go
  2. +8
    -0
      internal/app/engine.go
  3. +4
    -5
      internal/control/control.go
  4. +30
    -4
      internal/control/ui.html
  5. +6
    -0
      internal/rds/encoder.go

+ 2
- 0
cmd/fmrtx/main.go Ver fichero

@@ -330,6 +330,8 @@ func (b *txBridge) TXStats() map[string]any {
"runtimeIndicator": s.RuntimeIndicator,
"runtimeAlert": s.RuntimeAlert,
"appliedFrequencyMHz": s.AppliedFrequencyMHz,
"activePS": s.ActivePS,
"activeRadioText": s.ActiveRadioText,
"degradedTransitions": s.DegradedTransitions,
"mutedTransitions": s.MutedTransitions,
"faultedTransitions": s.FaultedTransitions,


+ 8
- 0
internal/app/engine.go Ver fichero

@@ -88,6 +88,8 @@ type EngineStats struct {
RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"`
RuntimeAlert string `json:"runtimeAlert,omitempty"`
AppliedFrequencyMHz float64 `json:"appliedFrequencyMHz"`
ActivePS string `json:"activePS,omitempty"`
ActiveRadioText string `json:"activeRadioText,omitempty"`
LastFault *FaultEvent `json:"lastFault,omitempty"`
DegradedTransitions uint64 `json:"degradedTransitions"`
MutedTransitions uint64 `json:"mutedTransitions"`
@@ -429,6 +431,10 @@ func (e *Engine) Stats() EngineStats {
hasRecentLateBuffers := e.hasRecentLateBuffers()
ri := runtimeIndicator(queue.Health, hasRecentLateBuffers)
lastFault := e.lastFaultEvent()
activePS, activeRT := "", ""
if enc := e.generator.RDSEncoder(); enc != nil {
activePS, activeRT = enc.CurrentText()
}
return EngineStats{
State: string(e.currentRuntimeState()),
RuntimeStateDurationSeconds: e.runtimeStateDurationSeconds(),
@@ -448,6 +454,8 @@ func (e *Engine) Stats() EngineStats {
RuntimeIndicator: ri,
RuntimeAlert: runtimeAlert(queue.Health, hasRecentLateBuffers),
AppliedFrequencyMHz: e.appliedFrequencyMHz(),
ActivePS: activePS,
ActiveRadioText: activeRT,
LastFault: lastFault,
DegradedTransitions: e.degradedTransitions.Load(),
MutedTransitions: e.mutedTransitions.Load(),


+ 4
- 5
internal/control/control.go Ver fichero

@@ -474,12 +474,11 @@ func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) {
http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
return
}
if err := tx.StopTX(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
go func() {
_ = tx.StopTX()
}()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stopped"})
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stop-requested"})
}

func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) {


+ 30
- 4
internal/control/ui.html Ver fichero

@@ -821,7 +821,29 @@ async function sendPatch(patch,{ok='Applied',clearKeys=[]}={}){beginReq();try{co
async function applySection(sec){if(secErrors(sec)){toast('Fix validation errors first','warn');return;}const patch=secPatch(sec),keys=Object.keys(patch);if(!keys.length){toast('No changes','info');return;}const msg=sec==='freq'?'Frequency updated':sec==='rds'?'RDS text updated':'Applied';await sendPatch(patch,{ok:msg,clearKeys:keys});}
function resetSection(sec){clearDirty(Object.keys(FIELDS).filter(k=>FIELDS[k].section===sec));toast('Draft reset','info');}
async function applyCfgSection(sec){if(sec==='rds-id'&&S.cfgErrors?.pi){toast('Fix validation errors first','warn');return;}const patch=cfgPatch(sec);if(!Object.keys(patch).length){toast('No changes','info');return;}const hasR=cfgHasRestart(sec);beginReq();try{const res=await api('/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)});Object.keys(CFG).filter(k=>CFG[k].sec===sec).forEach(k=>delete S.cfgDraft[k]);S.cfgDirty[sec]=false;if(sec==='rds-id'&&S.cfgErrors)delete S.cfgErrors.pi;toast(hasR?'Saved (restart required)':'Applied live','ok');log('CFG '+sec+' '+JSON.stringify(patch)+(hasR?' [restart]':' [live]'),'ok');await Promise.allSettled([loadConfig({silent:true}),loadRuntime({silent:true})]);return res;}catch(e){toast(e.message,'err');log('CFG failed: '+e.message,'err');throw e;}finally{endReq();}}
async function txAction(action){if(S.txBusy)return;S.txBusy=true;render();beginReq();try{await api(`/tx/${action}`,{method:'POST'});toast(action==='start'?'TX started':'TX stopped','ok');log('TX '+action,'ok');await Promise.allSettled([loadRuntime({silent:true}),loadConfig({silent:true})]);}catch(e){toast(e.message,'err');log('TX '+action+' failed: '+e.message,'err');}finally{S.txBusy=false;endReq();render();}}
async function txAction(action){
if(S.txBusy)return;
S.txBusy=true;
if(action==='stop'&&S.server.runtime){
S.server.runtime.engine={...(S.server.runtime.engine||{}),state:'stopping'};
}
render();
beginReq();
try{
log('TX '+action+' requested','info');
await api(`/tx/${action}`,{method:'POST'});
toast(action==='start'?'TX started':'TX stop requested','ok');
log('TX '+action+' accepted','ok');
await Promise.allSettled([loadRuntime({silent:true}),loadConfig({silent:true})]);
}catch(e){
toast(e.message,'err');
log('TX '+action+' failed: '+e.message,'err');
}finally{
S.txBusy=false;
endReq();
render();
}
}
async function resetFault(){if(S.faultBusy)return;S.faultBusy=true;render();beginReq();try{await api('/runtime/fault/reset',{method:'POST'});toast('Fault reset','ok');log('Fault reset','ok');await loadRuntime({silent:true});}catch(e){toast(e.message,'err');log('Fault reset failed: '+e.message,'err');}finally{S.faultBusy=false;endReq();render();}}
async function setToggle(key,val){if(S.toggleBusy[key])return;S.toggleBusy[key]=true;render();try{await sendPatch({[key]:val},{ok:key.replace(/Enabled$/,'')+' '+(val?'enabled':'disabled')});}finally{S.toggleBusy[key]=false;render();}}
async function saveIngest(){if(S.ingestSaving)return;if(!S.ingestDirty){toast('No changes','info');return;}S.ingestSaving=true;S.ingestError='';beginReq();render();try{const res=await api('/config/ingest/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ingest:S.ingestDraft})});S.ingestDirty=false;toast(res.reloadScheduled?'Saved, reloading…':'Saved','ok');log('Ingest saved'+(res.reloadScheduled?' [reload]':''),'ok');if(res.reloadScheduled)setTimeout(()=>location.reload(),1500);}catch(e){S.ingestError=e.message;toast(e.message,'err');log('Ingest save failed: '+e.message,'err');}finally{S.ingestSaving=false;endReq();render();}}
@@ -856,7 +878,9 @@ function _render(){
$('tx-state').textContent=S.txBusy?'WORKING':txSt.toUpperCase();
$('tx-state').className='tx-state '+(S.txBusy?'working':txSt);
setText('tx-hint',eng.lastError?`Last error: ${eng.lastError}`:S.txBusy?'Command in progress':'Runtime polled every 1s');
const startDis=S.txBusy||txSt==='running',stopDis=S.txBusy||['idle','stopped',''].includes(txSt);
const canStopStates=['running','arming','prebuffering','degraded','muted','faulted','stopping'];
const startDis=S.txBusy||txSt==='running';
const stopDis=S.txBusy||!canStopStates.includes(txSt);
$('btn-start').disabled=startDis;$('btn-stop').disabled=stopDis;$('btn-refresh').disabled=S.pending>0;

// ── Overview: meters + sparklines
@@ -908,7 +932,7 @@ function _render(){
setText('compliance-meta',S.cfgDirty['compliance']?'Draft pending':'Saved + Restart Required');
$('compliance-apply').disabled=!S.cfgDirty['compliance'];$('compliance-reset').disabled=!S.cfgDirty['compliance'];
// Danger
$('danger-stop').disabled=stopDis;const rfl=$('danger-reset-fault');if(rfl){rfl.disabled=S.faultBusy||!S.server.runtimeOk;rfl.textContent=S.faultBusy?'Resetting...':'Reset Fault';}
$('danger-stop').disabled=S.txBusy;const rfl=$('danger-reset-fault');if(rfl){rfl.disabled=S.faultBusy||!S.server.runtimeOk;rfl.textContent=S.faultBusy?'Resetting...':'Reset Fault';}
const rh=$('reset-hint');if(rh){const sn=normState(eng.state);rh.textContent=sn==='faulted'?'Faulted: reset moves runtime back to DEGRADED.':sn==='muted'||sn==='degraded'?'Reset Fault holds at DEGRADED until queue recovers.':'Manual fault reset drops to DEGRADED while queue recovers.';}

// ── RDS tab
@@ -934,8 +958,10 @@ function _render(){
syncSlider('rdsinj-slider','rdsinj-val','rdsInjection',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%');
const lvlDirty=!!S.cfgDirty['rds-lvl'];$('rds-levels-apply').disabled=!lvlDirty;$('rds-levels-reset').disabled=!lvlDirty;
// Status card
const activePS=String(eng.activePS||cfg.rds?.ps||'').trim();
const activeRT=String(eng.activeRadioText||cfg.rds?.radioText||'').trim();
setText('rds-stat-enabled',cfg.rds?.enabled?'ON':'OFF');setText('rds-stat-pi',fmtPI(cfg.rds?.pi));
setText('rds-stat-pty',fmtPTY(cfg.rds?.pty));setText('rds-stat-ps',cfg.rds?.ps||'--');setText('rds-stat-rt',cfg.rds?.radioText||'--');
setText('rds-stat-pty',fmtPTY(cfg.rds?.pty));setText('rds-stat-ps',activePS||'--');setText('rds-stat-rt',activeRT||'--');
setText('rds-stat-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('rds-stat-inj',fmtPilot(cfg.fm?.rdsInjection));

// ── Ingest tab


+ 6
- 0
internal/rds/encoder.go Ver fichero

@@ -195,6 +195,12 @@ func (e *Encoder) ClearRT() {
e.liveRT.Store(pendingText{val: "", set: true})
}

// CurrentText returns the currently active PS and RT from the encoder scheduler.
// It reflects the last text applied at an RDS group boundary.
func (e *Encoder) CurrentText() (ps, rt string) {
return e.scheduler.cfg.PS, e.scheduler.cfg.RT
}

// NextSample returns the next RDS subcarrier sample at the configured rate.
// Uses the internal free-running 57 kHz carrier. Prefer NextSampleWithCarrier
// for phase-locked operation in a stereo MPX chain.


Cargando…
Cancelar
Guardar