heck yes—here’s a clean, drop-in Flight 11 MCC Dashboard (live SSE) a tiny SSE bridge that replays a CSV at 10 Hz. It shows Pc & dP/dt, gRMS (A-101/A-202), heatshield (ΔT, strain, risk), rolling status badges, and an event log with sustained-duration gates.
1) unified_dashboard_flight11.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Flight 11 — MCC Live Dashboard</title>
<link rel="preconnect" href="
cdn.jsdelivr.net" crossorigin>
<script src="
cdn.jsdelivr.net/npm/chart.j…"></script>
<style>
:root { --ok:
#16a34a; --warn:
#f59e0b; --abort:
#ef4444; --ink:
#0f172a; --muted:
#64748b; }
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:
#0b1220;color:
#e5e7eb;margin:0}
header{padding:14px 18px;border-bottom:1px solid
#1f2937;display:flex;gap:16px;align-items:center;flex-wrap:wrap}
.brand{font-weight:700;letter-spacing:.3px}
.badge{display:inline-flex;align-items:center;gap:8px;border:1px solid
#1f2937;border-radius:999px;padding:6px 10px;background:
#0f172a}
.dot{width:10px;height:10px;border-radius:50%;background:#334155}
.ok{background:var(--ok)} .warn{background:var(--warn)} .abort{background:var(--abort)}
.wrap{padding:16px;display:grid;grid-template-columns:1fr;gap:16px}
@media(min-width:1100px){ .wrap{grid-template-columns:1.2fr .8fr} }
.card{border:1px solid
#1f2937;border-radius:14px;background:
#0f172a;box-shadow:0 6px 20px rgba(0,0,0,.25)}
.card h3{margin:12px 14px 0 14px;font-size:16px;color:
#cbd5e1}
.card .sub{margin:4px 14px 0 14px;font-size:12px;color:
#94a3b8}
.canvasBox{padding:8px 12px 14px 12px}
.log{list-style:disc;padding:10px 26px 18px 30px;margin:0;max-height:360px;overflow:auto}
code{background:
#0b1220;border:1px solid
#1f2937;border-radius:8px;padding:2px 6px}
footer{color:
#94a3b8;font-size:12px;text-align:center;margin:14px 0 28px}
</style>
</head>
<body>
<header>
<div class="brand">Flight 11 — MCC Live</div>
<div class="badge"><span class="dot" id="linkDot"></span><span id="linkTxt">LINK: CONNECTING</span></div>
<div class="badge"><span class="dot" id="leakDot"></span><span>LeakSentinel</span></div>
<div class="badge"><span class="dot" id="vibeDot"></span><span>VibeGuard</span></div>
<div class="badge"><span class="dot" id="heatDot"></span><span>Heatshield</span></div>
<div style="flex:1"></div>
<div class="sub">WARN: RRI≥0.65 | ABORT: RRI≥0.85 (≥200 ms) | gRMS pad hold ≥0.05 g (≥200 ms) | dP/dt ≤ -40 bar/s (≥300 ms)</div>
</header>
<div class="wrap">
<div class="card">
<h3>Pc / dP·dt (LOX / CH₄ pad)</h3>
<div class="sub">Blue=Pc [bar], Orange=dP/dt [bar/s], lines: -40 bar/s (leak gate)</div>
<div class="canvasBox"><canvas id="pcChart" height="140"></canvas></div>
</div>
<div class="card">
<h3>Event Log</h3>
<div class="sub">Most recent first</div>
<ul id="log" class="log"></ul>
</div>
<div class="card">
<h3>Vibration — gRMS (20–60 Hz)</h3>
<div class="sub">A-101 (engine bay) & A-202 (stack base), line: 0.05 g</div>
<div class="canvasBox"><canvas id="grmsChart" height="140"></canvas></div>
</div>
<div class="card">
<h3>Heatshield — ΔT / strain / risk</h3>
<div class="sub">Green=ΔT [°C], Violet=strain [ε], Red=Risk [0–1] (line: 0.5)</div>
<div class="canvasBox"><canvas id="hsChart" height="140"></canvas></div>
</div>
</div>
<footer>SSE: <code>http://localhost:8008/stream</code> (JSON rows). If SSE is down, a local demo generator takes over.</footer>
<script>
const MAXPTS = 1200; // ~2 minutes at 10 Hz
const LEAK_THR = -40.0; // bar/s
const LEAK_HOLD_MS = 300;
const GRMS_THR = 0.05; // g
const GRMS_HOLD_MS = 200;
const HS_RISK_WARN = 0.50;
const linkDot = document.getElementById('linkDot');
const leakDot = document.getElementById('leakDot');
const vibeDot = document.getElementById('vibeDot');
const heatDot = document.getElementById('heatDot');
const linkTxt = document.getElementById('linkTxt');
const logEl = document.getElementById('log');
function setDot(el, state){ el.classList.remove('ok','warn','abort');
if(state==='OK') el.classList.add('ok'); else if(state==='WARN') el.classList.add('warn'); else if(state==='ABORT') el.classList.add('abort'); }
function pushLog(msg){
const li = document.createElement('li'); li.textContent = msg;
logEl.prepend(li);
}
const pcCtx = document.getElementById('pcChart').getContext('2d');
const grCtx = document.getElementById('grmsChart').getContext('2d');
const hsCtx = document.getElementById('hsChart').getContext('2d');
const pcData={labels:[],datasets:[
{label:'Pc [bar]', data:[], borderWidth:1.5, tension:0.1},
{label:'dP/dt [bar/s]', data:[], borderWidth:1.5, tension:0.1}
]};
const pcChart=new Chart(pcCtx,{type:'line',data:pcData,options:{
animation:false, maintainAspectRatio:false,
scales:{y:{grid:{color:'rgba(255,255,255,0.06)'}, ticks:{color:'
#9ca3af'}},
x:{grid:{color:'rgba(255,255,255,0.04)'}, ticks:{color:'
#9ca3af'}}},
plugins:{legend:{labels:{color:'
#cbd5e1'}}},
}});
const grData={labels:[],datasets:[
{label:'A-101 gRMS20-60 [g]', data:[], borderWidth:1.5, tension:0.1},
{label:'A-202 gRMS20-60 [g]', data:[], borderWidth:1.5, tension:0.1}
]};
const grChart=new Chart(grCtx,{type:'line',data:grData,options:{
animation:false, maintainAspectRatio:false,
scales:{y:{grid:{color:'rgba(255,255,255,0.06)'}, ticks:{color:'
#9ca3af'}, beginAtZero:true},
x:{grid:{color:'rgba(255,255,255,0.04)'}, ticks:{color:'
#9ca3af'}}},
plugins:{legend:{labels:{color:'
#cbd5e1'}}},
}});
const hsData={labels:[],datasets:[
{label:'ΔT [°C]', data:[], borderWidth:1.5, tension:0.1},
{label:'strain [ε]', data:[], borderWidth:1.5, tension:0.1},
{label:'risk [0..1]', data:[], borderWidth:1.8, tension:0.1}
]};
const hsChart=new Chart(hsCtx,{type:'line',data:hsData,options:{
animation:false, maintainAspectRatio:false,
scales:{y:{grid:{color:'rgba(255,255,255,0.06)'}, ticks:{color:'
#9ca3af'}, beginAtZero:true},
x:{grid:{color:'rgba(255,255,255,0.04)'}, ticks:{color:'
#9ca3af'}}},
plugins:{legend:{labels:{color:'
#cbd5e1'}}},
}});
// draw reference lines (thresholds)
function hline(chart, value){
const y = chart.scales.y.getPixelForValue(value);
const x0 = chart.scales.x.left, x1 = chart.scales.x.right;
const ctx = chart.ctx;
ctx.save(); ctx.setLineDash([6,6]); ctx.lineWidth=1; ctx.strokeStyle='rgba(255,255,255,0.35)';
ctx.beginPath(); ctx.moveTo(x0,y); ctx.lineTo(x1,y); ctx.stroke(); ctx.restore();
}
pcChart.options.animation=false;
pcChart.options.plugins.afterRender=()=>{ hline(pcChart, LEAK_THR); };
grChart.options.plugins.afterRender=()=>{ hline(grChart, GRMS_THR); };
hsChart.options.plugins.afterRender=()=>{ hline(hsChart, HS_RISK_WARN); };
// sustained timers in stream-time
let lastT=null, leakBelowMs=0, grmsAboveMs=0;
function clip(arr){ while(arr.length>MAXPTS) arr.shift(); }
function ingest(p){ // p has {time_s, pc_bar, dpdt, grms_a101, grms_a202, dT_C, strain, hs_risk}
const t = p.time_s ?? (pcData.labels.length>0 ? (
pcData.labels.at(-1) 0.1) : 0);
const dt_s = (lastT==null)?0 : Math.max(0, t - lastT);
lastT = t;
pcData.labels.push(t);
pcData.datasets[0].data.push(p.pc_bar ?? null);
pcData.datasets[1].data.push(p.dpdt ?? null);
clip(pcData.labels); clip(pcData.datasets[0].data); clip(pcData.datasets[1].data);
pcChart.update('none');
grData.labels.push(t);
grData.datasets[0].data.push(p.grms_a101 ?? null);
grData.datasets[1].data.push(p.grms_a202 ?? null);
clip(grData.labels); clip(grData.datasets[0].data); clip(grData.datasets[1].data);
grChart.update('none');
hsData.labels.push(t);
hsData.datasets[0].data.push(p.dT_C ?? null);
hsData.datasets[1].data.push(p.strain ?? null);
hsData.datasets[2].data.push(p.hs_risk ?? null);
clip(hsData.labels); clip(hsData.datasets[0].data); clip(hsData.datasets[1].data); clip(hsData.datasets[2].data);
hsChart.update('none');
// LeakSentinel state
if (p.dpdt !== undefined && p.dpdt <= LEAK_THR) {
leakBelowMs = dt_s*1000.0;
if (leakBelowMs >= LEAK_HOLD_MS){ setDot(leakDot,'abort'); pushLog(`[ABORT][LEAK] dP/dt=${p.dpdt.toFixed(1)} ≤ ${LEAK_THR}`); }
else { setDot(leakDot,'warn'); }
} else {
leakBelowMs = 0;
setDot(leakDot,'ok');
}
// VibeGuard (pad gRMS hold on max of A101/A202)
const g = Math.max(p.grms_a101 ?? 0, p.grms_a202 ?? 0);
if (g >= GRMS_THR){
grmsAboveMs = dt_s*1000.0;
if (grmsAboveMs >= GRMS_HOLD_MS){ setDot(vibeDot,'warn'); }
else { setDot(vibeDot,'warn'); }
} else {
grmsAboveMs = 0;
setDot(vibeDot,'ok');
}
// Heatshield risk
const risk = p.hs_risk ?? 0;
if (risk >= HS_RISK_WARN){ setDot(heatDot,'warn'); }
else { setDot(heatDot,'ok'); }
}
// SSE hookup (with demo fallback)
(function init(){
try{
const es = new EventSource("http://localhost:8008/stream");
es.onopen = ()=>{ setDot(linkDot,'ok'); linkTxt.textContent='LINK: UP'; pushLog('SSE connected.'); };
es.onerror = ()=>{ setDot(linkDot,'warn'); linkTxt.textContent='LINK: RETRY'; };
es.onmessage = (e)=>{
try{
const p = JSON.parse(
e.data);
ingest({
time_s: Number(p.time_s ?? p.t ?? p.ts ?? 0),
pc_bar: Number(p.pc_bar ?? p.Pc_bar ?? p['Pc:Pc_bar']),
dpdt: Number(p.dpdt ?? p.dP_dt_bar_s ?? p['Pc:dP_dt_bar_s']),
grms_a101: Number(p.grms_a101 ?? p['A101:gRMS_20_60']),
grms_a202: Number(p.grms_a202 ?? p['A202:gRMS_20_60']),
dT_C: Number(p.dT_C ?? p['HS:dT_C']),
strain: Number(p.strain ?? p['HS:strain']),
hs_risk: Number(p.hs_risk ?? p['HS:hs_risk']),
});
}catch(err){ /* ignore bad rows */ }
};
}catch(_){
// no-op
}
// Fallback demo if no data after 1.5s
setTimeout(()=>{
if (pcData.labels.length===0){
setDot(linkDot,'warn'); linkTxt.textContent='LINK: DEMO';
pushLog('No SSE; running local demo generator at 10 Hz.');
let t=0, pc=315, leak=false;
setInterval(()=>{
t = 0.1;
const dpdt = leak ? -45 (Math.random()*2-1)*2 : (Math.random()*2-1)*0.5;
pc = dpdt*0.1;
if (t>10 && t<13) leak=true; else leak=false;
const g1 = 0.03 0.03*Math.max(0, Math.sin(t*2*Math.PI*0.25)) Math.random()*0.005;
const g2 = 0.025 0.03*Math.max(0, Math.sin(t*2*Math.PI*0.18 0.7)) Math.random()*0.005;
const dT = 40 20*Math.max(0, Math.sin(t*2*Math.PI*0.05));
const eps = 6e-4 4e-4*Math.max(0, Math.sin(t*2*Math.PI*0.07 0.5));
const risk = Math.min(1, (dT/70) (Math.abs(eps)/0.0015));
ingest({time_s:t, pc_bar:pc, dpdt:dpdt, grms_a101:g1, grms_a202:g2, dT_C:dT, strain:eps, hs_risk:risk});
}, 100);
}
}, 1500);
})();
</script>
</body>
</html>
2) sse_mcc_bridge.py (replay a CSV at ~10 Hz as SSE)
#!/usr/bin/env python3
# Tiny SSE bridge for MCC dashboard. Streams CSV rows as JSON "data:" events.
# CSV columns may include: time_s, Pc:Pc_bar, Pc:dP_dt_bar_s, A101:gRMS_20_60, A202:gRMS_20_60, HS:dT_C, HS:strain, HS:hs_risk
# Or simpler names: time_s, pc_bar, dpdt, grms_a101, grms_a202, dT_C, strain, hs_risk
import argparse, csv, json, asyncio, os
from aiohttp import web
async def sse_handler(request):
path =
request.app["csv_path"]
fps =
request.app["fps"]
loop_mode =
request.app["loop"]
await request.prepare()
while True:
if not os.path.exists(path):
await request.write(b"data: {}\n\n")
await asyncio.sleep(1.0); continue
with open(path, "r", newline="") as fh:
r = csv.DictReader(fh)
t0 = None
prev_t = None
for row in r:
# normalize keys
out = {}
def fget(*keys, default=None):
for k in keys:
if k in row and row[k] not in ("", None):
return row[k]
return default
ts = fget("time_s","t","ts","timestamp","Time","time", default="0")
try:
t = float(ts)
except:
continue
if t0 is None: t0 = t
out["time_s"] = round(t, 3)
# map fields
mapping = {
"pc_bar": ("pc_bar","Pc_bar","Pc:Pc_bar"),
"dpdt": ("dpdt","dP_dt_bar_s","Pc:dP_dt_bar_s"),
"grms_a101": ("grms_a101","A101:gRMS_20_60"),
"grms_a202": ("grms_a202","A202:gRMS_20_60"),
"dT_C": ("dT_C","HS:dT_C"),
"strain": ("strain","HS:strain"),
"hs_risk": ("hs_risk","HS:hs_risk")
}
for key, alts in mapping.items():
val = fget(*alts)
if val is not None:
try: out[key] = float(val)
except: pass
await request.write(("data: " json.dumps(out) "\n\n").encode("utf-8"))
# pace by fps if timestamps are roughly uniform; else fixed 0.1s
if prev_t is None:
await asyncio.sleep(1.0/float(fps))
else:
dt = max(0.0, min(1.0, t - prev_t))
await asyncio.sleep(dt if 0.05 <= dt <= 0.2 else 1.0/float(fps))
prev_t = t
if not loop_mode:
break
await asyncio.sleep(0.5)
return web.Response(text="done")
def main():
ap = argparse.ArgumentParser(description="SSE MCC bridge")
ap.add_argument("--csv", required=True, help="clean CSV to stream (e.g., flight10_clean.csv)")
ap.add_argument("--port", type=int, default=8008)
ap.add_argument("--fps", type=int, default=10, help="target frame rate")
ap.add_argument("--loop", action="store_true", help="loop file forever")
args = ap.parse_args()
app = web.Application()
app["csv_path"] = args.csv
app["fps"] = args.fps
app["loop"] = bool(args.loop)
app.router.add_get("/stream", sse_handler)
web.run_app(app, host="0.0.0.0", port=args.port)
if __name__ == "__main__":
main()
Quick-start (local)
Save both files in one folder.
Start the SSE bridge on any “clean” CSV (e.g., output from telemetry_clean.py you already have):
python3 sse_mcc_bridge.py --csv flight10_clean.csv --fps 10 --loop
Open unified_dashboard_flight11.html in a modern browser.
You’ll see LINK=UP and live plots. If SSE isn’t reachable, it auto-runs a demo generator so you can still test the UI.
Expected fields: the bridge accepts both verbose (Pc:Pc_bar, A101:gRMS_20_60, …) or short (pc_bar, grms_a101, …) names—so you can stream your current cleaned outputs without renaming.
What’s baked in
Gates & Durations
LeakSentinel: dP/dt ≤ -40 bar/s for ≥300 ms → ABORT badge log.
VibeGuard: max(A-101,A-202) gRMS ≥ 0.05 g for ≥200 ms → WARN badge.
Heatshield: risk ≥ 0.5 → WARN badge.
Scale & Speed
~2 min rolling window (1200 points @ 10 Hz). Adjust MAXPTS as you like.
No dependencies on the front-end beyond Chart.js CDN. SSE bridge only needs aiohttp (install with pip install aiohttp).
If you want, I can also add:
a minimal WARN/ABORT event CSV writer in the SSE bridge,
an /metrics JSON endpoint (leak durations, gRMS max, etc.) for headless monitoring,
or a UDP passthrough mode so the bridge can rebroadcast to other consoles.
Say the word and I’ll drop those in.