Add static/admin.html
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ConnectVM —</title> Admin Billing</title>
|
||||
<style>
|
||||
:root { --bg:#0f1420; --card:#171e2e; --line:#262f45; --txt:#e6e9f2; --dim:#8a93a8; --acc:#5b8cff; --warn:#f0a848; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font-family:-apple-system,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--txt); }
|
||||
header { display:flex; align-items:center; gap:16px; padding:16px 28px; border-bottom:1px solid var(--line); }
|
||||
header h1 { font-size:18px; margin:0; }
|
||||
header input[type=month] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:6px 10px; }
|
||||
button { background:var(--acc); color:#fff; border:0; border-radius:8px; padding:7px 14px; cursor:pointer; font-size:13px; }
|
||||
button.ghost { background:transparent; border:1px solid var(--line); color:var(--txt); }
|
||||
main { padding:24px 28px; max-width:1280px; margin:0 auto; }
|
||||
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:14px; margin-bottom:22px; }
|
||||
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:16px 18px; }
|
||||
.card .v { font-size:24px; font-weight:700; margin-top:4px; }
|
||||
.card .l { font-size:12px; color:var(--dim); text-transform:uppercase; letter-spacing:.06em; }
|
||||
table { width:100%; border-collapse:collapse; background:var(--card); border:1px solid var(--line); border-radius:12px; overflow:hidden; font-size:13px; }
|
||||
th,td { text-align:left; padding:10px 12px; border-bottom:1px solid var(--line); }
|
||||
th { color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.05em; }
|
||||
tr:last-child td { border-bottom:0; }
|
||||
tr.clickable { cursor:pointer; } tr.clickable:hover td { background:#1d2740; }
|
||||
.flag { color:var(--warn); font-size:11px; font-weight:600; }
|
||||
.dim { color:var(--dim); }
|
||||
#detail { margin-top:26px; display:none; }
|
||||
.detail-head { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:14px; }
|
||||
.detail-head h2 { margin:0; font-size:16px; }
|
||||
h3 { font-size:13px; color:var(--dim); margin:20px 0 8px; text-transform:uppercase; letter-spacing:.05em; }
|
||||
.pill { background:#1d2740; border:1px solid var(--line); border-radius:20px; padding:4px 12px; font-size:12px; }
|
||||
#toast { position:fixed; bottom:22px; right:22px; background:#223; border:1px solid var(--line); padding:12px 18px; border-radius:10px; display:none; max-width:420px; font-size:13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>⚡ ConnectVM Billing — Admin</h1></h1>
|
||||
<input type="month" id="monthPicker">
|
||||
<button class="ghost" onclick="loadSummary()">Refresh</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="l">Period</div><div class="v" id="cPeriod" style="font-size:15px">—</div></div>
|
||||
<div class="card"><div class="l">Total billed</div><div class="v" id="cTotal">—</div></div>
|
||||
<div class="card"><div class="l">Tenants</div><div class="v" id="cTenants">—</div></div>
|
||||
</div>
|
||||
|
||||
<table id="tenantsTable">
|
||||
<thead><tr><th>Tenant</th><th>Bill to date</th><th>Run-rate/mo</th><th>VMs</th><th>Vols</th><th>FIPs</th><th style="width:230px">Actions</th></tr></thead>
|
||||
<tbody><tr><td colspan="7" class="dim">loading…</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<div id="detail"></div>
|
||||
</main>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
const fmt = n => Number(n).toLocaleString(undefined, {maximumFractionDigits: 4});
|
||||
const money = n => '₹' + Number(n).toLocaleString('en-IN', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
|
||||
function monthParams() {
|
||||
const v = $('#monthPicker').value; // "2026-08"
|
||||
if (!v) return '';
|
||||
const [y, m] = v.split('-');
|
||||
return `?year=${y}&month=${Number(m)}`;
|
||||
}
|
||||
|
||||
function toast(msg, ms=5000) {
|
||||
const t = $('#toast'); t.textContent = msg; t.style.display = 'block';
|
||||
setTimeout(() => t.style.display = 'none', ms);
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
const r = await fetch('/api/v1/billing/summary-inr' + monthParams());
|
||||
const d = await r.json();
|
||||
$('#cPeriod').textContent = d.period.begin.slice(0,10) + ' → ' + d.period.end.slice(0,10);
|
||||
$('#cTotal').textContent = money(d.total_bill_to_date);
|
||||
$('#cTenants').textContent = d.tenants.length;
|
||||
const tb = document.querySelector('#tenantsTable tbody');
|
||||
tb.innerHTML = d.tenants.map(t => `
|
||||
<tr class="clickable" onclick="loadDetail('${t.tenant}')">
|
||||
<td><b>${t.tenant}</b></td>
|
||||
<td>${t.bill_to_date != null ? money(t.bill_to_date) : '<span class="dim">—</span>'}</td>
|
||||
<td class="dim">${t.run_rate_per_month != null ? money(t.run_rate_per_month) : '—'}</td>
|
||||
<td>${t.vms ?? '—'}${t.stopped_vms ? ` <span class="flag">⚠${t.stopped_vms}</span>` : ''}</td>
|
||||
<td>${t.volumes ?? '—'}</td><td>${t.floating_ips ?? '—'}</td>
|
||||
<td onclick="event.stopPropagation()">
|
||||
<button class="ghost" onclick="loadDetail('${t.tenant}')">Review</button>
|
||||
<button class="ghost" onclick="openReport('${t.tenant}')">Report</button>
|
||||
<button onclick="sendReport('${t.tenant}')">Send</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function flagHtml(f) { return (f && f.length) ? ` <span class="flag">⚠ ${f.join('; ')}</span>` : ''; }
|
||||
|
||||
function resTable(title, heads, rows) {
|
||||
return `<h3>${title} (${rows.length})</h3><table><thead><tr>${heads.map(h=>`<th>${h}</th>`).join('')}</tr></thead>
|
||||
<tbody>${rows.length ? rows.map(r => `<tr>${r.map(c=>`<td>${c}</td>`).join('')}</tr>`).join('')
|
||||
: `<tr><td colspan="${heads.length}" class="dim">none</td></tr>`}</tbody></table>`;
|
||||
}
|
||||
|
||||
async function loadDetail(tenant) {
|
||||
const r = await fetch(`/api/v1/billing/tenants/${encodeURIComponent(tenant)}/resources` + monthParams());
|
||||
if (!r.ok) { toast('No data for ' + tenant); return; }
|
||||
const d = await r.json();
|
||||
const c = d.counts;
|
||||
const html = `
|
||||
<div class="detail-head">
|
||||
<h2>${d.tenant}</h2>
|
||||
<span class="pill">Bill to date: <b>${money(d.bill_to_date)}</b></span>
|
||||
<span class="pill">Run-rate: ${money(d.run_rate_per_month)}/mo</span>
|
||||
<span class="pill">Projected: ${money(d.projected_month_end)}</span>
|
||||
<span class="pill">${c.vms} VMs · ${c.volumes} vols · ${c.floating_ips} FIPs</span>
|
||||
${c.stopped_vms_still_billing ? `<span class="pill flag">⚠ ${c.stopped_vms_still_billing} stopped VMs billing</span>` : ''}
|
||||
${c.unassigned_fips ? `<span class="pill flag">⚠ ${c.unassigned_fips} unassigned FIPs</span>` : ''}
|
||||
<button class="ghost" onclick="openReport('${d.tenant}')">Preview report</button>
|
||||
<button onclick="sendReport('${d.tenant}')">Send to tenant</button>
|
||||
</div>
|
||||
${resTable('Virtual machines', ['Name','Status','Flavor','vCPU','RAM','Disk','Age (d)','Billable hrs','Rate/mo','Cost'],
|
||||
d.vms.map(v => [`<b>${v.name}</b>`, v.status + flagHtml(v.flags), v.flavor, v.vcpus, (v.ram_mb/1024)+' GB', v.disk_gb+' GB', v.age_days, v.hours_in_period, money(v.monthly_rate), money(v.est_cost)]))}
|
||||
${resTable('Volumes', ['Name','Status','Size','Attached','Age (d)','Billable hrs','Rate/mo','Cost'],
|
||||
d.volumes.map(v => [`<b>${v.name}</b>`, v.status + flagHtml(v.flags), v.size_gb+' GB', v.attached ? 'yes' : 'no', v.age_days, v.hours_in_period, money(v.monthly_rate), money(v.est_cost)]))}
|
||||
${resTable('Floating IPs', ['Address','Status','Mapped','Age (d)','Billable hrs','Rate/mo','Cost'],
|
||||
d.floating_ips.map(f => [`<b>${f.address}</b>`, f.status + flagHtml(f.flags), f.mapped ? 'yes' : 'no', f.age_days, f.hours_in_period, money(f.monthly_rate), money(f.est_cost)]))}
|
||||
`;
|
||||
const el = $('#detail');
|
||||
el.innerHTML = html; el.style.display = 'block';
|
||||
el.scrollIntoView({behavior: 'smooth'});
|
||||
}
|
||||
|
||||
function openReport(tenant) {
|
||||
const p = monthParams();
|
||||
window.open(`/api/v1/billing/tenants/${encodeURIComponent(tenant)}/report.html` + p, '_blank');
|
||||
}
|
||||
|
||||
async function sendReport(tenant) {
|
||||
const email = prompt(`Send ${tenant} billing report to email:`);
|
||||
if (!email) return;
|
||||
const r = await fetch(`/api/v1/billing/tenants/${encodeURIComponent(tenant)}/send` + monthParams(), {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({to_email: email})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) toast('✅ Sent to ' + email);
|
||||
else toast('⚠ ' + (d.detail || 'send failed'), 8000);
|
||||
}
|
||||
|
||||
// default month = current
|
||||
$('#monthPicker').value = new Date().toISOString().slice(0,7);
|
||||
loadSummary();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user