fix(qc-dash): v0.2.1 — hot-reconfig + cartes depuis api/latest

BUG1: GET /api/config retourne config courante (host/port/path/url/interval)
      POST /api/config update à chaud + log "cible changée"
      Page pré-remplit les inputs au chargement via loadConfig()
      Toast "Appliqué" après POST réussi

BUG2: JS reécrit pour lire UNIQUEMENT api/latest (relatif)
      Tous les champs mappés sur json tags Go minuscules:
      gps.valid/sats/lat/lon/hdg/speed_kn, rssi, power.voltage/current_mA,
      seaker.angle/dist/rx_freq, targetf.lat/lon/r95_m, deviceId, firmware.version, ip
      Sparklines alimentées depuis rows[] ring-buffer backend
      Plus aucun fetch direct vers ESP32
This commit is contained in:
2026-06-09 06:48:37 +00:00
parent 42cf29d7e3
commit dde2bc61fa

162
main.go
View File

@@ -18,7 +18,7 @@ import (
"time" "time"
) )
const Version = "0.1.0" const Version = "0.2.1"
// ─── QC Thresholds (edit here) ─────────────────────────────────────────────── // ─── QC Thresholds (edit here) ───────────────────────────────────────────────
const ( const (
@@ -36,6 +36,7 @@ type Config struct {
Host string Host string
Port int Port int
Path string Path string
URL string // full URL override (http or https), replaces host:port/path
Interval int // seconds Interval int // seconds
CSV string CSV string
Web int Web int
@@ -93,6 +94,8 @@ func loadIni(path string, cfg *Config) {
} }
case "mode": case "mode":
cfg.Mode = v cfg.Mode = v
case "url":
cfg.URL = v
} }
} }
} }
@@ -136,6 +139,8 @@ func parseFlags(cfg *Config) {
} }
case "mode": case "mode":
cfg.Mode = val cfg.Mode = val
case "url":
cfg.URL = val
} }
} }
} }
@@ -191,8 +196,8 @@ type Telemetry struct {
// Row enriched with PC timestamp // Row enriched with PC timestamp
type Row struct { type Row struct {
Timestamp string Timestamp string `json:"Timestamp"`
T Telemetry T Telemetry `json:"T"`
} }
// ─── Ring buffer ───────────────────────────────────────────────────────────── // ─── Ring buffer ─────────────────────────────────────────────────────────────
@@ -368,6 +373,7 @@ type App struct {
espHost string espHost string
espPort int espPort int
espPath string espPath string
espURL string // full URL override
} }
func (a *App) poll() { func (a *App) poll() {
@@ -376,8 +382,14 @@ func (a *App) poll() {
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
a.mu.RLock() a.mu.RLock()
url := fmt.Sprintf("http://%s:%d%s", a.espHost, a.espPort, a.espPath) var pollURL string
if a.espURL != "" {
pollURL = a.espURL
} else {
pollURL = fmt.Sprintf("http://%s:%d%s", a.espHost, a.espPort, a.espPath)
}
a.mu.RUnlock() a.mu.RUnlock()
url := pollURL
resp, err := client.Get(url) resp, err := client.Get(url)
if err != nil { if err != nil {
@@ -460,27 +472,37 @@ func (a *App) apiLatest(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) apiConfig(w http.ResponseWriter, r *http.Request) { func (a *App) apiConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
a.mu.RLock() a.mu.RLock()
type Out struct { type Out struct {
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
Path string `json:"path"` Path string `json:"path"`
URL string `json:"url"`
Interval int `json:"interval"`
} }
json.NewEncoder(w).Encode(Out{Host: a.espHost, Port: a.espPort, Path: a.espPath}) out := Out{Host: a.espHost, Port: a.espPort, Path: a.espPath, URL: a.espURL, Interval: a.cfg.Interval}
a.mu.RUnlock() a.mu.RUnlock()
json.NewEncoder(w).Encode(out)
return return
} }
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
var in struct { var in struct {
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
Path string `json:"path"` Path string `json:"path"`
URL string `json:"url"`
Interval int `json:"interval"`
} }
json.NewDecoder(r.Body).Decode(&in) json.NewDecoder(r.Body).Decode(&in)
a.mu.Lock() a.mu.Lock()
if in.Host != "" { if in.Host != "" {
a.espHost = in.Host a.espHost = in.Host
// switching to host mode: clear url override
if in.URL == "" {
a.espURL = ""
}
} }
if in.Port != 0 { if in.Port != 0 {
a.espPort = in.Port a.espPort = in.Port
@@ -488,8 +510,14 @@ func (a *App) apiConfig(w http.ResponseWriter, r *http.Request) {
if in.Path != "" { if in.Path != "" {
a.espPath = in.Path a.espPath = in.Path
} }
if in.URL != "" {
a.espURL = in.URL
}
if in.Interval > 0 {
a.cfg.Interval = in.Interval
}
a.mu.Unlock() a.mu.Unlock()
log.Printf("[CFG] ESP32 → %s:%d%s", a.espHost, a.espPort, a.espPath) log.Printf("[CFG] cible changée → %s:%d%s (url=%q)", a.espHost, a.espPort, a.espPath, a.espURL)
w.WriteHeader(200) w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`)) w.Write([]byte(`{"ok":true}`))
return return
@@ -539,6 +567,7 @@ footer{font-size:0.7rem;color:#2a4060;margin-top:8px}
<input id="cfg-port" placeholder="80" style="width:60px"/> <input id="cfg-port" placeholder="80" style="width:60px"/>
<input id="cfg-path" placeholder="/api/telemetry" style="width:140px"/> <input id="cfg-path" placeholder="/api/telemetry" style="width:140px"/>
<button onclick="saveConfig()">Appliquer</button> <button onclick="saveConfig()">Appliquer</button>
<span id="cfg-status" style="font-size:0.8rem;color:#4ade80;display:none">Appliqué</span>
</div> </div>
<div class="grid" id="cards"> <div class="grid" id="cards">
@@ -589,10 +618,11 @@ function cls(v,ok,warn){
} }
function setCard(id,text,cls_name){ function setCard(id,text,cls_name){
const el=document.getElementById(id); const el=document.getElementById(id);
if(!el)return;
el.textContent=text; el.textContent=text;
el.className='value '+cls_name; el.className='value '+cls_name;
} }
function fmt(v,d=1){return v==null?'':Number(v).toFixed(d);} function fmt(v,d=1){return(v==null||v===undefined)?'':Number(v).toFixed(d);}
function drawSpark(id,data,color,minV,maxV){ function drawSpark(id,data,color,minV,maxV){
const canvas=document.getElementById(id); const canvas=document.getElementById(id);
@@ -616,11 +646,20 @@ function drawSpark(id,data,color,minV,maxV){
ctx.stroke(); ctx.stroke();
} }
function push(arr,v){arr.push(v);if(arr.length>SPARK_N)arr.shift();} // Pré-chargement config au démarrage (BUG1 fix)
async function loadConfig(){
try{
const resp=await fetch('api/config');
const cfg=await resp.json();
if(cfg.host)document.getElementById('cfg-host').value=cfg.host;
if(cfg.port)document.getElementById('cfg-port').value=cfg.port;
if(cfg.path)document.getElementById('cfg-path').value=cfg.path;
}catch(e){}
}
async function fetchAndUpdate(){ async function fetchAndUpdate(){
try{ try{
const resp=await fetch('/api/latest?n='+SPARK_N); const resp=await fetch('api/latest?n='+SPARK_N);
const data=await resp.json(); const data=await resp.json();
const linked=data.linked; const linked=data.linked;
const age=data.age_sec; const age=data.age_sec;
@@ -637,51 +676,58 @@ async function fetchAndUpdate(){
} }
if(rows.length===0)return; if(rows.length===0)return;
// JSON sérialisé par Go : Row.Timestamp="Timestamp", Row.T="T"
// Telemetry fields utilisent les json tags (minuscules)
const r=rows[rows.length-1]; const r=rows[rows.length-1];
const t=r.T; const t=r.T||{};
// GPS // GPS — json tags: gps.valid, gps.sats, gps.lat, gps.lon, gps.hdg, gps.speed_kn, gps.status
const gv=t.GPS&&t.GPS.Valid===1; const gps=t.gps||{};
setCard('c-gps-valid',gv?'FIX':'NO FIX',gv?'ok':'err'); const gv=gps.valid===1;
const sats=t.GPS?t.GPS.Sats:0; setCard('c-gps-valid',gv?(gps.status||'FIX'):'NO FIX',gv?'ok':'err');
const sats=gps.sats!=null?gps.sats:0;
setCard('c-gps-sats',sats,sats>=THRESH_SATS_OK?'ok':sats>0?'warn':'err'); setCard('c-gps-sats',sats,sats>=THRESH_SATS_OK?'ok':sats>0?'warn':'err');
setCard('c-lat',fmt(t.GPS&&t.GPS.Lat,7),'na'); setCard('c-lat',fmt(gps.lat,7),'na');
setCard('c-lon',fmt(t.GPS&&t.GPS.Lon,7),'na'); setCard('c-lon',fmt(gps.lon,7),'na');
setCard('c-hdg',fmt(t.GPS&&t.GPS.Hdg,1),'na'); setCard('c-hdg',fmt(gps.hdg,1),'na');
const spd=t.GPS?t.GPS.SpeedKn:0; setCard('c-speed',fmt(gps.speed_kn,2),'na');
setCard('c-speed',fmt(spd,2),'na');
// RSSI // RSSI — json tag: rssi
const rssi=t.RSSI||0; const rssi=t.rssi!=null?t.rssi:0;
setCard('c-rssi',rssi,cls(rssi,THRESH_RSSI_OK,THRESH_RSSI_WARN)); setCard('c-rssi',rssi,cls(rssi,THRESH_RSSI_OK,THRESH_RSSI_WARN));
// Power // Power — json tags: power.voltage, power.current_mA
const volt=t.Power?t.Power.Voltage:0; const pwr=t.power||{};
const volt=pwr.voltage!=null?pwr.voltage:0;
setCard('c-volt',fmt(volt,2),cls(volt,THRESH_BATT_OK,THRESH_BATT_WARN)); setCard('c-volt',fmt(volt,2),cls(volt,THRESH_BATT_OK,THRESH_BATT_WARN));
setCard('c-amp',fmt(t.Power&&t.Power.CurrentMA,0),'na'); setCard('c-amp',fmt(pwr.current_mA,0),'na');
// Seaker // Seaker — json tags: seaker.angle, seaker.dist, seaker.rx_freq
setCard('c-sk-ang',fmt(t.Seaker&&t.Seaker.Angle,1),'na'); const sk=t.seaker||{};
setCard('c-sk-dist',fmt(t.Seaker&&t.Seaker.Dist,1),'na'); setCard('c-sk-ang',fmt(sk.angle,1),'na');
setCard('c-sk-freq',fmt(t.Seaker&&t.Seaker.RxFreq,1),'na'); setCard('c-sk-dist',fmt(sk.dist,1),'na');
setCard('c-sk-freq',fmt(sk.rx_freq,1),'na');
// Target / ROV // Target / ROV — json tags: targetf.lat, targetf.lon, targetf.r95_m
setCard('c-rov-lat',fmt(t.TargetF&&t.TargetF.Lat,7),'na'); const tf=t.targetf||{};
setCard('c-rov-lon',fmt(t.TargetF&&t.TargetF.Lon,7),'na'); setCard('c-rov-lat',fmt(tf.lat,7),'na');
setCard('c-rov-r95',fmt(t.TargetF&&t.TargetF.R95M,2),'na'); setCard('c-rov-lon',fmt(tf.lon,7),'na');
setCard('c-rov-r95',fmt(tf.r95_m,2),'na');
setCard('c-devid',t.DeviceID||'','na'); // json tags: deviceId, firmware.version, ip
setCard('c-fw',(t.Firmware&&t.Firmware.Version)||'','na'); setCard('c-devid',t.deviceId||'','na');
setCard('c-ip',t.IP||'','na'); const fw=t.firmware||{};
setCard('c-fw',fw.version||'','na');
setCard('c-ip',t.ip||'','na');
// Sparklines — fill from all rows // Sparklines — alimentées depuis rows (historique ring-buffer)
sparkData.volt.length=0;sparkData.rssi.length=0;sparkData.dist.length=0;sparkData.speed.length=0; sparkData.volt.length=0;sparkData.rssi.length=0;sparkData.dist.length=0;sparkData.speed.length=0;
rows.forEach(row=>{ rows.forEach(row=>{
const tt=row.T; const tt=row.T||{};
sparkData.volt.push(tt.Power?tt.Power.Voltage:0); sparkData.volt.push((tt.power||{}).voltage||0);
sparkData.rssi.push(tt.RSSI||0); sparkData.rssi.push(tt.rssi||0);
sparkData.dist.push(tt.Seaker?tt.Seaker.Dist:0); sparkData.dist.push((tt.seaker||{}).dist||0);
sparkData.speed.push(tt.GPS?tt.GPS.SpeedKn:0); sparkData.speed.push((tt.gps||{}).speed_kn||0);
}); });
drawSpark('sp-volt',sparkData.volt,'#4ade80'); drawSpark('sp-volt',sparkData.volt,'#4ade80');
@@ -696,12 +742,27 @@ async function fetchAndUpdate(){
} }
async function saveConfig(){ async function saveConfig(){
const host=document.getElementById('cfg-host').value; const host=document.getElementById('cfg-host').value.trim();
const port=parseInt(document.getElementById('cfg-port').value)||0; const port=parseInt(document.getElementById('cfg-port').value)||0;
const path=document.getElementById('cfg-path').value; const path=document.getElementById('cfg-path').value.trim();
await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({host,port,path})}); try{
const resp=await fetch('api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({host,port,path})});
const ok=await resp.json();
const st=document.getElementById('cfg-status');
if(ok.ok){
st.textContent='Appliqué';st.style.color='#4ade80';
}else{
st.textContent='Erreur';st.style.color='#f87171';
}
st.style.display='inline';
setTimeout(()=>{st.style.display='none';},3000);
}catch(e){
const st=document.getElementById('cfg-status');
st.textContent='Erreur: '+e.message;st.style.color='#f87171';st.style.display='inline';
}
} }
loadConfig();
setInterval(fetchAndUpdate,1000); setInterval(fetchAndUpdate,1000);
fetchAndUpdate(); fetchAndUpdate();
</script> </script>
@@ -734,6 +795,7 @@ func main() {
espHost: cfg.Host, espHost: cfg.Host,
espPort: cfg.Port, espPort: cfg.Port,
espPath: cfg.Path, espPath: cfg.Path,
espURL: cfg.URL,
} }
// Start poller or receiver // Start poller or receiver