feat(reports): FTP night-report pipeline foundation #62

Merged
serversdown merged 16 commits from feat/ftp-report-pipeline into dev 2026-06-11 23:27:35 -04:00
Showing only changes of commit 1d49b54bd1 - Show all commits
+103 -6
View File
@@ -235,16 +235,28 @@ function loadRecentReports(projectId) {
<input type="time" id="rs-report-time" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm"> <input type="time" id="rs-report-time" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<p class="text-xs text-gray-400 mt-1">Runs after this time for the night that just ended.</p> <p class="text-xs text-gray-400 mt-1">Runs after this time for the night that just ended.</p>
</div> </div>
<div class="grid grid-cols-2 gap-3">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baseline start</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baseline source</label>
<div class="flex gap-4 text-sm mb-2">
<label class="flex items-center gap-1.5"><input type="radio" name="rs-baseline-mode" value="captured" onchange="rsToggleBaselineMode()" class="text-indigo-600"> Captured nights</label>
<label class="flex items-center gap-1.5"><input type="radio" name="rs-baseline-mode" value="reference" onchange="rsToggleBaselineMode()" class="text-indigo-600"> Fixed values</label>
</div>
<div id="rs-baseline-captured" class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Range start</label>
<input type="date" id="rs-baseline-start" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm"> <input type="date" id="rs-baseline-start" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baseline end</label> <label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Range end</label>
<input type="date" id="rs-baseline-end" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm"> <input type="date" id="rs-baseline-end" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
</div> </div>
</div> </div>
<div id="rs-baseline-reference" class="hidden">
<p class="text-xs text-gray-400 mb-2">Values to compare against (a spec limit like L10 = 85, or a prior report's averages). Blank cells aren't compared.</p>
<div class="flex justify-end mb-1"><button type="button" onclick="rsCopyFirstNrl()" class="text-xs text-indigo-600 dark:text-indigo-400 hover:underline">Copy first NRL → all</button></div>
<div id="rs-ref-grid" class="space-y-3"></div>
</div>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Metrics</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Metrics</label>
<input type="text" id="rs-metrics" placeholder="lmax,l01,l10,l90" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm"> <input type="text" id="rs-metrics" placeholder="lmax,l01,l10,l90" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
@@ -288,6 +300,8 @@ function openReportSettings(projectId) {
ss.innerHTML = '<span style="color:#9ca3af">●</span> Automatic sending is off. Last reported night: ' + last + '.'; ss.innerHTML = '<span style="color:#9ca3af">●</span> Automatic sending is off. Last reported night: ' + last + '.';
} }
document.getElementById('rs-test-status').textContent = ''; document.getElementById('rs-test-status').textContent = '';
rsSetMode(c.baseline_mode || 'captured');
loadBaselineEditor(projectId);
show(); show();
}) })
.catch(show); .catch(show);
@@ -297,15 +311,17 @@ function closeReportSettings() {
} }
function saveReportSettings(projectId) { function saveReportSettings(projectId) {
var st = document.getElementById('rs-status'); var st = document.getElementById('rs-status');
var mode = rsGetMode();
var bs = document.getElementById('rs-baseline-start').value; var bs = document.getElementById('rs-baseline-start').value;
var be = document.getElementById('rs-baseline-end').value; var be = document.getElementById('rs-baseline-end').value;
if ((bs && !be) || (be && !bs)) { if (mode === 'captured' && ((bs && !be) || (be && !bs))) {
st.style.color = '#b00020'; st.textContent = 'Provide both baseline dates, or neither.'; return; st.style.color = '#b00020'; st.textContent = 'Provide both baseline dates, or neither.'; return;
} }
var body = { var body = {
enabled: document.getElementById('rs-enabled').checked, enabled: document.getElementById('rs-enabled').checked,
report_time: document.getElementById('rs-report-time').value || '08:00', report_time: document.getElementById('rs-report-time').value || '08:00',
metric_keys: document.getElementById('rs-metrics').value || 'lmax,l01,l10,l90', metric_keys: document.getElementById('rs-metrics').value || 'lmax,l01,l10,l90',
baseline_mode: mode,
baseline_start: bs || null, baseline_start: bs || null,
baseline_end: be || null, baseline_end: be || null,
recipients: document.getElementById('rs-recipients').value || '' recipients: document.getElementById('rs-recipients').value || ''
@@ -315,11 +331,92 @@ function saveReportSettings(projectId) {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
}).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); }) }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (res) { .then(function (res) {
if (res.ok) { st.style.color = '#1a7f37'; st.textContent = 'Saved.'; setTimeout(closeReportSettings, 700); } if (!res.ok) { st.style.color = '#b00020'; st.textContent = 'Error: ' + (res.j.detail || 'save failed'); return; }
else { st.style.color = '#b00020'; st.textContent = 'Error: ' + (res.j.detail || 'save failed'); } if (mode === 'reference') {
return fetch('/api/projects/' + projectId + '/reports/baseline', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations: gatherRefValues() })
}).then(function (r2) {
if (!r2.ok) throw new Error('baseline values failed to save');
st.style.color = '#1a7f37'; st.textContent = 'Saved.'; setTimeout(closeReportSettings, 700);
});
}
st.style.color = '#1a7f37'; st.textContent = 'Saved.'; setTimeout(closeReportSettings, 700);
}) })
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; }); .catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
} }
var RS_BASELINE = { metrics: [], windows: [], locations: [] };
function rsGetMode() {
var r = document.querySelector('input[name="rs-baseline-mode"]:checked');
return r ? r.value : 'captured';
}
function rsSetMode(mode) {
document.querySelectorAll('input[name="rs-baseline-mode"]').forEach(function (el) { el.checked = (el.value === mode); });
rsToggleBaselineMode();
}
function rsToggleBaselineMode() {
var ref = rsGetMode() === 'reference';
document.getElementById('rs-baseline-captured').classList.toggle('hidden', ref);
document.getElementById('rs-baseline-reference').classList.toggle('hidden', !ref);
}
function loadBaselineEditor(projectId) {
fetch('/api/projects/' + projectId + '/reports/baseline')
.then(function (r) { return r.json(); })
.then(function (d) { RS_BASELINE = d; renderRefGrid(); })
.catch(function () {});
}
function _refId(loc, w, m) { return 'ref__' + loc + '__' + w + '__' + m; }
function renderRefGrid() {
var box = document.getElementById('rs-ref-grid');
if (!RS_BASELINE.locations || !RS_BASELINE.locations.length) {
box.innerHTML = '<div class="text-xs text-gray-400">No NRLs in this project yet.</div>'; return;
}
var W = RS_BASELINE.windows, M = RS_BASELINE.metrics;
box.innerHTML = RS_BASELINE.locations.map(function (loc) {
var head = '<tr><th></th>' + W.map(function (w) {
return '<th class="text-xs text-gray-400 font-normal pb-1 px-1">' + w.label.replace(/\s*\(.*\)/, '') + '</th>';
}).join('') + '</tr>';
var rows = M.map(function (m) {
var cells = W.map(function (w) {
var v = (loc.values[w.key] && loc.values[w.key][m.key] != null) ? loc.values[w.key][m.key] : '';
return '<td class="px-1"><input type="number" step="0.1" id="' + _refId(loc.id, w.key, m.key) + '" value="' + v + '" class="w-16 px-1.5 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm text-center"></td>';
}).join('');
return '<tr><td class="text-sm text-gray-700 dark:text-gray-300 pr-2">' + m.label + '</td>' + cells + '</tr>';
}).join('');
return '<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-2">'
+ '<div class="text-sm font-medium text-gray-800 dark:text-gray-200 mb-1">' + loc.name + '</div>'
+ '<table class="w-full">' + head + rows + '</table></div>';
}).join('');
}
function gatherRefValues() {
var out = {};
(RS_BASELINE.locations || []).forEach(function (loc) {
var wins = {};
RS_BASELINE.windows.forEach(function (w) {
var mv = {};
RS_BASELINE.metrics.forEach(function (m) {
var el = document.getElementById(_refId(loc.id, w.key, m.key));
if (el && el.value !== '') mv[m.key] = el.value;
});
if (Object.keys(mv).length) wins[w.key] = mv;
});
out[loc.id] = wins;
});
return out;
}
function rsCopyFirstNrl() {
if (!RS_BASELINE.locations || RS_BASELINE.locations.length < 2) return;
var first = RS_BASELINE.locations[0].id;
RS_BASELINE.locations.slice(1).forEach(function (loc) {
RS_BASELINE.windows.forEach(function (w) {
RS_BASELINE.metrics.forEach(function (m) {
var src = document.getElementById(_refId(first, w.key, m.key));
var dst = document.getElementById(_refId(loc.id, w.key, m.key));
if (src && dst) dst.value = src.value;
});
});
});
}
function sendTestEmail(projectId) { function sendTestEmail(projectId) {
var st = document.getElementById('rs-test-status'); var st = document.getElementById('rs-test-status');
st.style.color = ''; st.textContent = 'Sending…'; st.style.color = ''; st.textContent = 'Sending…';