Add static/admin.html

This commit is contained in:
2026-08-21 01:55:24 +00:00
parent ce095187fe
commit 78357212bb
+158
View File
@@ -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>