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>
246 lines
11 KiB
HTML
246 lines
11 KiB
HTML
{% extends "templates/web.html" %}
|
|
|
|
{% block page_content %}
|
|
<style>
|
|
:root { --pw-navy:#1e3a5f; --pw-blue:#2d4e78; --pw-light:#f8fafc; --pw-green:#059669; --pw-amber:#b45309; }
|
|
.pw-cdr-wrap { max-width: 900px; margin: 0 auto; }
|
|
.pw-cdr-wrap h1 { color: var(--pw-navy); 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-cdr-card h2 { color: var(--pw-navy); font-size: 1.2rem; margin: 0 0 .75rem; }
|
|
.pw-field { display: block; margin: 1rem 0 .25rem; font-weight: 600; color: #1f2937; }
|
|
.pw-input { width: 100%; padding: .55rem .7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: .95rem; }
|
|
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
|
.pw-row > * { flex: 1 1 250px; }
|
|
.pw-btn { padding: .6rem 1.25rem; border: 0; border-radius: 6px; background: var(--pw-green); color: #fff; font-weight: 600; cursor: pointer; }
|
|
.pw-btn-secondary { background: var(--pw-blue); }
|
|
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
|
|
.pw-callout { border-left: 4px solid var(--pw-blue); background: #f0f9ff; padding: .9rem 1.1rem; border-radius: 0 6px 6px 0; margin: 1rem 0; }
|
|
.pw-help { color: #64748b; font-size: .85rem; margin-top: .2rem; }
|
|
.pw-badge-ok { background: #d1fae5; color: #065f46; padding: .1rem .5rem; border-radius: 10px; font-size: .8rem; font-weight: 600; }
|
|
.pw-badge-off { background: #e2e8f0; color: #475569; padding: .1rem .5rem; border-radius: 10px; font-size: .8rem; font-weight: 600; }
|
|
.pw-badge-warn { background: #fef3c7; color: #92400e; padding: .1rem .5rem; border-radius: 10px; font-size: .8rem; font-weight: 600; }
|
|
.pw-quota-bar { height: 10px; background: #e2e8f0; border-radius: 5px; overflow: hidden; margin: .25rem 0; }
|
|
.pw-quota-fill { height: 100%; background: var(--pw-blue); transition: width .3s; }
|
|
.pw-quota-fill.warn { background: var(--pw-amber); }
|
|
.pw-quota-fill.over { background: #b91c1c; }
|
|
</style>
|
|
|
|
<div class="pw-cdr-wrap">
|
|
<h1>CDR Ingestion Settings</h1>
|
|
|
|
{% if not profile %}
|
|
<div class="pw-callout">
|
|
<strong>No CDR profile yet.</strong> Set up your carrier's CDR
|
|
ingestion and we'll start classifying calls for your 499-A traffic
|
|
study. You control whether you push CDRs to us, let us pull them from
|
|
your switch, or both.
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="pw-cdr-card">
|
|
<h2>1. Your switch</h2>
|
|
<label class="pw-field" for="switch_preset">What switch do you use?</label>
|
|
<select id="switch_preset" class="pw-input" name="switch_preset">
|
|
{% for p in switch_presets %}
|
|
<option value="{{ p.slug }}"
|
|
{% if profile and profile.switch_preset == p.slug %}selected{% endif %}>
|
|
{{ p.label }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
<p class="pw-help">
|
|
Picking a known switch auto-selects the CDR format and shows you the
|
|
credential fields we need. Click <em>Test connection</em> before saving
|
|
to make sure the credentials work.
|
|
</p>
|
|
|
|
<div id="preset_credentials">
|
|
<!-- populated dynamically from /api/v1/cdr/preset-fields/:slug -->
|
|
</div>
|
|
|
|
<div style="margin-top: 1rem;">
|
|
<button class="pw-btn-plain pw-btn" id="test_connection">Test connection</button>
|
|
<span id="test_result" style="margin-left: .75rem; font-size: .9rem;"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="pw-cdr-card">
|
|
<h2>2. Secure file drop (SFTP / FTPS)</h2>
|
|
<p>
|
|
Enable a secure SFTP / FTPS endpoint scoped to your account. We generate a
|
|
random password when you turn it on — <strong>copied once</strong>.
|
|
Use this alongside switch pull, or as a standalone option.
|
|
</p>
|
|
<p>
|
|
Status:
|
|
{% if profile and profile.sftpgo_enabled %}
|
|
<span class="pw-badge-ok">Enabled</span>
|
|
<code>{{ profile.sftpgo_username }}</code>
|
|
{% else %}
|
|
<span class="pw-badge-off">Disabled</span>
|
|
{% endif %}
|
|
</p>
|
|
<button id="sftp_toggle" class="pw-btn"
|
|
data-enabled="{{ '1' if (profile and profile.sftpgo_enabled) else '0' }}">
|
|
{% if profile and profile.sftpgo_enabled %}Rotate password{% else %}Enable secure file drop{% endif %}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="pw-cdr-card">
|
|
<h2>3. Revenue attribution</h2>
|
|
<label>
|
|
<input type="checkbox" id="minutes_only"
|
|
{% if profile and profile.minutes_only_estimation_enabled %}checked{% endif %}>
|
|
Use minutes-only estimation
|
|
</label>
|
|
<p class="pw-help">
|
|
Leave this <strong>off</strong> if your CDRs carry per-call revenue
|
|
(recommended — most commercial switches do). Turn it on if you
|
|
run a flat-rate line service or your switch doesn't emit per-call
|
|
charges. Your traffic study will be labeled accordingly.
|
|
</p>
|
|
<label class="pw-field" for="flat_monthly_revenue">Monthly flat revenue (USD)</label>
|
|
<input id="flat_monthly_revenue" type="number" step="1" class="pw-input"
|
|
value="{{ (profile.flat_monthly_revenue_cents / 100) if (profile and profile.flat_monthly_revenue_cents) else '' }}"
|
|
placeholder="only needed when minutes-only is on">
|
|
</div>
|
|
|
|
<div class="pw-cdr-card">
|
|
<h2>4. Storage & retention</h2>
|
|
{% if profile %}
|
|
<p>Current plan: <strong>{{ profile.storage_plan|title }}</strong></p>
|
|
<!-- Usage meter populated asynchronously -->
|
|
<div id="quota_meter">Loading usage…</div>
|
|
<p class="pw-help">At 80% we email a heads-up. At 100%, behavior follows the over-quota policy below.</p>
|
|
<label class="pw-field" for="over_quota_policy">Over-quota policy</label>
|
|
<select id="over_quota_policy" class="pw-input">
|
|
<option value="notify" {% if profile.over_quota_policy == "notify" %}selected{% endif %}>Pause classification, notify admin</option>
|
|
<option value="block" {% if profile.over_quota_policy == "block" %}selected{% endif %}>Reject new uploads until upgraded</option>
|
|
<option value="auto_upgrade" {% if profile.over_quota_policy == "auto_upgrade" %}selected{% endif %}>Auto-upgrade to next tier (charges saved card)</option>
|
|
</select>
|
|
{% else %}
|
|
<p>Storage quotas activate once your profile is saved. You're on the <em>Included with filing</em> plan by default.</p>
|
|
{% endif %}
|
|
|
|
<table style="width: 100%; margin-top: 1rem; font-size: .9rem; border-collapse: collapse;">
|
|
<thead style="background: var(--pw-light);">
|
|
<tr>
|
|
<th style="text-align:left; padding:.4rem .6rem;">Plan</th>
|
|
<th style="text-align:left; padding:.4rem .6rem;">Storage</th>
|
|
<th style="text-align:left; padding:.4rem .6rem;">Calls / year</th>
|
|
<th style="text-align:left; padding:.4rem .6rem;">Annual</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for t in storage_tiers %}
|
|
<tr>
|
|
<td style="padding:.4rem .6rem; border-top:1px solid #e2e8f0;">{{ t.name }}</td>
|
|
<td style="padding:.4rem .6rem; border-top:1px solid #e2e8f0;">{{ t.storage }}</td>
|
|
<td style="padding:.4rem .6rem; border-top:1px solid #e2e8f0;">{{ t.calls }}</td>
|
|
<td style="padding:.4rem .6rem; border-top:1px solid #e2e8f0;">{{ t.price }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div style="margin: 2rem 0; text-align: right;">
|
|
<button id="save_settings" class="pw-btn">Save settings</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const profileId = {{ profile.id if profile else 'null' }};
|
|
|
|
async function api(method, path, body) {
|
|
const opts = { method, headers: { "Content-Type": "application/json" }, credentials: "same-origin" };
|
|
if (body) opts.body = JSON.stringify(body);
|
|
const resp = await fetch(path, opts);
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
return resp.json();
|
|
}
|
|
|
|
// Load quota meter once the page is ready
|
|
async function loadUsage() {
|
|
if (!profileId) return;
|
|
try {
|
|
const year = new Date().getUTCFullYear();
|
|
const resp = await fetch(`/api/v1/cdr/profile/${profileId}/study?year=${year}`);
|
|
const data = await resp.json();
|
|
const ing = data.ingestion || {};
|
|
const el = document.getElementById("quota_meter");
|
|
if (!el) return;
|
|
// Without per-plan caps surfaced, show raw counts.
|
|
el.innerHTML = `
|
|
<div><strong>${(ing.rows_this_year || 0).toLocaleString()}</strong> calls classified in ${year}</div>
|
|
<div><strong>${((ing.bytes_stored || 0) / 1073741824).toFixed(2)} GB</strong> of compressed CDR data on file</div>
|
|
<div>Last ingest: ${ing.last_upload_at || "—"}</div>
|
|
`;
|
|
} catch (err) { /* silent */ }
|
|
}
|
|
loadUsage();
|
|
|
|
// Test connection button: fires against a hypothetical preset-validate
|
|
// endpoint; wired to the cdr.ts route once implemented.
|
|
document.getElementById("test_connection").addEventListener("click", async () => {
|
|
const msg = document.getElementById("test_result");
|
|
msg.textContent = "Testing…";
|
|
msg.style.color = "#64748b";
|
|
try {
|
|
const preset = document.getElementById("switch_preset").value;
|
|
const resp = await api("POST", `/api/v1/cdr/profile/${profileId || 0}/test-connection`, {
|
|
switch_preset: preset,
|
|
});
|
|
if (resp.ok) {
|
|
msg.textContent = "✓ " + (resp.detail || "connected");
|
|
msg.style.color = "#059669";
|
|
} else {
|
|
msg.textContent = "✗ " + (resp.detail || "failed");
|
|
msg.style.color = "#b91c1c";
|
|
}
|
|
} catch (err) {
|
|
msg.textContent = "✗ " + err.message;
|
|
msg.style.color = "#b91c1c";
|
|
}
|
|
});
|
|
|
|
// SFTP toggle
|
|
document.getElementById("sftp_toggle").addEventListener("click", async (e) => {
|
|
const enabled = e.target.dataset.enabled === "1";
|
|
try {
|
|
const resp = await api("POST", `/api/v1/cdr/profile/${profileId}/sftpgo`,
|
|
{ action: enabled ? "rotate" : "enable" });
|
|
if (resp.password) {
|
|
alert(
|
|
"SFTP credentials — copy now, we won't show this again:\n\n" +
|
|
`Host: cdr.performancewest.net\nUser: ${resp.username}\nPass: ${resp.password}\n\n` +
|
|
"Clients: SFTP port 2022, FTPS port 990 (implicit TLS).",
|
|
);
|
|
window.location.reload();
|
|
}
|
|
} catch (err) {
|
|
alert("Could not toggle SFTP: " + err.message);
|
|
}
|
|
});
|
|
|
|
// Save settings
|
|
document.getElementById("save_settings").addEventListener("click", async () => {
|
|
const body = {
|
|
switch_preset: document.getElementById("switch_preset").value,
|
|
minutes_only_estimation_enabled: document.getElementById("minutes_only").checked,
|
|
flat_monthly_revenue_cents: Math.round(
|
|
(parseFloat(document.getElementById("flat_monthly_revenue").value) || 0) * 100
|
|
),
|
|
over_quota_policy: document.getElementById("over_quota_policy")?.value,
|
|
};
|
|
try {
|
|
await api("PUT", `/api/v1/cdr/profile/${profileId}`, body);
|
|
alert("Saved.");
|
|
} catch (err) {
|
|
alert("Save failed: " + err.message);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|