Merge pull request 'feat(reports): FTP night-report pipeline foundation' (#62) from feat/ftp-report-pipeline into dev

Reviewed-on: #62
This commit was merged in pull request #62.
This commit is contained in:
2026-06-11 23:27:34 -04:00
13 changed files with 2398 additions and 148 deletions
@@ -74,6 +74,22 @@
</svg>
Generate Combined Report
</a>
<button onclick="openNightReportModal()"
title="Last night's noise vs baseline, per location (FTP report pipeline)"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2 text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
</svg>
Night Report
</button>
<button onclick="openReportSettings('{{ project.id }}')"
title="Nightly report settings — schedule, baseline range, recipients"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
{% endif %}
<button onclick="openMergeModal()"
title="Merge this project into another (consolidates duplicates)"
@@ -87,6 +103,338 @@
</div>
</div>
<!-- Night Report Modal -->
<div id="night-report-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Night Report</h3>
<button onclick="closeNightReportModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<div class="px-6 py-5 space-y-4">
<p class="text-xs text-gray-500 dark:text-gray-400">Last night's noise (7&nbsp;PM7&nbsp;AM) vs a baseline range, per location. Opens in a new tab.</p>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Night (evening date)</label>
<input type="date" id="nr-night-date" 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 class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baseline start <span class="text-gray-400 font-normal">(optional)</span></label>
<input type="date" id="nr-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>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baseline end</label>
<input type="date" id="nr-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 class="flex items-center justify-between mb-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Recent reports</label>
<span id="nr-recent-count" class="text-xs text-gray-400"></span>
</div>
<div id="nr-recent" class="max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
<div class="px-3 py-2 text-xs text-gray-400">Loading…</div>
</div>
</div>
<p id="nr-status" class="text-xs"></p>
</div>
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
<button onclick="closeNightReportModal()" class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm">Cancel</button>
<button onclick="runNightReport('{{ project.id }}')" class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors text-sm">Run &amp; Email</button>
<button onclick="viewNightReport('{{ project.id }}')" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm">View Report</button>
</div>
</div>
</div>
<script>
var NR_PROJECT_ID = '{{ project.id }}';
function openNightReportModal() {
var el = document.getElementById('nr-night-date');
if (el && !el.value) { // default to last night
var d = new Date(); d.setDate(d.getDate() - 1);
el.value = d.getFullYear() + '-'
+ String(d.getMonth() + 1).padStart(2, '0') + '-'
+ String(d.getDate()).padStart(2, '0');
}
document.getElementById('nr-status').textContent = '';
document.getElementById('night-report-modal').classList.remove('hidden');
loadRecentReports(NR_PROJECT_ID);
}
function closeNightReportModal() {
document.getElementById('night-report-modal').classList.add('hidden');
}
function _nrParams() {
var night = document.getElementById('nr-night-date').value;
var bs = document.getElementById('nr-baseline-start').value;
var be = document.getElementById('nr-baseline-end').value;
if (!night) { alert('Pick a night (evening date).'); return null; }
if ((bs && !be) || (be && !bs)) { alert('Provide both baseline dates, or leave both empty.'); return null; }
var qs = 'night_date=' + night;
if (bs && be) qs += '&baseline_start=' + bs + '&baseline_end=' + be;
return qs;
}
function viewNightReport(projectId) {
var qs = _nrParams(); if (!qs) return;
window.open('/api/projects/' + projectId + '/reports/nightly/view?' + qs, '_blank');
}
function runNightReport(projectId) {
var qs = _nrParams(); if (!qs) return;
var st = document.getElementById('nr-status');
st.style.color = ''; st.textContent = 'Running…';
fetch('/api/projects/' + projectId + '/reports/nightly/run?' + qs + '&send=true', { method: 'POST' })
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (res) {
if (!res.ok) { st.style.color = '#b00020'; st.textContent = 'Error: ' + (res.j.detail || 'run failed'); return; }
var em = res.j.email || {};
var emailMsg = em.sent ? 'emailed' : (em.dry_run ? 'email dry-run (SMTP not set)' : (em.error || 'email skipped'));
st.style.color = '#1a7f37';
st.innerHTML = 'Done — saved &amp; ' + _mergeEsc(emailMsg) + '. <a href="' + _mergeEsc(res.j.view_url) + '" target="_blank" class="underline">view</a>';
loadRecentReports(projectId);
})
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
}
function loadRecentReports(projectId) {
var box = document.getElementById('nr-recent');
var cnt = document.getElementById('nr-recent-count');
fetch('/api/projects/' + projectId + '/reports/list')
.then(function (r) { return r.json(); })
.then(function (j) {
cnt.textContent = (j.count || 0) + ' generated';
if (!j.reports || !j.reports.length) {
box.innerHTML = '<div class="px-3 py-2 text-xs text-gray-400">None yet. Run one above.</div>';
return;
}
box.innerHTML = j.reports.map(function (rp) {
var when = (rp.generated_at || '').replace('T', ' ').slice(0, 16);
var xlsx = rp.xlsx_url ? ' · <a href="' + _mergeEsc(rp.xlsx_url) + '" class="text-indigo-600 dark:text-indigo-400 hover:underline">Excel</a>' : '';
return '<div class="flex items-center justify-between px-3 py-2 text-sm">'
+ '<a href="' + _mergeEsc(rp.view_url) + '" target="_blank" class="font-medium text-gray-800 dark:text-gray-200 hover:underline">Night of ' + _mergeEsc(rp.night_date) + '</a>'
+ '<span class="text-xs text-gray-400">' + _mergeEsc(when) + ' UTC' + xlsx + '</span></div>';
}).join('');
})
.catch(function () { box.innerHTML = '<div class="px-3 py-2 text-xs text-red-500">Failed to load.</div>'; });
}
</script>
<!-- Nightly Report Settings Modal -->
<div id="report-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Nightly Report Settings</h3>
<button onclick="closeReportSettings()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<div class="px-6 py-5 space-y-4">
<div id="rs-schedule-status" class="text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/40 rounded-lg px-3 py-2"></div>
<label class="flex items-center gap-2 text-sm font-medium text-gray-800 dark:text-gray-200">
<input type="checkbox" id="rs-enabled" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
Email the report automatically each morning
</label>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Report time (local)</label>
<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>
</div>
<div>
<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">
</div>
<div>
<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">
</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>
<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">
<p class="text-xs text-gray-400 mt-1">Comma list. Options: lmax, l01, l10, l50, l90, l95, leq.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Recipients</label>
<input type="text" id="rs-recipients" placeholder="brian@…, dad@…" 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">Comma list. Blank → the default SMTP recipients.</p>
</div>
<div>
<button type="button" onclick="sendTestEmail('{{ project.id }}')" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline">Send test email</button>
<span id="rs-test-status" class="text-xs ml-2"></span>
</div>
<p id="rs-status" class="text-xs"></p>
</div>
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
<button onclick="closeReportSettings()" class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm">Cancel</button>
<button onclick="saveReportSettings('{{ project.id }}')" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm">Save</button>
</div>
</div>
</div>
<script>
function openReportSettings(projectId) {
var show = function () { document.getElementById('report-settings-modal').classList.remove('hidden'); };
document.getElementById('rs-status').textContent = '';
fetch('/api/projects/' + projectId + '/reports/config')
.then(function (r) { return r.json(); })
.then(function (c) {
document.getElementById('rs-enabled').checked = !!c.enabled;
document.getElementById('rs-report-time').value = c.report_time || '08:00';
document.getElementById('rs-baseline-start').value = c.baseline_start || '';
document.getElementById('rs-baseline-end').value = c.baseline_end || '';
document.getElementById('rs-metrics').value = c.metric_keys || 'lmax,l01,l10,l90';
document.getElementById('rs-recipients').value = c.recipients || '';
var ss = document.getElementById('rs-schedule-status');
var last = c.last_run_date || '—';
if (c.enabled) {
ss.innerHTML = '<span style="color:#1a7f37">●</span> Automatic — runs daily at ' + (c.report_time || '08:00') + '. Last reported night: ' + last + '.';
} else {
ss.innerHTML = '<span style="color:#9ca3af">●</span> Automatic sending is off. Last reported night: ' + last + '.';
}
document.getElementById('rs-test-status').textContent = '';
rsSetMode(c.baseline_mode || 'captured');
loadBaselineEditor(projectId);
show();
})
.catch(show);
}
function closeReportSettings() {
document.getElementById('report-settings-modal').classList.add('hidden');
}
function saveReportSettings(projectId) {
var st = document.getElementById('rs-status');
var mode = rsGetMode();
var bs = document.getElementById('rs-baseline-start').value;
var be = document.getElementById('rs-baseline-end').value;
if (mode === 'captured' && ((bs && !be) || (be && !bs))) {
st.style.color = '#b00020'; st.textContent = 'Provide both baseline dates, or neither.'; return;
}
var body = {
enabled: document.getElementById('rs-enabled').checked,
report_time: document.getElementById('rs-report-time').value || '08:00',
metric_keys: document.getElementById('rs-metrics').value || 'lmax,l01,l10,l90',
baseline_mode: mode,
baseline_start: bs || null,
baseline_end: be || null,
recipients: document.getElementById('rs-recipients').value || ''
};
st.style.color = ''; st.textContent = 'Saving…';
fetch('/api/projects/' + projectId + '/reports/config', {
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 (res) {
if (!res.ok) { st.style.color = '#b00020'; st.textContent = 'Error: ' + (res.j.detail || 'save failed'); return; }
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; });
}
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="' + _mergeEsc(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">' + _mergeEsc(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) {
var st = document.getElementById('rs-test-status');
st.style.color = ''; st.textContent = 'Sending…';
var recips = document.getElementById('rs-recipients').value;
fetch('/api/projects/' + projectId + '/reports/test-email', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recips ? { recipients: recips } : {})
}).then(function (r) { return r.json(); })
.then(function (j) {
if (j.sent) { st.style.color = '#1a7f37'; st.textContent = 'Sent to ' + (j.recipients || []).join(', '); }
else if (j.dry_run) { st.style.color = '#b8860b'; st.textContent = 'Dry-run (SMTP not set) — would send to ' + (j.recipients || []).join(', '); }
else { st.style.color = '#b00020'; st.textContent = 'Error: ' + (j.error || 'failed'); }
})
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
}
</script>
<!-- Merge Modal —
min-h on the body ensures the typeahead dropdown has room to render
below the input without forcing the operator to scroll inside the