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
2 changed files with 169 additions and 10 deletions
Showing only changes of commit 7fb4ba0343 - Show all commits
+81 -3
View File
@@ -17,10 +17,12 @@ baseline-week range to populate the comparison.
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime, timedelta, date import re
from typing import Optional
import uuid import uuid
from datetime import datetime, timedelta, date
from html import escape
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -202,3 +204,79 @@ async def run_nightly_report_endpoint(
+ (f"&metrics={','.join(metric_keys)}") + (f"&metrics={','.join(metric_keys)}")
) )
return result return result
# ============================================================================
# Test email + generated-report archive
# ============================================================================
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
@router.post("/test-email")
async def send_test_email(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Send a small test email to verify the SMTP relay (dry-run if unconfigured).
Recipients: JSON body {"recipients": "..."} overrides; else the project's
configured recipients; else the REPORT_SMTP_RECIPIENTS env default.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
try:
data = await request.json()
except Exception:
data = {}
raw = (data or {}).get("recipients")
if not raw:
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
raw = cfg.recipients if cfg else None
recipients = None
if raw:
if isinstance(raw, list):
raw = ",".join(raw)
recipients = [r.strip() for r in raw.split(",") if r.strip()]
from backend.services.report_email import send_report_email
body = (
"<div style=\"font:14px Arial,sans-serif\">"
f"Terra-View test email for <b>{escape(project.name)}</b>.<br>"
"If you got this, the nightly sound-report email path is working.</div>"
)
return send_report_email("Terra-View — nightly report test email", body, recipients=recipients)
@router.get("/list")
async def list_reports(project_id: str, db: Session = Depends(get_db)):
"""List the generated report artifacts on disk for this project (newest first)."""
if not db.query(Project).filter_by(id=project_id).first():
raise HTTPException(status_code=404, detail="Project not found")
base = Path("data/reports") / project_id
out = []
if base.exists():
for d in sorted((p for p in base.iterdir() if p.is_dir()), key=lambda p: p.name, reverse=True):
html_file = d / "report.html"
if html_file.exists():
st = html_file.stat()
out.append({
"night_date": d.name,
"view_url": f"/api/projects/{project_id}/reports/archive/{d.name}",
"size_bytes": st.st_size,
"generated_at": datetime.utcfromtimestamp(st.st_mtime).isoformat(),
})
return {"reports": out, "count": len(out)}
@router.get("/archive/{night_date}", response_class=HTMLResponse)
async def view_archived_report(project_id: str, night_date: str, db: Session = Depends(get_db)):
"""Serve a previously generated report.html from disk (the actual artifact)."""
if not db.query(Project).filter_by(id=project_id).first():
raise HTTPException(status_code=404, detail="Project not found")
if not _DATE_RE.match(night_date):
raise HTTPException(status_code=400, detail="Invalid date (YYYY-MM-DD)")
safe = _parse_date(night_date, "night_date") # also guards path traversal
path = Path("data/reports") / project_id / f"{safe:%Y-%m-%d}" / "report.html"
if not path.exists():
raise HTTPException(status_code=404, detail="No saved report for that date")
return HTMLResponse(path.read_text(encoding="utf-8"))
@@ -128,14 +128,26 @@
<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"> <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>
<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>
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2"> <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="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> <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> </div>
</div> </div>
<script> <script>
var NR_PROJECT_ID = '{{ project.id }}';
function openNightReportModal() { function openNightReportModal() {
var el = document.getElementById('nr-night-date'); var el = document.getElementById('nr-night-date');
if (el && !el.value) { // default to last night if (el && !el.value) { // default to last night
@@ -144,21 +156,62 @@ function openNightReportModal() {
+ String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-'
+ String(d.getDate()).padStart(2, '0'); + String(d.getDate()).padStart(2, '0');
} }
document.getElementById('nr-status').textContent = '';
document.getElementById('night-report-modal').classList.remove('hidden'); document.getElementById('night-report-modal').classList.remove('hidden');
loadRecentReports(NR_PROJECT_ID);
} }
function closeNightReportModal() { function closeNightReportModal() {
document.getElementById('night-report-modal').classList.add('hidden'); document.getElementById('night-report-modal').classList.add('hidden');
} }
function viewNightReport(projectId) { function _nrParams() {
var night = document.getElementById('nr-night-date').value; var night = document.getElementById('nr-night-date').value;
var bs = document.getElementById('nr-baseline-start').value; var bs = document.getElementById('nr-baseline-start').value;
var be = document.getElementById('nr-baseline-end').value; var be = document.getElementById('nr-baseline-end').value;
if (!night) { alert('Pick a night (evening date).'); return; } 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; } if ((bs && !be) || (be && !bs)) { alert('Provide both baseline dates, or leave both empty.'); return null; }
var url = '/api/projects/' + projectId + '/reports/nightly/view?night_date=' + night; var qs = 'night_date=' + night;
if (bs && be) url += '&baseline_start=' + bs + '&baseline_end=' + be; if (bs && be) qs += '&baseline_start=' + bs + '&baseline_end=' + be;
window.open(url, '_blank'); return qs;
closeNightReportModal(); }
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; ' + emailMsg + '. <a href="' + 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);
return '<a href="' + rp.view_url + '" target="_blank" class="flex items-center justify-between px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700">'
+ '<span class="font-medium text-gray-800 dark:text-gray-200">Night of ' + rp.night_date + '</span>'
+ '<span class="text-xs text-gray-400">' + when + ' UTC</span></a>';
}).join('');
})
.catch(function () { box.innerHTML = '<div class="px-3 py-2 text-xs text-red-500">Failed to load.</div>'; });
} }
</script> </script>
@@ -172,6 +225,7 @@ function viewNightReport(projectId) {
</button> </button>
</div> </div>
<div class="px-6 py-5 space-y-4"> <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"> <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"> <input type="checkbox" id="rs-enabled" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
Email the report automatically each morning Email the report automatically each morning
@@ -201,6 +255,10 @@ function viewNightReport(projectId) {
<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"> <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> <p class="text-xs text-gray-400 mt-1">Comma list. Blank → the default SMTP recipients.</p>
</div> </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> <p id="rs-status" class="text-xs"></p>
</div> </div>
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2"> <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
@@ -222,6 +280,14 @@ function openReportSettings(projectId) {
document.getElementById('rs-baseline-end').value = c.baseline_end || ''; document.getElementById('rs-baseline-end').value = c.baseline_end || '';
document.getElementById('rs-metrics').value = c.metric_keys || 'lmax,l01,l10,l90'; document.getElementById('rs-metrics').value = c.metric_keys || 'lmax,l01,l10,l90';
document.getElementById('rs-recipients').value = c.recipients || ''; 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 = '';
show(); show();
}) })
.catch(show); .catch(show);
@@ -254,6 +320,21 @@ function saveReportSettings(projectId) {
}) })
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; }); .catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
} }
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> </script>
<!-- Merge Modal — <!-- Merge Modal —