openc3-cosmos-constellation 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Rakefile +24 -0
- data/microservices/BRIDGE/bridge.py +546 -0
- data/microservices/BRIDGE/constellation_space/__init__.py +0 -0
- data/microservices/BRIDGE/constellation_space/results.py +512 -0
- data/plugin.txt +25 -0
- data/targets/CONSTELLATION/cmd_tlm/tlm.txt +78 -0
- data/targets/CONSTELLATION/target.txt +1 -0
- data/tools/constellationforecast/forecast-view.js +673 -0
- data/tools/constellationforecast/main.js +102 -0
- metadata +57 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
// The Constellation Forecast view: one renderer for every mission control system's widget (the
|
|
2
|
+
// Yamcs page, the Open MCT view). It draws what a Constellation bridge wrote for each workflow:
|
|
3
|
+
// the run's results and the blocks the workflow chose, grouped by what the results are (the
|
|
4
|
+
// bridge's `kind`, named as the template that makes them; items do not name their template).
|
|
5
|
+
//
|
|
6
|
+
// Items are Constellation's item shape: entity, target_at, until, value, explain (each column the
|
|
7
|
+
// row carries: {value, unit, source}), and the optional series, severity and action.
|
|
8
|
+
//
|
|
9
|
+
// mountForecast(element, { load }) -> { destroy }
|
|
10
|
+
// load(name) resolves a JSON object the bridge wrote ("index.json", "workflow-<slug>.json")
|
|
11
|
+
// through the host's own session, so the view holds no Constellation key.
|
|
12
|
+
//
|
|
13
|
+
// Framework-free, inline SVG, themed from the host's CSS variables. Every chart has a hover
|
|
14
|
+
// tooltip and a table (the list block) beside it.
|
|
15
|
+
|
|
16
|
+
const REFRESH_MS = 15000;
|
|
17
|
+
const ROWS = 100;
|
|
18
|
+
|
|
19
|
+
const CATEGORIES = [
|
|
20
|
+
{ key: 'passes', label: 'Passes', kinds: ['pass-yield', 'modcod-plan'] },
|
|
21
|
+
{ key: 'capacity', label: 'Capacity', kinds: ['antenna-capacity'] },
|
|
22
|
+
{ key: 'risk', label: 'Risk', kinds: ['rain-fade-swap', 'rfi-watch'] },
|
|
23
|
+
{ key: 'audit', label: 'Audit', kinds: ['audit-ledger'] },
|
|
24
|
+
];
|
|
25
|
+
export const TITLES = {
|
|
26
|
+
'pass-yield': 'Pass yield',
|
|
27
|
+
'modcod-plan': 'MODCOD plan',
|
|
28
|
+
'antenna-capacity': 'Antenna capacity',
|
|
29
|
+
'rain-fade-swap': 'Rain fade swap',
|
|
30
|
+
'audit-ledger': 'Audit ledger',
|
|
31
|
+
'rfi-watch': 'RFI watch',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// --- formatting ----------------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const esc = (v) =>
|
|
37
|
+
String(v ?? '—').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
38
|
+
const num = (v, d = 1) => (typeof v === 'number' && isFinite(v) ? v.toFixed(d) : '—');
|
|
39
|
+
const big = (v) => (typeof v !== 'number' ? '—' : v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v >= 100 ? v.toFixed(0) : v.toFixed(1));
|
|
40
|
+
// Times come from the forecast doc as strings: escaped here, since every caller writes them into HTML.
|
|
41
|
+
const hhmm = (iso) => (iso ? esc(iso.slice(11, 16)) : '—');
|
|
42
|
+
const minute = (iso) => (iso ? `${esc(iso.slice(0, 16).replace('T', ' '))}Z` : '—');
|
|
43
|
+
const ago = (iso) => {
|
|
44
|
+
const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
|
|
45
|
+
return s < 60 ? `${s} s ago` : s < 3600 ? `${Math.round(s / 60)} min ago` : `${Math.round(s / 3600)} h ago`;
|
|
46
|
+
};
|
|
47
|
+
const until = (iso) => {
|
|
48
|
+
const m = Math.round((Date.parse(iso) - Date.now()) / 60000);
|
|
49
|
+
return m <= 0 ? 'passed' : m < 120 ? `in ${m} min` : `in ${Math.round(m / 60)} h`;
|
|
50
|
+
};
|
|
51
|
+
// Expected GB at p10/p50/p90 (and, for an antenna-day, its passes and sellable minutes); null for
|
|
52
|
+
// a pass pricing could not price.
|
|
53
|
+
const gbOf = (item) => {
|
|
54
|
+
// The ledger node first (it carries an antenna-day's passes and minutes), else the item's value,
|
|
55
|
+
// the output column, as a copied workflow may rename the node.
|
|
56
|
+
const ledger = item.explain?.expected_gb?.value;
|
|
57
|
+
const g = typeof ledger?.p50 === 'number' ? ledger : item.value;
|
|
58
|
+
return g && typeof g.p50 === 'number' ? g : null;
|
|
59
|
+
};
|
|
60
|
+
const ledgerOf = (item) => item.explain?.ledger?.value || {};
|
|
61
|
+
// A pass's max elevation: the ledger entry the orbit wrote, under whichever node wrote it.
|
|
62
|
+
const elevationOf = (item) =>
|
|
63
|
+
Object.values(item.explain || {}).find((e) => e?.source === 'orbit' && e?.unit === 'deg')?.value;
|
|
64
|
+
// A column's value to show: a number as it is, a quantile column by its p50.
|
|
65
|
+
const shown = (v) => (typeof v === 'number' ? v : typeof v?.p50 === 'number' ? v.p50 : null);
|
|
66
|
+
// "climatology:unavailable:<the model's whole refusal>" reads as the fallback it is; the reason is
|
|
67
|
+
// on hover.
|
|
68
|
+
const sourceLabel = (source) =>
|
|
69
|
+
!source ? '—' : source.startsWith('climatology:') ? `climatology${source.includes(':unavailable') ? ' (model unavailable)' : ''}` : source;
|
|
70
|
+
const shortAntenna = (id) => (id && id.includes('__') ? id.split('__').pop() : id);
|
|
71
|
+
// "SITE-000000000001__ant0" as "000001 ant0": enough of the station to tell rows apart.
|
|
72
|
+
const stationTag = (id) => (id && id.includes('__') ? `${id.split('__')[0].slice(-6)} ${shortAntenna(id)}` : id);
|
|
73
|
+
const stationOf = (item) => item.entity.ground_station_id;
|
|
74
|
+
const linkLabel = (item) =>
|
|
75
|
+
item.entity.type === 'antenna'
|
|
76
|
+
? `${item.entity.id}`
|
|
77
|
+
: `${item.entity.satellite_id} @ ${item.entity.antenna_id}`;
|
|
78
|
+
|
|
79
|
+
// --- SVG charts ----------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const W = 640;
|
|
82
|
+
const tip = (text) => `data-tip="${esc(text)}"`;
|
|
83
|
+
|
|
84
|
+
function axisTicks(max, n = 4) {
|
|
85
|
+
if (!(max > 0)) return [0];
|
|
86
|
+
const raw = max / n;
|
|
87
|
+
const mag = 10 ** Math.floor(Math.log10(raw));
|
|
88
|
+
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) || raw;
|
|
89
|
+
const out = [];
|
|
90
|
+
for (let v = 0; v <= max + step * 0.01; v += step) out.push(v);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** p10 to p90 band with a p50 line over time buckets. */
|
|
95
|
+
function bandChart(buckets, { height = 180, unit = 'GB' } = {}) {
|
|
96
|
+
const pad = { l: 44, r: 12, t: 12, b: 26 };
|
|
97
|
+
const h = height;
|
|
98
|
+
const max = Math.max(...buckets.map((b) => b.p90), 0.001);
|
|
99
|
+
const ticks = axisTicks(max);
|
|
100
|
+
const top = ticks[ticks.length - 1];
|
|
101
|
+
const x = (i) => pad.l + (i / Math.max(buckets.length - 1, 1)) * (W - pad.l - pad.r);
|
|
102
|
+
const y = (v) => h - pad.b - (v / top) * (h - pad.t - pad.b);
|
|
103
|
+
const band = buckets.map((b, i) => `${x(i)},${y(b.p90)}`).join(' ') + ' ' + [...buckets].reverse().map((b, i) => `${x(buckets.length - 1 - i)},${y(b.p10)}`).join(' ');
|
|
104
|
+
const line = buckets.map((b, i) => `${i ? 'L' : 'M'}${x(i)},${y(b.p50)}`).join(' ');
|
|
105
|
+
const grid = ticks
|
|
106
|
+
.map((t) => `<line class="cf-grid" x1="${pad.l}" x2="${W - pad.r}" y1="${y(t)}" y2="${y(t)}"/><text class="cf-axis" x="${pad.l - 6}" y="${y(t) + 4}" text-anchor="end">${big(t)}</text>`)
|
|
107
|
+
.join('');
|
|
108
|
+
const labels = buckets
|
|
109
|
+
.map((b, i) => (i % Math.ceil(buckets.length / 8) === 0 ? `<text class="cf-axis" x="${x(i)}" y="${h - 8}" text-anchor="middle">${esc(b.label)}</text>` : ''))
|
|
110
|
+
.join('');
|
|
111
|
+
const hits = buckets
|
|
112
|
+
.map(
|
|
113
|
+
(b, i) =>
|
|
114
|
+
`<rect class="cf-hit" x="${x(i) - (W - pad.l - pad.r) / buckets.length / 2}" y="${pad.t}" width="${(W - pad.l - pad.r) / buckets.length}" height="${h - pad.t - pad.b}" ${tip(`${b.label} · ${num(b.p50)} ${unit} expected (p10 ${num(b.p10)}, p90 ${num(b.p90)}) · ${b.count} passes`)}/>`,
|
|
115
|
+
)
|
|
116
|
+
.join('');
|
|
117
|
+
return `<svg class="cf-chart" viewBox="0 0 ${W} ${h}" role="img" aria-label="Expected ${unit} over time, p10 to p90 band with p50 line">${grid}<polygon class="cf-band" points="${band}"/><path class="cf-line" d="${line}"/>${labels}${hits}</svg>`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Horizontal bars with p10 to p90 whiskers, one row per key. */
|
|
121
|
+
function rangeBars(rows, { unit = 'GB' } = {}) {
|
|
122
|
+
const rowH = 20;
|
|
123
|
+
const pad = { l: 128, r: 56, t: 4 };
|
|
124
|
+
const h = pad.t + rows.length * rowH;
|
|
125
|
+
const max = Math.max(...rows.map((r) => r.p90 ?? r.value), 0.001);
|
|
126
|
+
const x = (v) => pad.l + (v / max) * (W - pad.l - pad.r);
|
|
127
|
+
const body = rows
|
|
128
|
+
.map((r, i) => {
|
|
129
|
+
const y = pad.t + i * rowH;
|
|
130
|
+
const value = r.p50 ?? r.value;
|
|
131
|
+
const whisker =
|
|
132
|
+
r.p10 !== undefined
|
|
133
|
+
? `<line class="cf-whisker" x1="${x(r.p10)}" x2="${x(r.p90)}" y1="${y + rowH / 2}" y2="${y + rowH / 2}"/>`
|
|
134
|
+
: '';
|
|
135
|
+
return `<g ${tip(r.tip ?? `${r.label}: ${num(value)} ${unit}`)}><text class="cf-label" x="${pad.l - 8}" y="${y + rowH / 2 + 4}" text-anchor="end">${esc(r.label)}</text><rect class="cf-bar${r.warn ? ' cf-bar-warn' : ''}" x="${pad.l}" y="${y + 6}" width="${Math.max(x(value) - pad.l, 2)}" height="${rowH - 12}" rx="2"/>${whisker}<text class="cf-value" x="${x(r.p90 ?? value) + 6}" y="${y + rowH / 2 + 4}">${big(value)}</text></g>`;
|
|
136
|
+
})
|
|
137
|
+
.join('');
|
|
138
|
+
return `<svg class="cf-chart" viewBox="0 0 ${W} ${h}" role="img" aria-label="Values per row">${body}</svg>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A grid of cells shaded by value (one hue, light to dark). */
|
|
142
|
+
function heatGrid(rows, cols, cell, { unit = 'GB' } = {}) {
|
|
143
|
+
const pad = { l: 128, t: 22 };
|
|
144
|
+
const cw = Math.min(120, (W - pad.l) / Math.max(cols.length, 1));
|
|
145
|
+
const ch = 22;
|
|
146
|
+
const h = pad.t + rows.length * ch + 4;
|
|
147
|
+
const max = Math.max(...rows.flatMap((r) => cols.map((c) => cell(r, c)?.value ?? 0)), 0.001);
|
|
148
|
+
const head = cols.map((c, j) => `<text class="cf-axis" x="${pad.l + j * cw + cw / 2}" y="14" text-anchor="middle">${esc(c.slice(5))}</text>`).join('');
|
|
149
|
+
const body = rows
|
|
150
|
+
.map((r, i) => {
|
|
151
|
+
const cells = cols
|
|
152
|
+
.map((c, j) => {
|
|
153
|
+
const v = cell(r, c);
|
|
154
|
+
const share = v ? v.value / max : 0;
|
|
155
|
+
const label = v && cw > 48 ? `<text class="cf-heat-label${share > 0.55 ? ' cf-on-dark' : ''}" x="${pad.l + j * cw + cw / 2}" y="${pad.t + i * ch + 15}" text-anchor="middle">${big(v.value)}</text>` : '';
|
|
156
|
+
return `<g ${tip(v ? `${r} · ${c}: ${num(v.value)} ${unit}, ${v.detail}` : `${r} · ${c}: no passes`)}><rect class="cf-heat" x="${pad.l + j * cw + 1}" y="${pad.t + i * ch + 1}" width="${cw - 2}" height="${ch - 2}" rx="3" style="--share:${share.toFixed(3)}"/>${label}</g>`;
|
|
157
|
+
})
|
|
158
|
+
.join('');
|
|
159
|
+
return `<text class="cf-label" x="${pad.l - 8}" y="${pad.t + i * ch + 15}" text-anchor="end">${esc(r)}</text>${cells}`;
|
|
160
|
+
})
|
|
161
|
+
.join('');
|
|
162
|
+
return `<svg class="cf-chart" viewBox="0 0 ${W} ${h}" role="img" aria-label="Grid of values per row and day">${head}${body}</svg>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Two points per row joined by a line: what a pass keeps at p10 against its p50. */
|
|
166
|
+
function dumbbell(rows, { unit = 'GB' } = {}) {
|
|
167
|
+
const rowH = 22;
|
|
168
|
+
const pad = { l: 200, r: 24, t: 4 };
|
|
169
|
+
const h = pad.t + rows.length * rowH;
|
|
170
|
+
const max = Math.max(...rows.map((r) => r.b), 0.001);
|
|
171
|
+
const x = (v) => pad.l + (v / max) * (W - pad.l - pad.r);
|
|
172
|
+
return `<svg class="cf-chart" viewBox="0 0 ${W} ${h}" role="img" aria-label="p10 against p50 per pass">${rows
|
|
173
|
+
.map((r, i) => {
|
|
174
|
+
const y = pad.t + i * rowH + rowH / 2;
|
|
175
|
+
return `<g ${tip(r.tip)}><text class="cf-label" x="${pad.l - 8}" y="${y + 4}" text-anchor="end">${esc(r.label)}</text><line class="cf-whisker" x1="${x(r.a)}" x2="${x(r.b)}" y1="${y}" y2="${y}"/><circle class="cf-dot-warn" cx="${x(r.a)}" cy="${y}" r="5"/><circle class="cf-dot" cx="${x(r.b)}" cy="${y}" r="5"/></g>`;
|
|
176
|
+
})
|
|
177
|
+
.join('')}</svg>`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A step line over a pass: MODCOD changes as spectral efficiency over time. */
|
|
181
|
+
function stepChart(series) {
|
|
182
|
+
const pts = series.map((s) => ({ t: Date.parse(s.t), v: s.value.sinr_db, label: s.value.modcod }));
|
|
183
|
+
if (pts.length < 1) return '';
|
|
184
|
+
// Drawn for the ledger column, so its own width keeps text readable.
|
|
185
|
+
const W = 320;
|
|
186
|
+
const h = 170;
|
|
187
|
+
const pad = { l: 36, r: 8, t: 14, b: 22 };
|
|
188
|
+
const t0 = pts[0].t;
|
|
189
|
+
const t1 = Math.max(pts[pts.length - 1].t, t0 + 60000);
|
|
190
|
+
const lo = Math.min(...pts.map((p) => p.v)) - 1;
|
|
191
|
+
const hi = Math.max(...pts.map((p) => p.v)) + 1;
|
|
192
|
+
const x = (t) => pad.l + ((t - t0) / (t1 - t0)) * (W - pad.l - pad.r);
|
|
193
|
+
const y = (v) => h - pad.b - ((v - lo) / (hi - lo)) * (h - pad.t - pad.b);
|
|
194
|
+
let d = '';
|
|
195
|
+
pts.forEach((p, i) => {
|
|
196
|
+
d += i ? `H${x(p.t)}V${y(p.v)}` : `M${x(p.t)},${y(p.v)}`;
|
|
197
|
+
});
|
|
198
|
+
d += `H${W - pad.r}`;
|
|
199
|
+
const peak = pts.reduce((a, p) => (p.v > a.v ? p : a), pts[0]);
|
|
200
|
+
const marks = pts
|
|
201
|
+
.map((p) => `<circle class="cf-dot" cx="${x(p.t)}" cy="${y(p.v)}" r="4" ${tip(`${new Date(p.t).toISOString().slice(11, 16)}Z · ${p.label} · SINR ${num(p.v)} dB`)}/>`)
|
|
202
|
+
.join('');
|
|
203
|
+
const names = [pts[0], peak]
|
|
204
|
+
.map((p, k) => `<text class="cf-step-label" x="${Math.min(x(p.t) + 4, W - 70)}" y="${y(p.v) - 7}">${k ? 'peak ' : 'AOS '}${esc(p.label)}</text>`)
|
|
205
|
+
.join('');
|
|
206
|
+
return `<svg class="cf-chart" viewBox="0 0 ${W} ${h}" role="img" aria-label="MODCOD over the pass"><text class="cf-axis" x="${pad.l - 6}" y="${y(hi - 1) + 4}" text-anchor="end">${num(hi - 1, 0)} dB</text><text class="cf-axis" x="${pad.l - 6}" y="${y(lo + 1) + 4}" text-anchor="end">${num(lo + 1, 0)} dB</text><path class="cf-line" d="${d}"/>${marks}${names}<text class="cf-axis" x="${pad.l}" y="${h - 6}">${new Date(t0).toISOString().slice(11, 16)}Z</text><text class="cf-axis" x="${W - pad.r}" y="${h - 6}" text-anchor="end">${new Date(t1).toISOString().slice(11, 16)}Z</text></svg>`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- per-kind views ------------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
function upcoming(run) {
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
if (run.kind === 'audit-ledger') return [...run.items].sort((a, b) => a.target_at.localeCompare(b.target_at));
|
|
214
|
+
return run.items.filter((i) => !i.until || Date.parse(i.until) > now).sort((a, b) => a.target_at.localeCompare(b.target_at));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function hourly(items) {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
const start = Math.floor(now / 3600e3) * 3600e3;
|
|
220
|
+
const buckets = Array.from({ length: 24 }, (_, k) => ({ label: new Date(start + k * 3600e3).toISOString().slice(11, 16), p10: 0, p50: 0, p90: 0, count: 0 }));
|
|
221
|
+
for (const item of items) {
|
|
222
|
+
const g = gbOf(item);
|
|
223
|
+
const k = Math.floor((Date.parse(item.target_at) - start) / 3600e3);
|
|
224
|
+
if (!g || !(k >= 0 && k < 24)) continue;
|
|
225
|
+
buckets[k].p10 += g.p10;
|
|
226
|
+
buckets[k].p50 += g.p50;
|
|
227
|
+
buckets[k].p90 += g.p90;
|
|
228
|
+
buckets[k].count += 1;
|
|
229
|
+
}
|
|
230
|
+
return buckets;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function perKey(items, key, value) {
|
|
234
|
+
const out = new Map();
|
|
235
|
+
for (const item of items) {
|
|
236
|
+
const k = key(item);
|
|
237
|
+
const g = value(item);
|
|
238
|
+
if (!k || !g) continue;
|
|
239
|
+
const row = out.get(k) || { label: k, p10: 0, p50: 0, p90: 0, count: 0 };
|
|
240
|
+
row.p10 += g.p10;
|
|
241
|
+
row.p50 += g.p50;
|
|
242
|
+
row.p90 += g.p90;
|
|
243
|
+
row.count += 1;
|
|
244
|
+
out.set(k, row);
|
|
245
|
+
}
|
|
246
|
+
return [...out.values()].sort((a, b) => b.p50 - a.p50);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const tile = (label, value, hint) => `<div class="cf-tile"><div class="cf-tile-value">${esc(value)}</div><div class="cf-tile-label">${esc(label)}</div>${hint ? `<div class="cf-tile-hint">${esc(hint)}</div>` : ''}</div>`;
|
|
250
|
+
const section = (title, body, note) => `<section class="cf-section"><h3>${esc(title)}</h3>${note ? `<p class="cf-note">${esc(note)}</p>` : ''}${body}</section>`;
|
|
251
|
+
|
|
252
|
+
/** What an item's action is, as the bridges read it (constellation_space.results.action_state):
|
|
253
|
+
* 'advice' (suggested), 'approved' (approved in Constellation: an instruction), 'pending' (held
|
|
254
|
+
* for approval; its action is not sent), 'rejected', or null with no action. A status the view
|
|
255
|
+
* does not know is held, never a plain forecast. */
|
|
256
|
+
export function actionState(item) {
|
|
257
|
+
const status = item.action_status;
|
|
258
|
+
if (status != null && status !== 'approved') return status === 'rejected' ? 'rejected' : 'pending';
|
|
259
|
+
if (!item.action) return status == null ? null : 'pending';
|
|
260
|
+
return status === 'approved' ? 'approved' : 'advice';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A swap to show: advice, or approved in Constellation; never one held or rejected. */
|
|
264
|
+
const actionable = (i) => actionState(i) === 'advice' || actionState(i) === 'approved';
|
|
265
|
+
|
|
266
|
+
// rfi-watch items are links whose value is RFI in dB (p10/p50/p90) with a lock-loss risk (a
|
|
267
|
+
// share), and whose action is a reroute with no deadline: never GB, never a swap.
|
|
268
|
+
const pct = (v) => (typeof v === 'number' ? `${Math.round(v * 100)}%` : num(v));
|
|
269
|
+
const RFI = {
|
|
270
|
+
tiles(run, items) {
|
|
271
|
+
const worst = Math.max(0, ...items.map((i) => i.value?.p90 ?? 0));
|
|
272
|
+
const critical = items.filter((i) => i.severity === 'critical').length;
|
|
273
|
+
const approved = items.filter((i) => actionState(i) === 'approved').length;
|
|
274
|
+
const held = items.filter((i) => actionState(i) === 'pending').length;
|
|
275
|
+
return `<div class="cf-tiles">${tile('links over the RFI threshold', items.length)}${tile('worst RFI', `${num(worst)} dB`, 'p90, any link')}${tile('links losing lock to RFI', critical)}${tile('reroutes to decide', items.filter(actionable).length - approved, `${approved} approved · ${held} awaiting approval`)}${tile('workflow version', `v${run.workflow_version}`)}</div>`;
|
|
276
|
+
},
|
|
277
|
+
chart(run, items) {
|
|
278
|
+
const rows = [...items].sort((a, b) => (b.value?.p90 ?? 0) - (a.value?.p90 ?? 0)).slice(0, 14);
|
|
279
|
+
return section(
|
|
280
|
+
'RFI per link',
|
|
281
|
+
rangeBars(
|
|
282
|
+
rows.map((i) => ({
|
|
283
|
+
label: linkLabel(i),
|
|
284
|
+
value: i.value?.p50 ?? 0,
|
|
285
|
+
p10: i.value?.p10 ?? 0,
|
|
286
|
+
p90: i.value?.p90 ?? 0,
|
|
287
|
+
warn: i.severity === 'critical',
|
|
288
|
+
tip: `${linkLabel(i)}: ${num(i.value?.p50)} dB (p10 ${num(i.value?.p10)}, p90 ${num(i.value?.p90)}), lock-loss risk ${pct(i.value?.lock_loss_risk)}`,
|
|
289
|
+
})),
|
|
290
|
+
{ unit: 'dB' },
|
|
291
|
+
),
|
|
292
|
+
'Bar is p50 RFI in dB; the line runs p10 to p90. Amber: RFI alone loses lock.',
|
|
293
|
+
);
|
|
294
|
+
},
|
|
295
|
+
warnings(run, items) {
|
|
296
|
+
const approvedFirst = (a, b) => (actionState(b) === 'approved') - (actionState(a) === 'approved');
|
|
297
|
+
const risk = (i) => i.value?.lock_loss_risk ?? 0;
|
|
298
|
+
const all = items.filter(actionable).sort((a, b) => approvedFirst(a, b) || risk(b) - risk(a));
|
|
299
|
+
const held = items.filter((i) => actionState(i) === 'pending').length;
|
|
300
|
+
const cards = all.slice(0, 6).map((i) => {
|
|
301
|
+
const approved = actionState(i) === 'approved';
|
|
302
|
+
return `<div class="cf-card"><div class="cf-card-head"><span class="cf-status-warn" aria-hidden="true">▲</span><b>${approved ? 'Approved: carry out' : 'Reroute advised'}</b></div><div>${esc(linkLabel(i))} from ${minute(i.target_at)}: up to ${num(i.value?.p90)} dB of RFI at p90, lock-loss risk ${pct(risk(i))}.</div><div class="cf-card-action">Reroute off ${esc(shortAntenna(i.entity.antenna_id))}</div>${approved ? '<div class="cf-note">Approved in Constellation.</div>' : ''}</div>`;
|
|
303
|
+
});
|
|
304
|
+
const waiting = held ? ` ${held} awaiting approval in Constellation.` : '';
|
|
305
|
+
const body = cards.length ? `<div class="cf-cards">${cards.join('')}</div>` : `<p class="cf-empty">No reroutes advised.${waiting}</p>`;
|
|
306
|
+
return section('Reroutes', body, `Approved first, then the highest lock-loss risk.${waiting}`);
|
|
307
|
+
},
|
|
308
|
+
list(run, items, selected) {
|
|
309
|
+
const rows = items
|
|
310
|
+
.slice(0, ROWS)
|
|
311
|
+
.map(
|
|
312
|
+
(i) =>
|
|
313
|
+
`<tr data-id="${esc(i.entity.id)}" class="${i.entity.id === selected ? 'cf-sel' : ''}"><td>${minute(i.target_at)}</td><td>${esc(linkLabel(i))}</td><td class="cf-num">${num(i.value?.p10)} / <b>${num(i.value?.p50)}</b> / ${num(i.value?.p90)}</td><td class="cf-num">${pct(i.value?.lock_loss_risk)}</td><td>${i.action ? '<span class="cf-status-warn" title="reroute">▲</span>' : ''}</td></tr>`,
|
|
314
|
+
)
|
|
315
|
+
.join('');
|
|
316
|
+
const head = '<th>From (UTC)</th><th>Link</th><th class="cf-num">RFI dB p10 / p50 / p90</th><th class="cf-num">Lock-loss risk</th><th></th>';
|
|
317
|
+
const note = items.length > ROWS ? `The first ${ROWS} of ${items.length}.` : `${items.length} results.`;
|
|
318
|
+
return section('Results', `<div class="cf-scroll"><table class="cf-table"><thead><tr>${head}</tr></thead><tbody>${rows}</tbody></table></div>`, `${note} Select a row for its ledger.`);
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
export const VIEWS = {
|
|
323
|
+
tiles(run, items) {
|
|
324
|
+
if (run.kind === 'rfi-watch') return RFI.tiles(run, items);
|
|
325
|
+
if (run.kind === 'audit-ledger') {
|
|
326
|
+
const possible = items.reduce((s, i) => s + (ledgerOf(i).possible_gb ?? 0), 0);
|
|
327
|
+
const delivered = items.reduce((s, i) => s + (ledgerOf(i).delivered_gb ?? 0), 0);
|
|
328
|
+
const dead = items.filter((i) => ledgerOf(i).dead).length;
|
|
329
|
+
return `<div class="cf-tiles">${tile('passes audited', items.length)}${tile('GB delivered', big(delivered), `of ${big(possible)} possible`)}${tile('delivered', possible ? `${Math.round((delivered / possible) * 100)}%` : '—')}${tile('dead passes', dead, 'failed, missed or unlogged')}</div>`;
|
|
330
|
+
}
|
|
331
|
+
if (run.kind === 'antenna-capacity') {
|
|
332
|
+
const gb = items.reduce((s, i) => s + (gbOf(i)?.p50 ?? 0), 0);
|
|
333
|
+
const minutes = items.reduce((s, i) => s + (gbOf(i)?.sellable_min ?? 0), 0);
|
|
334
|
+
return `<div class="cf-tiles">${tile('antenna-days', items.length)}${tile('sellable minutes', big(minutes))}${tile('GB expected', big(gb), 'p50, summed')}${tile('antennas', new Set(items.map((i) => i.entity.id)).size)}</div>`;
|
|
335
|
+
}
|
|
336
|
+
if (run.kind === 'rain-fade-swap') {
|
|
337
|
+
const swaps = items.filter(actionable).sort((a, b) => a.action.deadline.localeCompare(b.action.deadline));
|
|
338
|
+
const approved = swaps.filter((i) => actionState(i) === 'approved').length;
|
|
339
|
+
const held = items.filter((i) => actionState(i) === 'pending').length;
|
|
340
|
+
const atRisk = swaps.reduce((s, i) => s + (gbOf(i).p50 - gbOf(i).p10), 0);
|
|
341
|
+
const soonest = swaps.find((i) => Date.parse(i.action.deadline) > Date.now());
|
|
342
|
+
const booked = swaps.filter((i) => i.action.to).length;
|
|
343
|
+
const hint = approved || held ? `${approved} approved · ${held} awaiting approval` : `${booked} with a better backup`;
|
|
344
|
+
return `<div class="cf-tiles">${tile('swaps to decide', swaps.length - approved, hint)}${tile('GB at risk if it rains', big(atRisk), 'p50 minus p10, summed')}${tile('soonest decision', soonest ? until(soonest.action.deadline) : '—', soonest ? `cancel by ${minute(soonest.action.deadline)}` : '')}${tile('workflow version', `v${run.workflow_version}`)}</div>`;
|
|
345
|
+
}
|
|
346
|
+
const day = items.filter((i) => Date.parse(i.target_at) < Date.now() + 86400e3);
|
|
347
|
+
const p50 = day.reduce((s, i) => s + (gbOf(i)?.p50 ?? 0), 0);
|
|
348
|
+
const p10 = day.reduce((s, i) => s + (gbOf(i)?.p10 ?? 0), 0);
|
|
349
|
+
const swaps = items.filter(actionable).length;
|
|
350
|
+
const next = items.find((i) => Date.parse(i.target_at) > Date.now());
|
|
351
|
+
return `<div class="cf-tiles">${tile('passes, next 24 h', day.length)}${tile('GB expected', big(p50), `${big(p10)} at worst (p10)`)}${swaps ? tile('swaps advised', swaps, 'see Risk below') : tile('next pass', next ? `${hhmm(next.target_at)}Z` : '—', next ? `${next.entity.satellite_id} @ ${shortAntenna(next.entity.antenna_id)}` : '')}${tile('workflow version', `v${run.workflow_version}`)}</div>`;
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
chart(run, items) {
|
|
355
|
+
if (run.kind === 'rfi-watch') return RFI.chart(run, items);
|
|
356
|
+
if (run.kind === 'antenna-capacity') {
|
|
357
|
+
const days = [...new Set(items.map((i) => i.target_at.slice(0, 10)))].sort();
|
|
358
|
+
const antennas = [...new Set(items.map((i) => i.entity.id))].sort();
|
|
359
|
+
const at = new Map(items.map((i) => [`${i.entity.id}|${i.target_at.slice(0, 10)}`, i]));
|
|
360
|
+
return section(
|
|
361
|
+
'Expected GB per antenna per day',
|
|
362
|
+
heatGrid(antennas, days, (a, d) => {
|
|
363
|
+
const i = at.get(`${a}|${d}`);
|
|
364
|
+
return i ? { value: gbOf(i)?.p50 ?? 0, detail: `${gbOf(i)?.passes} passes, ${num(gbOf(i)?.sellable_min, 0)} sellable min` } : null;
|
|
365
|
+
}),
|
|
366
|
+
'Darker is more. Hover a cell for its passes and minutes.',
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
if (run.kind === 'rain-fade-swap') {
|
|
370
|
+
const risky = items.filter(actionable).slice(0, 20);
|
|
371
|
+
if (!risky.length) return section('Passes at weather risk', '<p class="cf-empty">No pass is at risk in this run.</p>');
|
|
372
|
+
return section(
|
|
373
|
+
'What each at-risk pass keeps if it rains',
|
|
374
|
+
dumbbell(
|
|
375
|
+
risky.map((i) => ({
|
|
376
|
+
label: `${i.entity.satellite_id} @ ${stationTag(i.entity.antenna_id)}`,
|
|
377
|
+
a: gbOf(i).p10,
|
|
378
|
+
b: gbOf(i).p50,
|
|
379
|
+
tip: `${linkLabel(i)} at ${minute(i.target_at)}: ${num(gbOf(i).p10)} GB at p10 against ${num(gbOf(i).p50)} GB at p50`,
|
|
380
|
+
})),
|
|
381
|
+
),
|
|
382
|
+
'Amber is the wet tail (p10); dark is the median (p50). The wider the gap, the more rain costs.',
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
if (run.kind === 'audit-ledger') {
|
|
386
|
+
const rows = perKey(items, stationOf, (i) => ({ p10: ledgerOf(i).delivered_gb ?? 0, p50: ledgerOf(i).delivered_gb ?? 0, p90: ledgerOf(i).possible_gb ?? 0 }));
|
|
387
|
+
return section(
|
|
388
|
+
'Delivered against possible, per station',
|
|
389
|
+
rangeBars(rows.map((r) => ({ label: r.label, value: r.p50, p10: r.p50, p90: r.p90, tip: `${r.label}: ${num(r.p50)} GB delivered of ${num(r.p90)} possible, ${r.count} passes` }))),
|
|
390
|
+
'The bar is what was delivered; the line runs to what was possible.',
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
if (run.kind === 'modcod-plan') {
|
|
394
|
+
const mix = new Map();
|
|
395
|
+
for (const item of items) for (const s of item.series || []) mix.set(s.value.modcod, (mix.get(s.value.modcod) || 0) + 1);
|
|
396
|
+
const rows = [...mix.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([label, value]) => ({ label, value, tip: `${label}: chosen ${value} times across the plan` }));
|
|
397
|
+
return section('MODCOD mix across the plan', rangeBars(rows, { unit: 'steps' }), 'How often each MODCOD is planned. Select a pass below to see its own plan.');
|
|
398
|
+
}
|
|
399
|
+
return section('Expected GB over the next 24 hours', bandChart(hourly(items)), 'The line is the median (p50); the band runs from the wet tail (p10) to the best case (p90). Hover an hour for its passes.');
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
breakdown(run, items) {
|
|
403
|
+
if (['audit-ledger', 'modcod-plan', 'rfi-watch'].includes(run.kind)) return '';
|
|
404
|
+
if (run.kind === 'antenna-capacity') {
|
|
405
|
+
const rows = perKey(items, (i) => i.entity.id, gbOf).slice(0, 14);
|
|
406
|
+
return section('Antennas ranked by expected GB', rangeBars(rows.map((r) => ({ ...r, label: shortAntenna(r.label) === r.label ? r.label : `${r.label.split('__')[0].slice(-6)} ${shortAntenna(r.label)}`, tip: `${r.label}: ${num(r.p50)} GB (p10 ${num(r.p10)}, p90 ${num(r.p90)}) over ${r.count} days` }))));
|
|
407
|
+
}
|
|
408
|
+
const day = items.filter((i) => Date.parse(i.target_at) < Date.now() + 86400e3);
|
|
409
|
+
const rows = perKey(day, (i) => i.entity.antenna_id, gbOf).slice(0, 14);
|
|
410
|
+
return section(
|
|
411
|
+
'Where the GB lands: antennas, next 24 h',
|
|
412
|
+
rangeBars(rows.map((r) => ({ ...r, label: stationTag(r.label), tip: `${r.label}: ${num(r.p50)} GB expected over ${r.count} passes (p10 ${num(r.p10)}, p90 ${num(r.p90)})` }))),
|
|
413
|
+
'Bar is p50; the line runs p10 to p90.',
|
|
414
|
+
);
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
warnings(run, items) {
|
|
418
|
+
if (run.kind === 'rfi-watch') return RFI.warnings(run, items);
|
|
419
|
+
const approvedFirst = (a, b) => (actionState(b) === 'approved') - (actionState(a) === 'approved');
|
|
420
|
+
const all = items
|
|
421
|
+
.filter((i) => actionable(i) && Date.parse(i.action.deadline) > Date.now())
|
|
422
|
+
.sort((a, b) => approvedFirst(a, b) || a.action.deadline.localeCompare(b.action.deadline));
|
|
423
|
+
const held = items.filter((i) => actionState(i) === 'pending').length;
|
|
424
|
+
const cards = all
|
|
425
|
+
.slice(0, 6)
|
|
426
|
+
.map((i) => {
|
|
427
|
+
const a = i.action;
|
|
428
|
+
const g = gbOf(i);
|
|
429
|
+
const approved = actionState(i) === 'approved';
|
|
430
|
+
const backup = a.to ? `Book ${esc(i.entity.satellite_id)} on ${esc(shortAntenna(a.to_antenna_id))} at ${minute(a.to_at)}, +${num(a.gain_gb_p10)} GB at p10` : 'No better pass within 6 h';
|
|
431
|
+
const head = approved ? 'Approved: carry out' : 'Rain-fade risk';
|
|
432
|
+
const note = approved ? `Approved in Constellation. Cancel the original by ${minute(a.deadline)}` : `Cancel by ${minute(a.deadline)}`;
|
|
433
|
+
return `<div class="cf-card" ${tip(`Decide by ${minute(a.deadline)}`)}><div class="cf-card-head"><span class="cf-status-warn" aria-hidden="true">▲</span><b>${head}</b><span class="cf-chip">${until(a.deadline)}</span></div><div>${esc(linkLabel(i))} at ${minute(i.target_at)} keeps ${num(g.p10)} of ${num(g.p50)} GB if it rains.</div><div class="cf-card-action">${backup}</div><div class="cf-note">${note}</div></div>`;
|
|
434
|
+
});
|
|
435
|
+
// A pass at risk with no backup comes as advice alone (a warning and its note): nothing to decide.
|
|
436
|
+
const advisories = items
|
|
437
|
+
.filter((i) => actionState(i) === null && i.severity)
|
|
438
|
+
.slice(0, Math.max(0, 6 - cards.length))
|
|
439
|
+
.map((i) => `<div class="cf-card"><div class="cf-card-head"><span class="cf-status-warn" aria-hidden="true">▲</span><b>Rain-fade risk</b></div><div>${esc(linkLabel(i))} at ${minute(i.target_at)} keeps ${num(gbOf(i).p10)} of ${num(gbOf(i).p50)} GB if it rains.</div><div class="cf-card-action">${esc(i.note || 'No backup pass in the window')}</div></div>`);
|
|
440
|
+
cards.push(...advisories);
|
|
441
|
+
const waiting = held ? ` ${held} awaiting approval in Constellation.` : '';
|
|
442
|
+
const more = (all.length > 6 ? `The ${6} soonest of ${all.length}. Every swap is in Results below.` : 'Approved first, then the soonest decision.') + waiting;
|
|
443
|
+
const title = all.some((i) => actionState(i) === 'approved') ? 'Swaps to carry out and to decide' : 'Swaps to decide';
|
|
444
|
+
return section(title, cards.length ? `<div class="cf-cards">${cards.join('')}</div>` : `<p class="cf-empty">No swaps to decide.${waiting}</p>`, more);
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
list(run, items, selected) {
|
|
448
|
+
if (run.kind === 'rfi-watch') return RFI.list(run, items, selected);
|
|
449
|
+
const shown = items.slice(0, ROWS);
|
|
450
|
+
const audit = run.kind === 'audit-ledger';
|
|
451
|
+
const head = audit
|
|
452
|
+
? '<th>Pass</th><th>Result</th><th class="cf-num">GB delivered / possible</th><th>Status</th>'
|
|
453
|
+
: run.kind === 'antenna-capacity'
|
|
454
|
+
? '<th>Day</th><th>Antenna</th><th class="cf-num">GB p10 / p50 / p90</th><th>Passes · minutes</th>'
|
|
455
|
+
: '<th>Time (UTC)</th><th>Pass</th><th class="cf-num">GB p10 / p50 / p90</th><th></th>';
|
|
456
|
+
const rows = shown
|
|
457
|
+
.map((i) => {
|
|
458
|
+
const g = gbOf(i);
|
|
459
|
+
const cells = audit
|
|
460
|
+
? `<td>${minute(i.target_at)}</td><td>${esc(linkLabel(i))}</td><td class="cf-num">${num(ledgerOf(i).delivered_gb)} / ${num(ledgerOf(i).possible_gb)}</td><td>${ledgerOf(i).dead ? `<span class="cf-status-warn">▲</span> ${esc(ledgerOf(i).status)}` : esc(ledgerOf(i).status)}</td>`
|
|
461
|
+
: run.kind === 'antenna-capacity'
|
|
462
|
+
? `<td>${esc(i.target_at.slice(0, 10))}</td><td>${esc(i.entity.id)}</td><td class="cf-num">${num(g?.p10)} / <b>${num(g?.p50)}</b> / ${num(g?.p90)}</td><td>${esc(gbOf(i)?.passes)} · ${num(gbOf(i)?.sellable_min, 0)} min</td>`
|
|
463
|
+
: `<td>${minute(i.target_at)}</td><td>${esc(linkLabel(i))}</td><td class="cf-num">${num(g?.p10)} / <b>${num(g?.p50)}</b> / ${num(g?.p90)}</td><td>${i.action || i.severity ? '<span class="cf-status-warn" title="rain-fade risk">▲</span>' : ''}${i.series ? `${esc(i.series.length)} MODCODs` : elevationOf(i) != null ? `${Math.round(elevationOf(i))}°` : ''}</td>`;
|
|
464
|
+
return `<tr data-id="${esc(i.entity.id)}" class="${i.entity.id === selected ? 'cf-sel' : ''}">${cells}</tr>`;
|
|
465
|
+
})
|
|
466
|
+
.join('');
|
|
467
|
+
const note = items.length > ROWS ? `The first ${ROWS} of ${items.length}.` : `${items.length} results.`;
|
|
468
|
+
return section('Results', `<div class="cf-scroll"><table class="cf-table"><thead><tr>${head}</tr></thead><tbody>${rows}</tbody></table></div>`, `${note} Select a row for its ledger.`);
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
ledger(run, items, selected) {
|
|
472
|
+
const item = items.find((i) => i.entity.id === selected) || items[0];
|
|
473
|
+
if (!item) return '';
|
|
474
|
+
const x = item.explain || {};
|
|
475
|
+
const laneRows = Object.entries(x)
|
|
476
|
+
.filter(([, l]) => shown(l?.value) != null)
|
|
477
|
+
.map(([column, l]) => `<tr><td>${esc(column)}</td><td class="cf-num">${num(shown(l.value))} ${esc(l.unit ?? '')}</td><td class="cf-src" ${tip(l.source ?? '')}>${esc(sourceLabel(l.source))}</td></tr>`)
|
|
478
|
+
.join('');
|
|
479
|
+
const g = run.kind === 'rfi-watch' ? null : gbOf(item);
|
|
480
|
+
const plan = item.series?.length ? `<h4>Plan over the pass</h4>${stepChart(item.series)}` : '';
|
|
481
|
+
const range = g
|
|
482
|
+
? `<div class="cf-range" ${tip(`p10 ${num(g.p10)} · p50 ${num(g.p50)} · p90 ${num(g.p90)} GB`)}><span style="left:${(g.p10 / g.p90) * 100}%;right:0"></span><i style="left:${(g.p50 / g.p90) * 100}%"></i></div><div class="cf-range-labels"><span>p10 ${num(g.p10)}</span><b>p50 ${num(g.p50)} GB</b><span>p90 ${num(g.p90)}</span></div>`
|
|
483
|
+
: '';
|
|
484
|
+
return `<aside class="cf-ledger"><h3>${run.kind === 'rfi-watch' ? 'Why this link is flagged' : 'Why this pass is worth what it is'}</h3><div class="cf-ledger-title">${esc(linkLabel(item))}<span>${minute(item.target_at)}${item.until ? ` – ${hhmm(item.until)}Z` : ''}</span></div>${range}<table class="cf-table cf-compact">${laneRows}</table>${plan}<p class="cf-note">Workflow ${esc(run.workflow)} v${esc(item.workflow_version ?? run.workflow_version)} · run ${esc(run.run_id)}</p></aside>`;
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// --- the view ------------------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
const STYLE = `
|
|
491
|
+
.cf { --cf-ink: var(--y-foreground-color, #0b0b0b); --cf-muted: var(--y-hint-foreground, #6b6b66); --cf-line: var(--y-border-color, rgba(0,0,0,.1));
|
|
492
|
+
--cf-surface: var(--y-background-color, #fff); --cf-raise: var(--y-detail-pane-background-color, #fafaf9); --cf-series: #2a78d6; --cf-band: rgba(42,120,214,.18);
|
|
493
|
+
--cf-warn: #fab219; --cf-warn-ink: #8a5a00;
|
|
494
|
+
font: 13px/1.45 Roboto, "Helvetica Neue", system-ui, sans-serif; color: var(--cf-ink); background: var(--cf-surface); padding: 20px 24px 48px; position: relative; }
|
|
495
|
+
/* Dark hosts (Open MCT's Espresso, Yamcs's dark theme): the same roles stepped for a dark surface. */
|
|
496
|
+
.cf[data-theme="dark"] { --cf-ink: #e8e8e6; --cf-muted: #a3a29c; --cf-line: rgba(255,255,255,.12); --cf-surface: #1a1a19;
|
|
497
|
+
--cf-raise: #242423; --cf-series: #3987e5; --cf-band: rgba(57,135,229,.28); --cf-warn: #fab219; --cf-warn-ink: #fab219; }
|
|
498
|
+
.cf * { box-sizing: border-box; }
|
|
499
|
+
.cf-head { display:flex; align-items:baseline; gap:12px; flex-wrap:wrap; margin-bottom:4px; }
|
|
500
|
+
.cf-head h2 { font-size:20px; font-weight:500; margin:0; }
|
|
501
|
+
.cf-forecast { font-size:11px; letter-spacing:.06em; text-transform:uppercase; color:var(--cf-warn-ink); border:1px solid var(--cf-warn); border-radius:999px; padding:1px 8px; }
|
|
502
|
+
.cf-meta { color:var(--cf-muted); font-size:12px; }
|
|
503
|
+
.cf-cats { display:flex; gap:2px; border-bottom:1px solid var(--cf-line); margin:16px 0 0; }
|
|
504
|
+
.cf-cats button { background:none; border:none; border-bottom:2px solid transparent; padding:8px 14px; color:var(--cf-muted); cursor:pointer; font:inherit; }
|
|
505
|
+
.cf-cats button[aria-selected="true"] { color:var(--cf-ink); border-bottom-color:var(--cf-ink); font-weight:500; }
|
|
506
|
+
.cf-cats button:disabled { opacity:.4; cursor:default; }
|
|
507
|
+
.cf-count { font-size:11px; margin-left:6px; color:var(--cf-muted); }
|
|
508
|
+
.cf-flows { display:flex; gap:6px; margin:12px 0 4px; flex-wrap:wrap; }
|
|
509
|
+
.cf-flows button { border:1px solid var(--cf-line); background:var(--cf-surface); border-radius:6px; padding:4px 10px; cursor:pointer; font:inherit; color:inherit; }
|
|
510
|
+
.cf-flows button[aria-pressed="true"] { border-color:var(--cf-ink); }
|
|
511
|
+
.cf-grid2 { display:grid; grid-template-columns:minmax(0,1fr) 340px; gap:20px; align-items:start; margin-top:12px; }
|
|
512
|
+
@media (max-width: 1100px) { .cf-grid2 { grid-template-columns:1fr; } }
|
|
513
|
+
.cf-tiles { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px,1fr)); gap:10px; margin:12px 0 4px; }
|
|
514
|
+
.cf-tile { border:1px solid var(--cf-line); border-radius:8px; padding:10px 12px; background:var(--cf-surface); }
|
|
515
|
+
.cf-tile-value { font-size:24px; font-weight:500; font-variant-numeric:tabular-nums; }
|
|
516
|
+
.cf-tile-label { color:var(--cf-muted); }
|
|
517
|
+
.cf-tile-hint { font-size:11px; color:var(--cf-muted); margin-top:2px; }
|
|
518
|
+
.cf-section { margin:18px 0 0; }
|
|
519
|
+
.cf-section h3, .cf-ledger h3 { font-size:13px; font-weight:500; margin:0 0 6px; }
|
|
520
|
+
.cf-ledger h4 { font-size:12px; font-weight:500; margin:12px 0 4px; }
|
|
521
|
+
.cf-note { color:var(--cf-muted); font-size:12px; margin:0 0 6px; }
|
|
522
|
+
.cf-empty { color:var(--cf-muted); }
|
|
523
|
+
.cf-chart { width:100%; height:auto; display:block; overflow:visible; }
|
|
524
|
+
.cf-grid { stroke:var(--cf-line); stroke-width:1; }
|
|
525
|
+
.cf-axis, .cf-label, .cf-value { font-size:9.5px; fill:var(--cf-muted); }
|
|
526
|
+
.cf-chart { max-width:820px; }
|
|
527
|
+
.cf-label { fill:var(--cf-ink); }
|
|
528
|
+
.cf-band { fill:var(--cf-band); }
|
|
529
|
+
.cf-line { fill:none; stroke:var(--cf-series); stroke-width:2; stroke-linejoin:round; }
|
|
530
|
+
.cf-bar { fill:var(--cf-series); }
|
|
531
|
+
.cf-bar-warn { fill:var(--cf-warn); }
|
|
532
|
+
.cf-whisker { stroke:var(--cf-ink); stroke-width:1.5; opacity:.55; }
|
|
533
|
+
.cf-dot { fill:var(--cf-series); stroke:var(--cf-surface); stroke-width:2; }
|
|
534
|
+
.cf-dot-warn { fill:var(--cf-warn); stroke:var(--cf-surface); stroke-width:2; }
|
|
535
|
+
.cf-heat { fill:var(--cf-series); fill-opacity:calc(.08 + var(--share) * .92); }
|
|
536
|
+
.cf-hit { fill:transparent; }
|
|
537
|
+
.cf-heat-label { font-size:9.5px; fill:var(--cf-ink); }
|
|
538
|
+
.cf-on-dark { fill:#fff; }
|
|
539
|
+
.cf-step-label { font-size:10px; fill:var(--cf-ink); }
|
|
540
|
+
.cf-hit:hover { fill:var(--cf-line); }
|
|
541
|
+
g[data-tip]:hover .cf-bar, g[data-tip]:hover .cf-dot { filter:brightness(1.15); }
|
|
542
|
+
.cf-cards { display:grid; grid-template-columns:repeat(auto-fill, minmax(260px,1fr)); gap:10px; }
|
|
543
|
+
.cf-card { border:1px solid var(--cf-line); border-left:3px solid var(--cf-warn); border-radius:8px; padding:10px 12px; background:var(--cf-surface); }
|
|
544
|
+
.cf-card-head { display:flex; gap:6px; align-items:center; margin-bottom:4px; }
|
|
545
|
+
.cf-card-head .cf-chip { margin-left:auto; }
|
|
546
|
+
.cf-card-action { font-weight:500; margin-top:4px; }
|
|
547
|
+
.cf-chip { font-size:11px; border:1px solid var(--cf-line); border-radius:999px; padding:0 8px; color:var(--cf-muted); }
|
|
548
|
+
.cf-status-warn { color:var(--cf-warn); }
|
|
549
|
+
.cf-scroll { max-height:420px; overflow:auto; border:1px solid var(--cf-line); border-radius:8px; }
|
|
550
|
+
.cf-table { border-collapse:collapse; width:100%; }
|
|
551
|
+
.cf-table th { position:sticky; top:0; background:var(--cf-raise); font-weight:500; text-align:left; font-size:12px; }
|
|
552
|
+
.cf-table th, .cf-table td { padding:6px 10px; border-bottom:1px solid var(--cf-line); }
|
|
553
|
+
.cf-table tbody tr { cursor:pointer; }
|
|
554
|
+
.cf-table tbody tr:hover { background:var(--cf-raise); }
|
|
555
|
+
.cf-table tr.cf-sel { background:var(--cf-band); }
|
|
556
|
+
.cf-num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
|
557
|
+
.cf-compact td { padding:5px 6px; font-size:12px; cursor:default; }
|
|
558
|
+
.cf-src { color:var(--cf-muted); font-size:11px; overflow-wrap:anywhere; }
|
|
559
|
+
.cf-ledger { border:1px solid var(--cf-line); border-radius:8px; padding:12px 14px; background:var(--cf-surface); position:sticky; top:12px; }
|
|
560
|
+
.cf-ledger-title { font-weight:500; display:flex; flex-direction:column; margin-bottom:10px; }
|
|
561
|
+
.cf-ledger-title span { color:var(--cf-muted); font-weight:400; font-size:12px; }
|
|
562
|
+
.cf-range { position:relative; height:10px; background:var(--cf-raise); border-radius:5px; margin:6px 0 2px; }
|
|
563
|
+
.cf-range span { position:absolute; top:0; bottom:0; background:var(--cf-band); border-radius:5px; }
|
|
564
|
+
.cf-range i { position:absolute; top:-3px; width:3px; height:16px; background:var(--cf-series); border-radius:2px; transform:translateX(-50%); }
|
|
565
|
+
.cf-range-labels { display:flex; justify-content:space-between; font-size:11px; color:var(--cf-muted); margin-bottom:8px; }
|
|
566
|
+
.cf-range-labels b { color:var(--cf-ink); font-weight:500; }
|
|
567
|
+
.cf-tip { position:fixed; pointer-events:none; background:var(--cf-ink); color:var(--cf-surface); font-size:12px; padding:5px 8px; border-radius:6px; max-width:320px; z-index:1000; opacity:0; transition:opacity .08s; }
|
|
568
|
+
.cf-error { border:1px solid var(--cf-line); border-left:3px solid #d03b3b; border-radius:8px; padding:10px 12px; margin-top:12px; }
|
|
569
|
+
.cf-blank { color:var(--cf-muted); margin-top:24px; }
|
|
570
|
+
`;
|
|
571
|
+
|
|
572
|
+
function categoryOf(kind) {
|
|
573
|
+
return CATEGORIES.find((c) => c.kinds.includes(kind))?.key ?? 'passes';
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function mountForecast(element, { load, refreshMs = REFRESH_MS, theme = 'light' }) {
|
|
577
|
+
const state = { workflows: [], category: null, workflow: null, run: null, selected: null, error: null, loaded: false };
|
|
578
|
+
element.innerHTML = '';
|
|
579
|
+
const root = document.createElement('div');
|
|
580
|
+
root.className = 'cf';
|
|
581
|
+
root.dataset.theme = theme;
|
|
582
|
+
const style = document.createElement('style');
|
|
583
|
+
style.textContent = STYLE;
|
|
584
|
+
const tipEl = document.createElement('div');
|
|
585
|
+
tipEl.className = 'cf-tip';
|
|
586
|
+
element.append(style, root, tipEl);
|
|
587
|
+
|
|
588
|
+
async function refresh() {
|
|
589
|
+
try {
|
|
590
|
+
const index = await load('index.json');
|
|
591
|
+
state.workflows = index.workflows || [];
|
|
592
|
+
if (!state.workflow || !state.workflows.some((w) => w.workflow === state.workflow)) {
|
|
593
|
+
const first = CATEGORIES.map((c) => state.workflows.find((w) => c.kinds.includes(w.kind))).find(Boolean) || state.workflows[0];
|
|
594
|
+
state.workflow = first?.workflow ?? null;
|
|
595
|
+
state.category = first ? categoryOf(first.kind) : null;
|
|
596
|
+
}
|
|
597
|
+
if (state.workflow) state.run = await load(`workflow-${state.workflow}.json`);
|
|
598
|
+
state.error = null;
|
|
599
|
+
} catch (err) {
|
|
600
|
+
const text = err?.message ?? String(err);
|
|
601
|
+
// A 502, 503 or 504 is the host restarting or its proxy starting up: say so, keep what is
|
|
602
|
+
// shown, and the next refresh clears it.
|
|
603
|
+
state.error = /404|none yet|not found/i.test(text) ? null : /\b50[234]\b/.test(text) ? 'restarting' : text;
|
|
604
|
+
}
|
|
605
|
+
state.loaded = true;
|
|
606
|
+
draw();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function draw() {
|
|
610
|
+
const { workflows, run, selected, error, category } = state;
|
|
611
|
+
const counts = Object.fromEntries(CATEGORIES.map((c) => [c.key, workflows.filter((w) => c.kinds.includes(w.kind)).length]));
|
|
612
|
+
const cats = CATEGORIES.map(
|
|
613
|
+
(c) => `<button role="tab" data-category="${c.key}" aria-selected="${c.key === category}" ${counts[c.key] ? '' : 'disabled'}>${c.label}<span class="cf-count">${counts[c.key]}</span></button>`,
|
|
614
|
+
).join('');
|
|
615
|
+
const inCategory = workflows.filter((w) => categoryOf(w.kind) === category);
|
|
616
|
+
const flows =
|
|
617
|
+
inCategory.length > 1
|
|
618
|
+
? `<div class="cf-flows">${inCategory.map((w) => `<button data-workflow="${esc(w.workflow)}" aria-pressed="${w.workflow === state.workflow}">${esc(TITLES[w.kind] ?? w.kind)} · ${esc(w.workflow)}</button>`).join('')}</div>`
|
|
619
|
+
: '';
|
|
620
|
+
let body = '';
|
|
621
|
+
if (!state.loaded) body = '<p class="cf-blank">Loading forecasts…</p>';
|
|
622
|
+
else if (!workflows.length) body = '<p class="cf-blank">No forecasts yet. They appear here after the next workflow run reaches this system.</p>';
|
|
623
|
+
else if (run) {
|
|
624
|
+
const items = upcoming(run);
|
|
625
|
+
const blocks = run.blocks || ['tiles', 'list', 'ledger'];
|
|
626
|
+
const main = blocks
|
|
627
|
+
.filter((b) => b !== 'ledger')
|
|
628
|
+
.map((b) => (VIEWS[b] ? VIEWS[b](run, items, selected) : `<p class="cf-note">This section needs a newer Constellation Forecast view (${esc(b)}).</p>`))
|
|
629
|
+
.join('');
|
|
630
|
+
const side = blocks.includes('ledger') ? VIEWS.ledger(run, items, selected) : '';
|
|
631
|
+
body = `<p class="cf-meta">${esc(TITLES[run.kind] ?? run.kind)} · workflow ${esc(run.workflow)} v${esc(run.workflow_version)} · run ${esc(run.run_id)} · delivered ${ago(run.delivered_at)}</p>${flows}<div class="cf-grid2"><div>${main}</div><div>${side}</div></div>`;
|
|
632
|
+
}
|
|
633
|
+
root.innerHTML = `<div class="cf-head"><h2>Constellation Forecast</h2><span class="cf-forecast">Forecast · not scheduled contacts</span></div>
|
|
634
|
+
<p class="cf-meta">Predictions from Constellation workflows. Measured telemetry lives in its usual place.</p>
|
|
635
|
+
${error === 'restarting' ? '<p class="cf-note">Waiting for the host to come back; this refreshes on its own.</p>' : error ? `<div class="cf-error">Could not read the forecasts: ${esc(error)}</div>` : ''}
|
|
636
|
+
${workflows.length ? `<div class="cf-cats" role="tablist">${cats}</div>` : ''}${body}`;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
root.addEventListener('click', (e) => {
|
|
640
|
+
const cat = e.target.closest('[data-category]');
|
|
641
|
+
if (cat) {
|
|
642
|
+
state.category = cat.dataset.category;
|
|
643
|
+
const first = state.workflows.find((w) => categoryOf(w.kind) === state.category);
|
|
644
|
+
state.workflow = first?.workflow ?? null;
|
|
645
|
+
state.selected = null;
|
|
646
|
+
return void refresh();
|
|
647
|
+
}
|
|
648
|
+
const flow = e.target.closest('[data-workflow]');
|
|
649
|
+
if (flow) {
|
|
650
|
+
state.workflow = flow.dataset.workflow;
|
|
651
|
+
state.selected = null;
|
|
652
|
+
return void refresh();
|
|
653
|
+
}
|
|
654
|
+
const row = e.target.closest('tr[data-id]');
|
|
655
|
+
if (row) {
|
|
656
|
+
state.selected = row.dataset.id;
|
|
657
|
+
draw();
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
root.addEventListener('mousemove', (e) => {
|
|
661
|
+
const target = e.target.closest('[data-tip]');
|
|
662
|
+
if (!target) return void (tipEl.style.opacity = '0');
|
|
663
|
+
tipEl.textContent = target.getAttribute('data-tip');
|
|
664
|
+
tipEl.style.left = `${Math.min(e.clientX + 12, window.innerWidth - 330)}px`;
|
|
665
|
+
tipEl.style.top = `${e.clientY + 14}px`;
|
|
666
|
+
tipEl.style.opacity = '1';
|
|
667
|
+
});
|
|
668
|
+
root.addEventListener('mouseleave', () => (tipEl.style.opacity = '0'));
|
|
669
|
+
|
|
670
|
+
refresh();
|
|
671
|
+
const timer = setInterval(refresh, refreshMs);
|
|
672
|
+
return { destroy: () => clearInterval(timer), refresh };
|
|
673
|
+
}
|