Files
jlinc-server/frontend/src/components/UsageChart.astro
2026-08-20 15:08:32 +00:00

109 lines
4.2 KiB
Plaintext

---
---
<section class="bg-white border border-surface-border rounded-lg overflow-hidden flex flex-col">
<div class="p-lg border-b border-surface-border flex justify-between items-center flex-wrap gap-md">
<h3 class="font-headline-sm text-headline-sm text-deep-indigo">API Usage &amp; Storage</h3>
<div id="usage-legend" class="flex gap-md flex-wrap"></div>
</div>
<div id="usage-chart" class="h-80 w-full relative p-lg"></div>
</section>
<script>
import { timeSeriesChart } from '../resources/lineChart.js';
import { formatBytes } from '../resources/formatters.js';
import { getFilters, activeModules, onFilters, toQuery } from '../resources/filters.js';
import { toast } from '../resources/toast.js';
let chart: { destroy: () => void } | null = null;
function buildSeries(coreOn: boolean, auditOn: boolean) {
const series = [];
// Left axis: API call counts. Right axis ('right'): cumulative disk bytes.
if (coreOn || auditOn) series.push({ key: 'total', label: 'Total API', color: '#4F378B' });
if (coreOn) series.push({ key: 'core', label: 'Core API', color: '#8B6FD8' });
if (auditOn) series.push({ key: 'audit', label: 'Audit API', color: '#cfbcff', dashed: true });
if (coreOn) series.push({ key: 'coreDisk', label: 'Core Disk', color: '#31A9BA', axis: 'right' });
if (auditOn) series.push({ key: 'auditDisk', label: 'Audit Disk', color: '#006874', axis: 'right', dashed: true });
return series;
}
function renderLegend(series: Array<{ label: string; color: string; dashed?: boolean }>) {
const el = document.getElementById('usage-legend');
if (!el) return;
el.replaceChildren();
for (const s of series) {
const item = document.createElement('div');
item.className = 'flex items-center gap-xs';
const dot = document.createElement('span');
dot.className = s.dashed ? 'w-3 h-0.5 border-t-2 border-dashed' : 'w-3 h-3 rounded-full';
if (s.dashed) dot.style.borderColor = s.color;
else dot.style.background = s.color;
const label = document.createElement('span');
label.className = 'text-label-sm font-label-sm';
label.textContent = s.label;
item.append(dot, label);
el.appendChild(item);
}
}
function message(el: HTMLElement, text: string) {
chart?.destroy();
chart = null;
el.replaceChildren();
const div = document.createElement('div');
div.className = 'h-full flex items-center justify-center text-label-sm font-label-sm text-on-surface-variant/60 italic';
div.textContent = text;
el.appendChild(div);
}
async function load() {
const el = document.getElementById('usage-chart');
if (!el) return;
try {
// One request for both usage counts and storage bytes (server runs a single
// query and returns { usage: [...], storage: [...] }).
const res = await fetch(`/api/dashboard/series?${toQuery()}`, { credentials: 'include' });
if (!res.ok) return message(el, 'Sign in to view usage');
const payload = await res.json();
const usage = payload.usage ?? [];
const storage = payload.storage ?? [];
const storageByDate = new Map(
storage.map((d: { date: string; coreDisk: number; auditDisk: number }) => [d.date, d])
);
const mods = activeModules();
const coreOn = mods.includes('core');
const auditOn = mods.includes('audit');
const rows = usage.map((d: { date: string; core: number; audit: number }) => {
const sd = storageByDate.get(d.date) || { coreDisk: 0, auditDisk: 0 };
return {
date: d.date,
core: d.core,
audit: d.audit,
total: (coreOn ? d.core : 0) + (auditOn ? d.audit : 0),
coreDisk: sd.coreDisk || 0,
auditDisk: sd.auditDisk || 0,
};
});
const series = buildSeries(coreOn, auditOn);
renderLegend(series);
el.replaceChildren();
chart?.destroy();
chart = timeSeriesChart(el, { data: rows, series, formatRight: formatBytes });
} catch (e) {
console.error('usage chart', e);
message(el, 'Failed to load usage');
toast('Could not load the usage chart');
}
}
// Re-render on filter changes; the date-range fetch + module toggles flow through here.
onFilters(() => load());
load();
</script>