Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
178 lines
7.9 KiB
HTML
178 lines
7.9 KiB
HTML
{% extends "templates/web.html" %}
|
|
|
|
{% block page_content %}
|
|
<style>
|
|
.pw-cdr-wrap { max-width: 860px; margin: 0 auto; }
|
|
.pw-cdr-wrap h1 { color: #1e3a5f; margin: 0 0 1rem; }
|
|
.pw-cdr-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 1.25rem 1.5rem; margin: 1rem 0; }
|
|
.pw-drop {
|
|
border: 2px dashed #cbd5e1; border-radius: 12px; padding: 2rem 1rem;
|
|
text-align: center; color: #475569; background: #f8fafc;
|
|
}
|
|
.pw-drop.drag { border-color: #059669; background: #ecfdf5; color: #065f46; }
|
|
.pw-lock-banner {
|
|
background: #fef3c7; border-left: 4px solid #b45309;
|
|
padding: 1rem 1.25rem; border-radius: 0 8px 8px 0; margin: 1rem 0;
|
|
}
|
|
.pw-unlock-banner {
|
|
background: #ecfdf5; border-left: 4px solid #059669;
|
|
padding: 1rem 1.25rem; border-radius: 0 8px 8px 0; margin: 1rem 0;
|
|
}
|
|
.pw-stat-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
|
.pw-stat { flex: 1 1 180px; background: #f8fafc; padding: 1rem; border-radius: 8px; }
|
|
.pw-stat strong { font-size: 1.4rem; color: #1e3a5f; display: block; }
|
|
.pw-btn { padding: .6rem 1.25rem; border: 0; border-radius: 6px; background: #059669; color: #fff; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-block; }
|
|
</style>
|
|
|
|
<div class="pw-cdr-wrap">
|
|
<h1>CDR Upload & Traffic Study</h1>
|
|
|
|
{% if not profile %}
|
|
<div class="pw-cdr-card">
|
|
<p>No CDR ingestion profile yet.</p>
|
|
<p><a href="/cdr-settings" class="pw-btn">Set up ingestion →</a></p>
|
|
</div>
|
|
{% else %}
|
|
<div class="pw-cdr-card">
|
|
<h2 style="color:#1e3a5f; font-size:1.2rem; margin:0 0 .75rem;">Drop a CDR file</h2>
|
|
<div id="pw_drop" class="pw-drop">
|
|
<p>Drag & drop a CDR (CSV, NDJSON, CSV.gz) here, or click to choose.</p>
|
|
<input id="pw_file" type="file" style="display:none" accept=".csv,.csv.gz,.ndjson,.json,.gz,.txt">
|
|
<button class="pw-btn" onclick="document.getElementById('pw_file').click()">Choose file</button>
|
|
</div>
|
|
<p style="margin-top:1rem; font-size:.85rem; color:#64748b;">
|
|
Upload goes straight to encrypted storage scoped to your account.
|
|
Processing starts within a minute. Files > 500 MB: use the
|
|
secure file drop (SFTP/FTPS) from <a href="/cdr-settings">settings</a>.
|
|
</p>
|
|
</div>
|
|
|
|
<div id="pw_study" class="pw-cdr-card">
|
|
<h2 style="color:#1e3a5f; font-size:1.2rem; margin:0 0 .75rem;">
|
|
{{ reporting_year }} traffic study
|
|
</h2>
|
|
<div id="pw_study_body">Loading…</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const profileId = {{ profile.id if profile else "null" }};
|
|
const year = {{ reporting_year }};
|
|
if (!profileId) return;
|
|
|
|
// ── Drag-drop upload ──────────────────────────────────────────────
|
|
const drop = document.getElementById("pw_drop");
|
|
const fileInput = document.getElementById("pw_file");
|
|
|
|
["dragenter","dragover"].forEach((e) =>
|
|
drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.add("drag"); }));
|
|
["dragleave","drop"].forEach((e) =>
|
|
drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.remove("drag"); }));
|
|
drop.addEventListener("drop", (ev) => { if (ev.dataTransfer.files[0]) uploadFile(ev.dataTransfer.files[0]); });
|
|
fileInput.addEventListener("change", () => { if (fileInput.files[0]) uploadFile(fileInput.files[0]); });
|
|
|
|
async function uploadFile(file) {
|
|
drop.innerHTML = `<p>Uploading ${file.name}…</p>`;
|
|
try {
|
|
const tokenResp = await fetch("/api/v1/cdr/upload-token", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({ profile_id: profileId, file_name: file.name }),
|
|
});
|
|
const token = await tokenResp.json();
|
|
// Actual PUT would use the pre-signed MinIO URL; the API layer
|
|
// constructs it from token. Here we proxy through the same API.
|
|
const putResp = await fetch(`/api/v1/cdr/upload/${token.token}`, {
|
|
method: "PUT",
|
|
body: file,
|
|
});
|
|
if (!putResp.ok) throw new Error(`HTTP ${putResp.status}`);
|
|
drop.innerHTML = `<p style="color:#065f46;">✓ ${file.name} uploaded.
|
|
Processing in queue; refresh in a minute to see the study update.</p>`;
|
|
setTimeout(loadStudy, 30000);
|
|
} catch (err) {
|
|
drop.innerHTML = `<p style="color:#b91c1c;">Upload failed: ${err.message}</p>`;
|
|
}
|
|
}
|
|
|
|
// ── Paywall-aware study render ────────────────────────────────────
|
|
async function loadStudy() {
|
|
const body = document.getElementById("pw_study_body");
|
|
try {
|
|
const resp = await fetch(`/api/v1/cdr/profile/${profileId}/study?year=${year}`);
|
|
const data = await resp.json();
|
|
const ing = data.ingestion || {};
|
|
const healthBlock = `
|
|
<div class="pw-stat-row">
|
|
<div class="pw-stat">
|
|
<strong>${(ing.rows_this_year || 0).toLocaleString()}</strong>
|
|
calls classified in ${year}
|
|
</div>
|
|
<div class="pw-stat">
|
|
<strong>${((ing.bytes_stored || 0) / 1073741824).toFixed(2)} GB</strong>
|
|
compressed CDR data on file
|
|
</div>
|
|
<div class="pw-stat">
|
|
<strong>${ing.rows_quarantined || 0}</strong>
|
|
rows in quarantine
|
|
<a href="/cdr-quarantine" style="font-size:.85rem">review →</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
if (data.status === "locked") {
|
|
body.innerHTML = healthBlock + `
|
|
<div class="pw-lock-banner">
|
|
<strong>${year} classified study is locked.</strong>
|
|
<p style="margin:.3rem 0 .75rem;">${data.unlock_reason}</p>
|
|
<a class="pw-btn" href="${data.unlock_url}">Unlock ${year} traffic study →</a>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
if (data.status === "unlocked_pending_study") {
|
|
body.innerHTML = healthBlock + `
|
|
<div class="pw-unlock-banner">
|
|
<strong>Unlocked.</strong> Your ${year} study will generate on the next CDRAnalysisHandler run — usually within 24 hours of payment.
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
// Unlocked
|
|
const rep = data.classified_report || {};
|
|
body.innerHTML = healthBlock + `
|
|
<div class="pw-unlock-banner">
|
|
<strong>${year} study unlocked</strong> (paid via order ${data.granted_by_order}).
|
|
</div>
|
|
<table style="width:100%; border-collapse:collapse; margin-top:1rem;">
|
|
<thead style="background:#f8fafc;">
|
|
<tr><th style="text-align:left;padding:.4rem .6rem;">Metric</th>
|
|
<th style="text-align:right;padding:.4rem .6rem;">Revenue-weighted</th>
|
|
<th style="text-align:right;padding:.4rem .6rem;">Minutes-weighted</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
${["interstate","intrastate","international","indeterminate"].map((j) => `
|
|
<tr><td style="padding:.4rem .6rem;">${j[0].toUpperCase() + j.slice(1)}</td>
|
|
<td style="text-align:right;padding:.4rem .6rem;">${fmt(rep[j+"_pct"])}</td>
|
|
<td style="text-align:right;padding:.4rem .6rem;">${fmt(rep[j+"_pct_minutes"])}</td></tr>
|
|
`).join("")}
|
|
</tbody>
|
|
</table>
|
|
<p style="margin-top:1rem;">
|
|
${rep.pdf_minio_path
|
|
? `<a class="pw-btn" href="/api/v1/cdr/study-download?profile=${profileId}&year=${year}&fmt=pdf">Download PDF</a>
|
|
<a class="pw-btn" style="background:#2d4e78;margin-left:.5rem;" href="/api/v1/cdr/study-download?profile=${profileId}&year=${year}&fmt=xlsx">Download XLSX</a>`
|
|
: "<em>Study PDF/XLSX being generated…</em>"}
|
|
</p>
|
|
`;
|
|
} catch (err) {
|
|
body.innerHTML = `<p style="color:#b91c1c;">Could not load study: ${err.message}</p>`;
|
|
}
|
|
}
|
|
function fmt(n) { return (n == null) ? "—" : (Number(n).toFixed(2) + "%"); }
|
|
loadStudy();
|
|
})();
|
|
</script>
|
|
{% endblock %}
|