addition of new dashboard

This commit is contained in:
2026-08-20 15:08:32 +00:00
parent 0622018d95
commit bf59249ed7
161 changed files with 13170 additions and 119 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,125 @@
---
---
<section id="api-keys-view" class="hidden p-lg max-w-400px w-full mx-auto space-y-lg pb-24 lg:pb-lg">
<h2 class="font-headline-lg text-headline-lg text-deep-indigo">API Keys</h2>
<!-- Show-once banner for a freshly generated key -->
<div id="new-key-banner" class="hidden bg-surface-container-lowest border border-teal-accent rounded-lg p-md">
<div class="flex items-center justify-between gap-md">
<div class="min-w-0">
<p class="font-label-sm text-label-sm text-on-surface-variant uppercase tracking-wider">New key — copy it now, it won't be shown again</p>
<code id="new-key-value" class="block truncate font-label-md text-label-md text-deep-indigo mt-1"></code>
</div>
<button id="new-key-copy" class="bg-vibrant-purple text-white px-md py-1.5 rounded font-label-md text-label-md shrink-0 cursor-pointer">Copy</button>
</div>
</div>
<div id="api-keys-modules" class="space-y-lg"></div>
</section>
<script>
import { formatDate } from '../resources/formatters';
import { toast } from '../resources/toast.js';
const modulesHost = document.getElementById('api-keys-modules');
const banner = document.getElementById('new-key-banner');
const bannerValue = document.getElementById('new-key-value');
let loaded = false;
const esc = (s: unknown) => String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
document.getElementById('new-key-copy')?.addEventListener('click', () => {
navigator.clipboard?.writeText(bannerValue?.textContent || '').then(() => toast('Copied', 'info'));
});
async function render() {
if (!modulesHost) return;
let who, keys;
try {
const [whoRes, keysRes] = await Promise.all([
fetch('/api/dashboard/auth/whoami', { credentials: 'include' }),
fetch('/api/dashboard/auth/keys', { credentials: 'include' }),
]);
if (!whoRes.ok || !keysRes.ok) { toast('Could not load API keys'); return; }
[who, keys] = await Promise.all([whoRes.json(), keysRes.json()]);
} catch (e) {
console.error('api-keys', e);
toast('Could not load API keys');
return;
}
const base = `${location.origin}/api/v1`;
const byModule: Record<string, any[]> = {};
for (const k of keys) (byModule[k.appType] ||= []).push(k);
modulesHost.innerHTML = (who.modules || []).map((mod: string) => `
<section class="bg-white border border-surface-border rounded-lg">
<div class="p-md border-b border-surface-border flex items-center justify-between flex-wrap gap-md">
<div class="min-w-0">
<h3 class="font-headline-sm text-headline-sm text-deep-indigo capitalize">${esc(mod)}</h3>
<p class="font-label-sm text-label-sm text-on-surface-variant truncate">${esc(base)}</p>
</div>
<button data-gen="${esc(mod)}" class="bg-vibrant-purple/10 text-vibrant-purple px-md py-1.5 rounded font-label-md text-label-md hover:bg-vibrant-purple hover:text-white transition-all flex items-center gap-xs cursor-pointer">
<span class="material-symbols-outlined text-[18px]">add</span> Generate key
</button>
</div>
<div class="divide-y divide-surface-border">
${(byModule[mod] || []).map((k) => `
<div class="p-md flex items-center justify-between gap-md flex-wrap">
<div class="min-w-0">
<code class="font-label-md text-label-md text-deep-indigo">${esc(k.prefix)}••••••••</code>
${k.label ? `<span class="ml-2 text-label-sm font-label-sm text-on-surface-variant">${esc(k.label)}</span>` : ''}
<p class="text-label-sm font-label-sm text-on-surface-variant mt-1">
Created ${esc(formatDate(k.createdTs))}${k.expiresTs ? ` · Expires ${esc(formatDate(k.expiresTs))}` : ' · No expiry'}${k.lastUsedTs ? ` · Last used ${esc(formatDate(k.lastUsedTs))}` : ' · Never used'}
</p>
</div>
<button data-revoke="${esc(k.id)}" class="text-error hover:underline font-label-sm text-label-sm shrink-0 cursor-pointer">Revoke</button>
</div>`).join('') || `<div class="p-md text-label-sm font-label-sm text-on-surface-variant/60 italic">No keys yet</div>`}
</div>
</section>`).join('') || `<p class="text-label-sm font-label-sm text-on-surface-variant/60 italic">No modules enabled.</p>`;
}
async function generate(type: string, btn: HTMLButtonElement | null) {
if (btn) btn.disabled = true; // guard against a double-click creating two keys
try {
const res = await fetch('/api/dashboard/auth/keys', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type }),
});
if (!res.ok) { toast('Could not generate key'); return; }
const { key } = await res.json();
if (!key) { toast('Key missing from response'); return; } // never surface "undefined"
if (bannerValue) bannerValue.textContent = key;
banner?.classList.remove('hidden');
await render();
} catch { toast('Could not generate key'); }
finally { if (btn) btn.disabled = false; }
}
async function revoke(id: string, btn: HTMLButtonElement | null) {
if (btn) btn.disabled = true;
try {
const res = await fetch(`/api/dashboard/auth/keys/${encodeURIComponent(id)}`, { method: 'DELETE', credentials: 'include' });
if (!res.ok) { toast('Could not revoke key'); return; }
await render();
} catch { toast('Could not revoke key'); }
finally { if (btn) btn.disabled = false; }
}
modulesHost?.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest('[data-gen],[data-revoke]') as HTMLButtonElement | null;
if (!t || t.disabled) return;
if (t.dataset.gen) generate(t.dataset.gen, t);
else if (t.dataset.revoke) revoke(t.dataset.revoke, t);
});
// Lazy-load on first switch to the API Keys view; hide the show-once key banner
// when leaving so the raw key isn't left on screen for the session.
window.addEventListener('dashboard:view', (e) => {
const view = (e as CustomEvent).detail;
if (view === 'keys') { if (!loaded) { loaded = true; render(); } }
else { banner?.classList.add('hidden'); if (bannerValue) bannerValue.textContent = ''; }
});
</script>

View File

@@ -0,0 +1,63 @@
---
---
<header class="sticky top-0 z-40 bg-surface-container-lowest border-b border-surface-border flex justify-between items-center w-full px-lg h-16">
<div class="flex items-center gap-lg flex-1 justify-center">
<span id="menu-toggle" class="lg:hidden material-symbols-outlined text-primary cursor-pointer">menu</span>
<div class="hidden md:flex items-center bg-surface-container rounded px-md py-1.5 gap-sm flex-1 max-w-2xl">
<span class="material-symbols-outlined text-on-surface-variant text-[20px]">search</span>
<input id="global-search" class="bg-transparent border-none focus:ring-0 text-body-md w-full placeholder:text-on-surface-variant" placeholder="Search resources, events, or UUIDs..." type="text">
</div>
</div>
<div class="flex items-center gap-md">
<div class="relative" title="0 notifications">
<span class="material-symbols-outlined text-on-surface-variant">notifications</span>
<span class="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-surface-container text-on-surface-variant text-[10px] font-label-sm flex items-center justify-center">0</span>
</div>
<a href="https://docs.jlinc.io" target="_blank" rel="noopener" title="Documentation" class="material-symbols-outlined text-on-surface-variant cursor-pointer hover:text-primary transition-colors">help_outline</a>
<div class="h-8 w-px bg-surface-border mx-sm"></div>
<div class="relative">
<button id="avatar-button" aria-label="Account menu" class="w-8 h-8 rounded-full bg-teal-accent text-white flex items-center justify-center font-label-md text-label-md cursor-pointer">
<span id="avatar-initial">·</span>
</button>
<div id="avatar-menu" class="hidden absolute right-0 top-10 min-w-40 bg-deep-indigo rounded-lg shadow-xl p-sm z-50">
<div id="avatar-username" class="px-sm py-1 text-on-primary-fixed-variant text-label-sm font-label-sm truncate"></div>
<form method="POST" action="/logout">
<button type="submit" class="w-full mt-1 bg-teal-accent text-white rounded px-sm py-1.5 text-label-md font-label-md cursor-pointer hover:brightness-110">Logout</button>
</form>
</div>
</div>
</div>
</header>
<script>
import { setSearch } from '../resources/filters.js';
const input = document.getElementById('global-search') as HTMLInputElement | null;
let timer: ReturnType<typeof setTimeout>;
input?.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => setSearch(input.value.trim()), 250);
});
// Avatar: initial-circle from the logged-in username (matches /dashboard).
const avatarBtn = document.getElementById('avatar-button');
const avatarMenu = document.getElementById('avatar-menu');
avatarBtn?.addEventListener('click', (e) => { e.stopPropagation(); avatarMenu?.classList.toggle('hidden'); });
document.addEventListener('click', () => avatarMenu?.classList.add('hidden'));
(async () => {
try {
const res = await fetch('/api/dashboard/auth/whoami', { credentials: 'include' });
if (!res.ok) return;
const { username } = await res.json();
if (!username) return;
const initial = document.getElementById('avatar-initial');
const nameEl = document.getElementById('avatar-username');
if (initial) initial.textContent = username.charAt(0).toUpperCase();
if (nameEl) nameEl.textContent = username;
} catch { /* ignore */ }
})();
</script>

View File

@@ -0,0 +1,74 @@
---
---
<section class="bg-white border border-surface-border p-md flex flex-wrap items-center justify-between gap-md rounded-lg">
<div class="flex items-center gap-lg flex-wrap">
<div class="flex items-center gap-md">
<label class="flex items-center gap-sm cursor-pointer">
<input id="filter-core" type="checkbox" class="rounded border-outline-variant text-vibrant-purple focus:ring-vibrant-purple">
<span class="font-label-md text-label-md">Core</span>
</label>
<label class="flex items-center gap-sm cursor-pointer">
<input id="filter-audit" type="checkbox" class="rounded border-outline-variant text-vibrant-purple focus:ring-vibrant-purple">
<span class="font-label-md text-label-md">Audit</span>
</label>
</div>
<div class="h-6 w-px bg-surface-border"></div>
<div class="flex items-center gap-sm bg-surface-container-low border border-surface-border rounded px-md py-1.5">
<span class="material-symbols-outlined text-[18px] text-on-surface-variant">calendar_today</span>
<input id="filter-from" type="date" class="bg-transparent border-none focus:ring-0 font-label-md text-label-md p-0">
<span class="text-on-surface-variant">–</span>
<input id="filter-to" type="date" class="bg-transparent border-none focus:ring-0 font-label-md text-label-md p-0">
</div>
</div>
<div class="flex items-center gap-md">
<span id="filter-saved" class="text-label-sm font-label-sm text-teal-accent opacity-0 transition-opacity">Saved ✓</span>
<button id="filter-save" class="bg-vibrant-purple/10 text-vibrant-purple px-lg py-2 rounded font-label-md text-label-md hover:bg-vibrant-purple hover:text-white transition-all">
Save Filters
</button>
</div>
</section>
<script>
import { getFilters, setFilters } from '../resources/filters.js';
const core = document.getElementById('filter-core') as HTMLInputElement | null;
const audit = document.getElementById('filter-audit') as HTMLInputElement | null;
const from = document.getElementById('filter-from') as HTMLInputElement | null;
const to = document.getElementById('filter-to') as HTMLInputElement | null;
const saveBtn = document.getElementById('filter-save');
const savedBadge = document.getElementById('filter-saved');
// Reflect current state into the controls.
const f = getFilters();
if (core) core.checked = f.core;
if (audit) audit.checked = f.audit;
if (from) from.value = f.from;
if (to) to.value = f.to;
// Live-apply on change; "Save Filters" persists for next session.
function apply(persist = false) {
// Re-read current state so an emptied date field falls back to the latest
// value, not the stale page-load one captured above.
const cur = getFilters();
setFilters(
{
core: !!core?.checked,
audit: !!audit?.checked,
from: from?.value || cur.from,
to: to?.value || cur.to,
},
{ persist }
);
}
[core, audit, from, to].forEach((el) => el?.addEventListener('change', () => apply(false)));
saveBtn?.addEventListener('click', () => {
apply(true);
if (savedBadge) {
savedBadge.style.opacity = '1';
setTimeout(() => { savedBadge.style.opacity = '0'; }, 1600);
}
});
</script>

View File

@@ -0,0 +1,54 @@
---
import { eeSlot } from '../resources/ee';
const eeFooter = eeSlot('sidebar');
---
<div id="sidebar-overlay" class="fixed inset-0 bg-black/50 z-40 hidden lg:hidden"></div>
<aside id="sidebar" class="fixed left-0 top-0 h-full w-65 flex flex-col bg-deep-indigo border-r border-outline-variant/20 z-50 -translate-x-full lg:translate-x-0 transition-transform duration-300">
<div class="px-lg py-xl">
<img src="https://lh3.googleusercontent.com/aida-public/AB6AXuCj4NmZrFnPwzfaDAQVLCszxSWFRzmJzzZWtZABjqvzd7Llv3L-M6JTbWcAlMhhUqTeii_ie3wd3NxIXwKXyX7CO4UV1VwVyc5xY9IyHBt6cBbGtzOY_yM3Ke1LzSD8Ulbjgb932b-9fPAu_WFHZ0lSFz5Rlu95XNSPUgYyPhjOq7Ns884ffeUkwEKtQy8NpWSU67BG4-UuWI5Q9JxLwkx1PWBk989lSZKFoxXeypJ1Ps-R_DHrGLoLOu-GH8aMbASuKZJ6gmhUYRQ" alt="JLINC Logo" class="jlinc-logo h-12 w-auto object-contain">
</div>
<nav class="flex-1 flex flex-col mt-md">
<a id="nav-dashboard" class="text-primary-fixed-dim font-bold bg-vibrant-purple/10 border-r-4 border-vibrant-purple py-3 px-6 flex items-center gap-md transition-all scale-98" href="#">
<span class="material-symbols-outlined" data-icon="dashboard">dashboard</span>
<span class="font-label-md text-label-md">Dashboard</span>
</a>
<a id="nav-api-keys" class="text-on-primary-fixed-variant hover:text-primary-fixed py-3 px-6 flex items-center gap-md hover:bg-vibrant-purple/5 transition-colors" href="#">
<span class="material-symbols-outlined" data-icon="key">key</span>
<span class="font-label-md text-label-md">API Keys</span>
</a>
</nav>
{eeFooter.length > 0 && (
<div class="p-lg">
{eeFooter.map((Item) => <Item />)}
</div>
)}
</aside>
<script>
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
const toggle = document.getElementById('menu-toggle');
function openSidebar() {
sidebar?.classList.remove('-translate-x-full');
overlay?.classList.remove('hidden');
}
function closeSidebar() {
sidebar?.classList.add('-translate-x-full');
overlay?.classList.add('hidden');
}
toggle?.addEventListener('click', () => {
if (sidebar?.classList.contains('-translate-x-full')) {
openSidebar();
} else {
closeSidebar();
}
});
overlay?.addEventListener('click', closeSidebar);
</script>

View File

@@ -0,0 +1,121 @@
---
import { eeSlot } from '../resources/ee';
const eeCards = eeSlot('cards');
const columns = eeCards.length ? 'lg:grid-cols-4' : 'lg:grid-cols-3';
---
<div class:list={['grid grid-cols-1 md:grid-cols-2 gap-lg', columns]}>
<!-- API Usage -->
<div class="bg-surface-container-lowest border border-surface-border p-md rounded-lg flex flex-col gap-sm">
<div class="flex justify-between items-start">
<span class="font-label-sm text-label-sm text-on-surface-variant tracking-wider uppercase">API Usage</span>
<span class="material-symbols-outlined text-primary text-[20px]">hub</span>
</div>
<div class="mt-xs">
<h3 class="font-headline-md text-headline-md text-deep-indigo" id="api-usage-total"></h3>
<p class="font-label-sm text-label-sm text-on-surface-variant" id="api-usage"></p>
</div>
<div class="w-full bg-surface-container h-1.5 rounded-full overflow-hidden mt-sm flex" title="Core vs Audit split">
<div id="api-bar-core" class="bg-vibrant-purple h-full" style="width:0%"></div>
<div id="api-bar-audit" class="bg-primary-fixed-dim h-full" style="width:0%"></div>
</div>
</div>
<!-- Storage Usage -->
<div class="bg-surface-container-lowest border border-surface-border p-md rounded-lg flex flex-col gap-sm">
<div class="flex justify-between items-start">
<span class="font-label-sm text-label-sm text-on-surface-variant tracking-wider uppercase">Disk Storage</span>
<span class="material-symbols-outlined text-teal-accent text-[20px]">database</span>
</div>
<div class="mt-xs">
<h3 class="font-headline-md text-headline-md text-deep-indigo" id="storage-total"></h3>
<p class="font-label-sm text-label-sm text-on-surface-variant" id="storage-breakdown"></p>
</div>
<div class="w-full bg-surface-container h-1.5 rounded-full overflow-hidden mt-sm flex" title="Core vs Audit split">
<div id="storage-bar-core" class="bg-teal-accent h-full" style="width:0%"></div>
<div id="storage-bar-audit" class="bg-secondary h-full" style="width:0%"></div>
</div>
</div>
{/* EE cards (Billing); empty in the community build */}
{eeCards.map((Card) => <Card />)}
<!-- Settings -->
<div class="bg-surface-container-lowest border border-surface-border p-md rounded-lg flex flex-col gap-sm">
<div class="flex justify-between items-start">
<span class="font-label-sm text-label-sm text-on-surface-variant tracking-wider uppercase">Config</span>
<span class="material-symbols-outlined text-on-surface-variant text-[20px]">settings_input_component</span>
</div>
<div class="space-y-sm mt-xs">
<div class="flex justify-between items-center bg-surface-container-low px-sm py-1 rounded">
<span class="font-label-sm text-label-sm">API Keys</span>
<span class="font-label-sm text-label-sm text-teal-accent" id="api-keys"></span>
</div>
<div class="flex justify-between items-center bg-surface-container-low px-sm py-1 rounded">
<span class="font-label-sm text-label-sm">Endpoints</span>
<span class="font-label-sm text-label-sm text-primary" id="endpoints"></span>
</div>
</div>
</div>
</div>
<script>
import { formatBytes, formatCompact } from '../resources/formatters.js';
import { activeModules, onFilters, toQuery } from '../resources/filters.js';
import { toast } from '../resources/toast.js';
const setWidth = (id: string, pct: number) => {
const el = document.getElementById(id);
if (el) el.style.width = `${pct}%`;
};
const ratio = (part: number, total: number) => (total > 0 ? (part / total) * 100 : 0);
async function loadSummary() {
let data;
try {
const res = await fetch(`/api/dashboard/summary?${toQuery()}`, { credentials: 'include' });
if (!res.ok) { if (res.status !== 401) toast('Could not load summary'); return; }
data = await res.json();
} catch (e) {
console.error('summary', e);
toast('Could not load summary');
return;
}
const apiUsageElement = document.getElementById('api-usage');
const apiKeysElement = document.getElementById('api-keys');
const endPointsElement = document.getElementById('endpoints');
const apiUsageTotalElement = document.getElementById('api-usage-total');
const storageTotalElement = document.getElementById('storage-total');
const storageBreakdownElement = document.getElementById('storage-breakdown');
// API usage reflects the active modules (so the toggles change the total).
const mods = activeModules();
const apiUsage = data.apiUsage || {}; // degrade to zeros if the field is absent
const core = mods.includes('core') ? (apiUsage.core || 0) : 0;
const audit = mods.includes('audit') ? (apiUsage.audit || 0) : 0;
const total = core + audit;
if (apiUsageElement) apiUsageElement.textContent = `Core: ${formatCompact(core)} | Audit: ${formatCompact(audit)}`;
if (apiKeysElement) apiKeysElement.textContent = `${data.apiKeys} Active`;
if (endPointsElement) endPointsElement.textContent = `${data.endpoints} Global`;
if (apiUsageTotalElement) apiUsageTotalElement.textContent = formatCompact(total);
setWidth('api-bar-core', ratio(core, total));
setWidth('api-bar-audit', ratio(audit, total));
if (data.storage) {
const s = data.storage;
if (storageTotalElement) {
storageTotalElement.textContent = formatBytes(s.onDisk.total);
storageTotalElement.title = `${formatBytes(s.logical.total)} logical (uncompressed)`;
}
if (storageBreakdownElement) {
storageBreakdownElement.textContent = `Core: ${formatBytes(s.onDisk.core)} | Audit: ${formatBytes(s.onDisk.audit)}`;
}
setWidth('storage-bar-core', ratio(s.onDisk.core, s.onDisk.total));
setWidth('storage-bar-audit', ratio(s.onDisk.audit, s.onDisk.total));
}
}
onFilters(() => loadSummary());
loadSummary();
</script>

View File

@@ -0,0 +1,449 @@
---
import { eeSlot } from '../resources/ee';
const eeToolbar = eeSlot('toolbar');
---
<section class="bg-white border border-surface-border rounded-lg shadow-sm">
<div class="flex border-b border-surface-border">
<button class="px-xl py-md font-label-md text-label-md tab-active transition-all" id="tab-core">
CORE TRANSACTIONS
</button>
<button class="px-xl py-md font-label-md text-label-md text-on-surface-variant hover:text-vibrant-purple transition-all" id="tab-audit">
AUDIT TRANSACTIONS
</button>
</div>
<div class="p-md bg-surface-container-low/30 border-b border-surface-border flex justify-between items-center">
<span id="tx-count" class="text-label-sm font-label-sm text-on-surface-variant">Loading…</span>
<div class="hidden gap-md" id="audit-actions">
<button id="validate-all" class="bg-teal-accent text-white px-md py-1.5 rounded font-label-md text-label-md flex items-center gap-xs shadow-sm hover:brightness-110">
<span class="material-symbols-outlined text-[18px]">verified_user</span>
Validate All
</button>
</div>
<div class="flex items-center gap-md">
{eeToolbar.map((Item) => <Item />)}
<button id="verify-selected" class="bg-vibrant-purple/10 text-vibrant-purple px-md py-1.5 rounded font-label-md text-label-md flex items-center gap-xs hover:bg-vibrant-purple hover:text-white transition-all">
<span class="material-symbols-outlined text-[18px]">verified</span> Verify Selected
</button>
</div>
</div>
<div class="overflow-x-auto" id="transaction-container">
<table class="w-full text-left" id="transaction-table">
<thead>
<tr class="bg-surface-container-low/50">
<th class="p-md w-10">
<input id="select-all" type="checkbox" class="rounded border-outline-variant text-vibrant-purple focus:ring-vibrant-purple">
</th>
<th class="col-audit hidden p-md font-label-md text-label-md text-on-surface-variant whitespace-nowrap">
Audit UUID
</th>
<th data-sort="agreementUuid" class="p-md font-label-md text-label-md text-on-surface-variant whitespace-nowrap cursor-pointer select-none hover:text-vibrant-purple">
Agreement <span class="material-symbols-outlined text-[14px] align-middle ml-1 opacity-50 sort-arrow">arrow_drop_down</span>
</th>
<th data-sort="eventUuid" class="p-md font-label-md text-label-md text-on-surface-variant whitespace-nowrap cursor-pointer select-none hover:text-vibrant-purple">
Event UUID <span class="material-symbols-outlined text-[14px] align-middle ml-1 opacity-50 sort-arrow">arrow_drop_down</span>
</th>
<th data-sort="sender" class="col-core p-md font-label-md text-label-md text-on-surface-variant whitespace-nowrap cursor-pointer select-none hover:text-vibrant-purple">
Sender / Receiver <span class="material-symbols-outlined text-[14px] align-middle ml-1 opacity-50 sort-arrow">arrow_drop_down</span>
</th>
<th data-sort="date" class="p-md font-label-md text-label-md text-on-surface-variant whitespace-nowrap cursor-pointer select-none hover:text-vibrant-purple">
<span id="date-header-label">Event Date</span> <span class="material-symbols-outlined text-[14px] align-middle ml-1 opacity-50 sort-arrow">arrow_drop_down</span>
</th>
<th data-sort="size" class="p-md font-label-md text-label-md text-on-surface-variant text-right whitespace-nowrap cursor-pointer select-none hover:text-vibrant-purple">
Size <span class="material-symbols-outlined text-[14px] align-middle ml-1 opacity-50 sort-arrow">arrow_drop_down</span>
</th>
<th class="p-md font-label-md text-label-md text-on-surface-variant text-center">Action</th>
</tr>
</thead>
<tbody id="transaction-rows"></tbody>
</table>
</div>
<div id="tx-pagination" class="p-md border-t border-surface-border flex justify-center items-center gap-1 flex-wrap"></div>
</section>
<script>
import { truncateUuid, truncateText, formatBytes, formatDate } from "../resources/formatters";
import { openJsonModal } from "../resources/jsonModal.js";
import { openDetailsModal } from "../resources/detailsModal.js";
import { openVerifyModal } from "../resources/verifyModal.js";
import { openAgreementModal } from "../resources/agreementModal.js";
import { activeModules, onFilters, toQuery, getSearch, onSearch } from "../resources/filters.js";
import { toast } from "../resources/toast.js";
interface Transaction {
eventUuid: string,
sender: string,
receiver: string,
agreementUuid: string,
agreementTitle?: string,
size: number,
date: string,
auditDigest?: string,
// Audit tab only: rows share an eventUuid, so this identifies the row.
auditId?: number,
}
const coreBtn = document.getElementById('tab-core');
const auditBtn = document.getElementById('tab-audit');
const auditActions = document.getElementById('audit-actions');
const tbody = document.getElementById('transaction-rows');
const countEl = document.getElementById('tx-count');
const paginationEl = document.getElementById('tx-pagination');
const selectAll = document.getElementById('select-all') as HTMLInputElement | null;
const verifySelectedBtn = document.getElementById('verify-selected');
const validateAllBtn = document.getElementById('validate-all');
const PAGE = 50;
let currentType: 'core' | 'audit' = 'core';
let sort = 'date';
let dir: 'asc' | 'desc' = 'desc';
let page = 1;
// Per-row verification result (keyed by rowKey, so audit rows keep their own),
// kept so clicking a resolved badge can reopen the per-check popup without
// re-verifying. Cleared on every reload since the rows are re-rendered.
const verifyResults = new Map<string, any>();
// Whether the current user may View JSON. Set from whoami before the first
// render; only hides the button — the server enforces.
let mayViewJson = true;
// sender/receiver are user data going into innerHTML.
const esc = (s: unknown) => String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
function styleTabs() {
const isAudit = currentType === 'audit';
auditBtn?.classList.toggle('tab-active', isAudit);
coreBtn?.classList.toggle('tab-active', !isAudit);
auditBtn?.classList.toggle('text-on-surface-variant', !isAudit);
coreBtn?.classList.toggle('text-on-surface-variant', isAudit);
auditActions?.classList.toggle('hidden', !isAudit);
auditActions?.classList.toggle('flex', isAudit);
applyColumnVisibility();
}
function applyColumnVisibility() {
const isAudit = currentType === 'audit';
document.querySelectorAll('.col-audit').forEach((el) => el.classList.toggle('hidden', !isAudit));
document.querySelectorAll('.col-core').forEach((el) => el.classList.toggle('hidden', isAudit));
const dateLabel = document.getElementById('date-header-label');
if (dateLabel) dateLabel.textContent = isAudit ? 'Audit Date' : 'Event Date';
}
function syncTabs() {
const mods = activeModules();
coreBtn?.classList.toggle('hidden', !mods.includes('core'));
auditBtn?.classList.toggle('hidden', !mods.includes('audit'));
if (currentType === 'core' && !mods.includes('core')) currentType = mods.includes('audit') ? 'audit' : 'core';
if (currentType === 'audit' && !mods.includes('audit')) currentType = mods.includes('core') ? 'core' : 'audit';
styleTabs();
}
const emptyRow = (text: string) =>
`<tr class="border-t border-surface-border"><td colspan="8" class="p-xl text-center font-label-sm text-on-surface-variant/60 italic">${esc(text)}</td></tr>`;
const rowHtml = (row: Transaction) => `
<tr class="border-t border-surface-border hover:bg-secondary/5 transition-colors group" data-event-id="${esc(row.eventUuid)}" data-audit-id="${esc(row.auditId ?? '')}">
<td class="p-md w-10">
<input type="checkbox" class="row-check rounded border-outline-variant text-vibrant-purple focus:ring-vibrant-purple">
</td>
<td class="col-audit hidden p-md font-label-sm text-label-sm text-on-surface-variant" title="Audit digest: ${esc(row.auditDigest ?? '')}">
${esc(truncateUuid(row.auditDigest ?? ''))}
</td>
<td class="p-md font-label-sm text-label-sm" title="${esc(row.agreementTitle ?? row.agreementUuid)}">
<button class="btn-agreement text-vibrant-purple hover:underline text-left" data-agreement-id="${esc(row.agreementUuid)}">
${esc(row.agreementTitle ? truncateText(row.agreementTitle) : truncateUuid(row.agreementUuid))}
</button>
</td>
<td class="p-md font-label-sm text-label-sm text-vibrant-purple" title="${esc(row.eventUuid)}">
${esc(truncateUuid(row.eventUuid))}
</td>
<td class="col-core p-md max-w-[220px]">
<div class="flex flex-col">
<span class="block truncate text-body-md font-medium" title="${esc(row.sender)}">${esc(row.sender)}</span>
<span class="block truncate text-[11px] text-on-surface-variant" title="${esc(row.receiver)}">${esc(row.receiver)}</span>
</div>
</td>
<td class="p-md font-label-sm text-label-sm text-on-surface-variant whitespace-nowrap">
${esc(formatDate(row.date))}
</td>
<td class="p-md font-label-sm text-label-sm text-right">
${esc(formatBytes(row.size))}
</td>
<td class="p-md text-center">
<div class="flex justify-center items-center gap-md">
${mayViewJson ? '<button class="text-primary hover:underline font-label-sm text-label-sm btn-details">View Details</button>' : ''}
<button class="text-teal-accent hover:underline font-label-sm text-label-sm btn-verify">Verify</button>
<span class="verify-badge font-label-sm text-label-sm hidden"></span>
</div>
</td>
</tr>`;
const SORTABLE = ['agreementUuid', 'eventUuid', 'sender', 'date', 'size'];
function updateSortArrows() {
document.querySelectorAll('#transaction-table th[data-sort]').forEach((th) => {
const active = th.getAttribute('data-sort') === sort;
const arrow = th.querySelector('.sort-arrow');
if (arrow) arrow.textContent = active && dir === 'asc' ? 'arrow_drop_up' : 'arrow_drop_down';
arrow?.classList.toggle('opacity-50', !active);
th.classList.toggle('text-vibrant-purple', active);
});
}
function setCount(start: number, end: number, total: number) {
if (!countEl) return;
const noun = currentType === 'audit' ? 'audit' : 'core';
const q = getSearch();
const match = q ? ` matching “${q}”` : '';
countEl.textContent = total === 0
? `No ${noun} records${match}`
: `Showing ${start}–${end} of ${total} ${noun} record${total === 1 ? '' : 's'}${match}`;
}
function pageWindow(current: number, pages: number): (number | '…')[] {
const wanted = new Set([1, pages, current, current - 1, current + 1]);
const list = [...wanted].filter((p) => p >= 1 && p <= pages).sort((a, b) => a - b);
const out: (number | '…')[] = [];
let prev = 0;
for (const p of list) {
if (p - prev > 1) out.push('…');
out.push(p);
prev = p;
}
return out;
}
function renderPagination(total: number) {
if (!paginationEl) return;
paginationEl.replaceChildren();
const pages = Math.max(1, Math.ceil(total / PAGE));
if (pages <= 1) return;
const btn = (label: string, target: number, opts: { active?: boolean; disabled?: boolean } = {}) => {
const b = document.createElement('button');
b.textContent = label;
b.disabled = !!opts.disabled;
b.className = 'min-w-8 h-8 px-2 rounded font-label-sm text-label-sm '
+ (opts.active ? 'bg-vibrant-purple text-white ' : 'text-on-surface-variant hover:bg-surface-container ')
+ (opts.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer');
if (!opts.disabled && !opts.active) b.addEventListener('click', () => { page = target; reload(); });
return b;
};
paginationEl.appendChild(btn('‹', page - 1, { disabled: page <= 1 }));
for (const p of pageWindow(page, pages)) {
if (p === '…') {
const span = document.createElement('span');
span.textContent = '…';
span.className = 'px-1 text-on-surface-variant/60';
paginationEl.appendChild(span);
} else {
paginationEl.appendChild(btn(String(p), p, { active: p === page }));
}
}
paginationEl.appendChild(btn('›', page + 1, { disabled: page >= pages }));
}
let reqGen = 0;
async function reload() {
if (!tbody) return;
const gen = ++reqGen;
verifyResults.clear(); // rows are about to be re-rendered; drop stale results
if (selectAll) selectAll.checked = false;
if (activeModules().length === 0) {
tbody.innerHTML = emptyRow('No module selected');
setCount(0, 0, 0);
renderPagination(0);
return;
}
try {
const offset = (page - 1) * PAGE;
const url = `/api/dashboard/transactions?type=${currentType}&${toQuery()}`
+ `&sort=${sort}&dir=${dir}&limit=${PAGE}&offset=${offset}&q=${encodeURIComponent(getSearch())}`;
const res = await fetch(url, { credentials: 'include' });
if (gen !== reqGen) return; // superseded by a newer reload (tab/filter switch)
if (!res.ok) { tbody.innerHTML = emptyRow('Sign in to view transactions'); setCount(0, 0, 0); renderPagination(0); return; }
const { rows = [], total = 0 } = await res.json();
if (rows.length === 0 && total > 0 && page > 1) { page = 1; return reload(); }
tbody.innerHTML = rows.length
? (rows as Transaction[]).map(rowHtml).join('')
: emptyRow(currentType === 'audit' ? 'No audit transactions in this period' : 'No transactions in this period');
setCount(total === 0 ? 0 : offset + 1, offset + rows.length, total);
renderPagination(total);
applyColumnVisibility();
} catch (e) {
console.error('transactions', e);
tbody.innerHTML = emptyRow('Failed to load transactions');
setCount(0, 0, 0);
renderPagination(0);
toast('Could not load transactions');
}
}
coreBtn?.addEventListener('click', () => { currentType = 'core'; page = 1; styleTabs(); reload(); });
auditBtn?.addEventListener('click', () => { currentType = 'audit'; page = 1; styleTabs(); reload(); });
document.querySelectorAll('#transaction-table th[data-sort]').forEach((th) => {
th.addEventListener('click', () => {
const key = th.getAttribute('data-sort');
if (!key || !SORTABLE.includes(key)) return;
if (key === sort) dir = dir === 'asc' ? 'desc' : 'asc';
else { sort = key; dir = (key === 'date' || key === 'size') ? 'desc' : 'asc'; }
page = 1;
updateSortArrows();
reload();
});
});
async function viewJson(eventUuid: string) {
try {
const res = await fetch(`/api/dashboard/transactions/${encodeURIComponent(eventUuid)}`, { credentials: 'include' });
if (!res.ok) { openJsonModal('View JSON', { error: `Request failed (${res.status})` }); return; }
openJsonModal(`Event ${truncateUuid(eventUuid)}`, await res.json());
} catch (e) {
console.error('view-json', e);
openJsonModal('View JSON', { error: 'Failed to load event' });
}
}
// A verification targets ONE ROW. Audit rows share an event UUID, so they are
// keyed by auditId — keying them by event badges siblings too.
const rowKey = (tr: Element): string =>
(currentType === 'audit' ? tr.getAttribute('data-audit-id') : tr.getAttribute('data-event-id')) || '';
function setBadge(tr: Element, state: 'pending' | 'verified' | 'invalid' | 'nosig' | 'error', detail = '') {
const map = {
pending: ['⋯', 'text-on-surface-variant'],
verified: ['✓ Verified', 'text-teal-accent'],
invalid: ['✗ Invalid', 'text-error'],
nosig: ['— No audit', 'text-on-surface-variant'],
error: ['✗ Error', 'text-error'],
} as const;
const badge = tr.querySelector('.verify-badge') as HTMLElement | null;
const btn = tr.querySelector('.btn-verify') as HTMLElement | null;
if (!badge) return;
badge.classList.remove('hidden', 'text-error', 'text-teal-accent', 'text-on-surface-variant');
const [text, cls] = map[state];
badge.textContent = text;
badge.classList.add(cls);
badge.title = detail;
// A resolved result (verified/invalid/nosig) can be clicked to open the
// per-check popup; pending/error rows carry nothing to show.
const clickable = state === 'verified' || state === 'invalid' || state === 'nosig';
badge.classList.toggle('cursor-pointer', clickable);
badge.classList.toggle('hover:underline', clickable);
btn?.classList.toggle('hidden', state !== 'error'); // hide Verify once resolved; keep to retry on error
}
async function verifyRows(rows: Element[]) {
const isAudit = currentType === 'audit';
const byKey = new Map<string, Element>();
for (const tr of rows) {
const key = rowKey(tr);
if (key) byKey.set(key, tr);
}
if (!byKey.size) return;
byKey.forEach((tr) => setBadge(tr, 'pending'));
const keys = [...byKey.keys()];
const param = isAudit ? 'auditIds' : 'ids';
try {
const res = await fetch(`/api/dashboard/verify?type=${currentType}&${param}=${keys.join(',')}`, { credentials: 'include' });
if (!res.ok) throw new Error(`status ${res.status}`);
const { results = [] } = await res.json();
const resolved = new Set<string>();
for (const r of results) {
const key = String(isAudit ? r.auditId : r.eventUuid);
const tr = byKey.get(key);
if (!tr) continue;
resolved.add(key);
verifyResults.set(key, r); // keep for the click-to-reopen popup
if (r.status === 'no-audit') {
setBadge(tr, 'nosig', r.reason || 'no audit record');
} else if (r.status === 'verified') {
setBadge(tr, 'verified', r.auditCount > 1 ? `all ${r.auditCount} audit records passed` : 'all checks passed');
} else {
const failed = r.checks
? Object.entries(r.checks).filter(([, v]) => v === false).map(([k]) => k)
: [];
setBadge(tr, 'invalid', failed.length ? `failed: ${failed.join(', ')}` : 'verification failed');
}
}
for (const [key, tr] of byKey) if (!resolved.has(key)) setBadge(tr, 'error', 'no result returned');
} catch (e) {
console.error('verify', e);
byKey.forEach((tr) => setBadge(tr, 'error', 'verification request failed'));
toast('Verification request failed');
}
}
function showVerifyModal(tr: Element) {
const result = verifyResults.get(rowKey(tr));
const eventUuid = tr.getAttribute('data-event-id') || '';
if (result) openVerifyModal(`Verification — ${truncateUuid(eventUuid)}`, result);
}
tbody?.addEventListener('click', async (e) => {
const target = e.target as HTMLElement;
const tr = target.closest('tr');
if (!tr) return;
const agrBtn = target.closest('.btn-agreement');
if (agrBtn) {
const uuid = agrBtn.getAttribute('data-agreement-id') || '';
if (uuid) openAgreementModal(uuid);
return;
}
if (target.closest('.btn-details')) {
const eventUuid = tr.getAttribute('data-event-id') || '';
if (eventUuid) openDetailsModal(eventUuid);
} else if (target.closest('.btn-verify')) {
await verifyRows([tr]); // then open the popup so a single Verify shows the per-check breakdown
showVerifyModal(tr);
} else if (target.closest('.verify-badge')) {
showVerifyModal(tr); // reopen without re-verifying
}
});
selectAll?.addEventListener('change', () => {
tbody?.querySelectorAll('.row-check').forEach((cb) => { (cb as HTMLInputElement).checked = !!selectAll.checked; });
});
verifySelectedBtn?.addEventListener('click', () => {
const rows = [...(tbody?.querySelectorAll('.row-check') || [])]
.filter((cb) => (cb as HTMLInputElement).checked)
.map((cb) => cb.closest('tr'))
.filter((tr): tr is HTMLTableRowElement => !!tr);
verifyRows(rows);
});
validateAllBtn?.addEventListener('click', () => {
verifyRows([...(tbody?.querySelectorAll('tr[data-event-id]') || [])]);
});
onFilters(() => { page = 1; syncTabs(); reload(); });
onSearch(() => { page = 1; reload(); });
// Resolve the viewer's permission before the first render so users without it
// never see the View JSON button. Failure leaves the button visible; the server
// still 403s.
try {
const who = await fetch('/api/dashboard/auth/whoami', { credentials: 'include' });
if (who.ok) {
const { canViewJson } = await who.json();
mayViewJson = !!canViewJson;
}
} catch { /* keep default; server enforces regardless */ }
syncTabs();
updateSortArrows();
reload();
</script>

View File

@@ -0,0 +1,108 @@
---
---
<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>

View File

@@ -0,0 +1,210 @@
---
import astroLogo from '../assets/astro.svg';
import background from '../assets/background.svg';
---
<div id="container">
<img id="background" src={background.src} alt="" fetchpriority="high" />
<main>
<section id="hero">
<a href="https://astro.build"
><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
>
<h1>
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
</h1>
<section id="links">
<a class="button" href="https://docs.astro.build">Read our docs</a>
<a href="https://astro.build/chat"
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
><path
fill="currentColor"
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
></path></svg
>
</a>
</section>
</section>
</main>
<a href="https://astro.build/blog/astro-6-beta/" id="news" class="box">
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
><path
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
fill="#111827"></path></svg
>
<h2>What's New in Astro 6.0?</h2>
<p>
Redesigned dev server, fonts, live collections, built-in CSP support, and more! Click to
explore Astro 6.0's new features.
</p>
</a>
</div>
<style>
#background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
filter: blur(100px);
}
#container {
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
height: 100%;
}
main {
height: 100%;
display: flex;
justify-content: center;
}
#hero {
display: flex;
align-items: start;
flex-direction: column;
justify-content: center;
padding: 16px;
}
h1 {
font-size: 22px;
margin-top: 0.25em;
}
#links {
display: flex;
gap: 16px;
}
#links a {
display: flex;
align-items: center;
padding: 10px 12px;
color: #111827;
text-decoration: none;
transition: color 0.2s;
}
#links a:hover {
color: rgb(78, 80, 86);
}
#links a svg {
height: 1em;
margin-left: 8px;
}
#links a.button {
color: white;
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
border-radius: 10px;
}
#links a.button:hover {
color: rgb(230, 230, 230);
box-shadow: none;
}
pre {
font-family:
ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
monospace;
font-weight: normal;
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
h2 {
margin: 0 0 1em;
font-weight: normal;
color: #111827;
font-size: 20px;
}
p {
color: #4b5563;
font-size: 16px;
line-height: 24px;
letter-spacing: -0.006em;
margin: 0;
}
code {
display: inline-block;
background:
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
border-radius: 8px;
border: 1px solid transparent;
padding: 6px 8px;
}
.box {
padding: 16px;
background: rgba(255, 255, 255, 1);
border-radius: 16px;
border: 1px solid white;
}
#news {
position: absolute;
bottom: 16px;
right: 16px;
max-width: 300px;
text-decoration: none;
transition: background 0.2s;
backdrop-filter: blur(50px);
}
#news:hover {
background: rgba(255, 255, 255, 0.55);
}
@media screen and (max-height: 368px) {
#news {
display: none;
}
}
@media screen and (max-width: 768px) {
#container {
display: flex;
flex-direction: column;
}
#hero {
display: block;
padding-top: 10%;
}
#links {
flex-wrap: wrap;
}
#links a.button {
padding: 14px 18px;
}
#news {
right: 16px;
left: 16px;
bottom: 2.5rem;
max-width: 100%;
}
h1 {
line-height: 1.5;
}
}
</style>

View File

@@ -0,0 +1,27 @@
---
import '../styles/global.css';
import '../styles/fonts.css';
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>JLINC Dashboard</title>
<!-- optional white-label overrides; last so it wins, 404s harmlessly if unmounted -->
<link rel="stylesheet" href="/custom.css">
</head>
<body>
<slot />
</body>
</html>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,69 @@
---
import Layout from '../layouts/Layout.astro';
import Sidebar from '../components/Sidebar.astro';
import AppBar from '../components/AppBar.astro';
import SummaryCards from '../components/SummaryCards.astro';
import FilterBar from '../components/FilterBar.astro';
import UsageChart from '../components/UsageChart.astro';
import TransactionsTable from '../components/TransactionsTable.astro';
import ApiKeys from '../components/ApiKeys.astro';
import { eeSlot } from '../resources/ee';
const eeNav = eeSlot('nav');
---
<Layout>
<body class="bg-background text-on-background font-body-md overflow-x-hidden">
<!-- Side Navigation Shell -->
<Sidebar />
<!-- Main Content Area -->
<main class="ml-0 lg:ml-65 min-h-screen flex flex-col">
<!-- Top App Bar Shell -->
<AppBar />
<!-- Dashboard Canvas -->
<div id="dashboard-view" class="p-lg max-w-400px w-full mx-auto space-y-lg pb-24 lg:pb-lg">
<SummaryCards />
<FilterBar />
<UsageChart />
<TransactionsTable />
</div>
<!-- API Keys View -->
<ApiKeys />
<!-- Bottom Navigation Mobile Only -->
<nav class="docked full-width bottom-0 fixed lg:hidden z-50 flex justify-around items-center w-full h-16 bg-surface-container-lowest border-t border-surface-border shadow-lg pb-safe">
<a class="flex flex-col items-center justify-center text-vibrant-purple font-bold active:scale-95 duration-75" href="#">
<span class="material-symbols-outlined" data-icon="home">home</span>
<span class="font-label-sm-mobile text-label-sm-mobile">Home</span>
</a>
<a class="flex flex-col items-center justify-center text-on-surface-variant active:scale-95 duration-75" href="#">
<span class="material-symbols-outlined" data-icon="analytics">analytics</span>
<span class="font-label-sm-mobile text-label-sm-mobile">Usage</span>
</a>
<a class="flex flex-col items-center justify-center text-on-surface-variant active:scale-95 duration-75" href="#">
<span class="material-symbols-outlined" data-icon="list_alt">list_alt</span>
<span class="font-label-sm-mobile text-label-sm-mobile">Logs</span>
</a>
{eeNav.map((Item) => <Item />)}
</nav>
</main>
<script>
// Sidebar view switch: Dashboard <-> API Keys (single-page).
const navDashboard = document.getElementById('nav-dashboard');
const navKeys = document.getElementById('nav-api-keys');
const dashView = document.getElementById('dashboard-view');
const keysView = document.getElementById('api-keys-view');
function show(view: string) {
const keys = view === 'keys';
dashView?.classList.toggle('hidden', keys);
keysView?.classList.toggle('hidden', !keys);
navDashboard?.classList.toggle('bg-vibrant-purple/10', !keys);
navKeys?.classList.toggle('bg-vibrant-purple/10', keys);
window.dispatchEvent(new CustomEvent('dashboard:view', { detail: view }));
}
navDashboard?.addEventListener('click', (e) => { e.preventDefault(); show('dashboard'); });
navKeys?.addEventListener('click', (e) => { e.preventDefault(); show('keys'); });
</script>
</body>
</Layout>

View File

@@ -0,0 +1,126 @@
function close() {
document.getElementById('agreement-modal')?.remove();
document.removeEventListener('keydown', onKey);
}
function onKey(e) { if (e.key === 'Escape') close(); }
const TIMEOUT_MS = 15000;
const MD_CSS = `
.agreement-md{color:#191c1e;line-height:1.5}
.agreement-md h1{font-size:1.5rem;font-weight:700;margin:0 0 .5rem}
.agreement-md h2{font-size:1.15rem;font-weight:600;margin:1rem 0 .35rem}
.agreement-md p{margin:.5rem 0}
.agreement-md ul,.agreement-md ol{margin:.5rem 0;padding-left:1.25rem}
.agreement-md li{margin:.15rem 0}
.agreement-md a{color:#4F378B;text-decoration:underline}
.agreement-md table{border-collapse:collapse;margin:.5rem 0}
.agreement-md td,.agreement-md th{border:1px solid #cbc4d2;padding:4px 8px}
.agreement-md code{background:#eee;padding:1px 4px;border-radius:3px}`;
function ensureStyle() {
if (document.getElementById('agreement-md-style')) return;
const s = document.createElement('style');
s.id = 'agreement-md-style';
s.textContent = MD_CSS;
document.head.appendChild(s);
}
// Strip anything executable before the markdown HTML is inserted: dangerous
// elements, inline event handlers, and javascript: URLs. The content is
// markdown rendered server-side, but a cached remote agreement is only
// semi-trusted, so we never hand it raw to innerHTML.
function sanitize(html) {
const tpl = document.createElement('template');
tpl.innerHTML = html;
tpl.content.querySelectorAll('script,style,iframe,object,embed,link,meta,form,svg,math').forEach((el) => el.remove());
tpl.content.querySelectorAll('*').forEach((el) => {
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
const val = attr.value.replace(/\s+/g, '').toLowerCase();
if (name.startsWith('on') || ((name === 'href' || name === 'src') && val.startsWith('javascript:'))) {
el.removeAttribute(attr.name);
}
}
});
return tpl.innerHTML;
}
export async function openAgreementModal(agreementUuid) {
close();
const overlay = document.createElement('div');
overlay.id = 'agreement-modal';
overlay.className = 'fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-lg';
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const panel = document.createElement('div');
panel.className =
'bg-white w-full max-w-3xl max-h-[80vh] rounded-lg border ' +
'border-surface-border shadow-xl flex flex-col overflow-hidden';
const header = document.createElement('div');
header.className = 'flex items-center justify-between px-lg py-md border-b border-surface-border';
const heading = document.createElement('h3');
heading.className = 'font-headline-sm text-headline-sm text-deep-indigo';
heading.textContent = 'Agreement';
const actions = document.createElement('div');
actions.className = 'flex items-center gap-md';
const retryBtn = document.createElement('button');
retryBtn.className = 'hidden text-vibrant-purple hover:underline font-label-md text-label-md';
retryBtn.textContent = 'Retry';
const closeBtn = document.createElement('button');
closeBtn.className = 'material-symbols-outlined text-on-surface-variant hover:text-primary cursor-pointer';
closeBtn.textContent = 'close';
closeBtn.addEventListener('click', close);
actions.append(retryBtn, closeBtn);
header.append(heading, actions);
const body = document.createElement('div');
body.className = 'flex-1 overflow-auto p-lg';
panel.append(header, body);
overlay.appendChild(panel);
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
const message = (text, isError) =>
`<p class="font-label-md text-label-md ${isError ? 'text-error' : 'text-on-surface-variant'}">${text}</p>`;
// Always leaves the body in a visible state — loading, content, empty, or a
// clear error with Retry — so a slow/failed request can never show a blank.
async function load() {
retryBtn.classList.add('hidden');
body.innerHTML = message('Loading…');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(`/api/dashboard/agreement/${encodeURIComponent(agreementUuid)}`, {
credentials: 'include',
signal: controller.signal,
});
if (!res.ok) throw new Error(`status ${res.status}`);
const { title, html } = await res.json();
if (title) heading.textContent = title;
const clean = sanitize(html || '');
if (clean.trim()) {
ensureStyle();
body.innerHTML = `<div class="agreement-md">${clean}</div>`;
} else {
body.innerHTML = message('This agreement has no content.');
}
} catch (e) {
console.error('agreement', e);
const msg = e.name === 'AbortError'
? 'This is taking longer than expected.'
: 'Could not load the agreement.';
body.innerHTML = message(msg, true);
retryBtn.classList.remove('hidden');
} finally {
clearTimeout(timer);
}
}
retryBtn.addEventListener('click', load);
await load();
}

View File

@@ -0,0 +1,140 @@
import { openJsonModal } from './jsonModal.js';
import { openAgreementModal } from './agreementModal.js';
import { truncateText } from './formatters.js';
import { toast } from './toast.js';
function close() {
document.getElementById('details-modal')?.remove();
document.removeEventListener('keydown', onKey);
}
function onKey(e) { if (e.key === 'Escape') close(); }
// A sender/receiver box: role label, full short name, faded DID underneath.
function party(who, accent, roleColor) {
return `
<div class="bg-surface-container-low rounded-lg p-md border-l-4 ${accent}">
<div class="flex items-center gap-xs mb-xs">
<span class="font-label-sm text-label-sm uppercase tracking-wider text-on-surface-variant">${who}</span>
<span id="d-${who}-role" class="font-label-sm text-label-sm ${roleColor}"></span>
</div>
<div id="d-${who}-name" class="font-body-md text-body-md font-medium text-on-surface break-all"></div>
<div id="d-${who}-did" class="font-label-sm text-label-sm text-on-surface-variant/50 break-all"></div>
</div>`;
}
function shell() {
return `
<div class="flex items-center justify-between px-lg py-md border-b border-surface-border">
<h3 class="font-headline-sm text-headline-sm text-deep-indigo">Details</h3>
<button id="d-close" class="material-symbols-outlined text-on-surface-variant hover:text-primary cursor-pointer">close</button>
</div>
<div class="overflow-auto p-lg space-y-lg">
${party('sender', 'border-vibrant-purple', 'text-vibrant-purple')}
<pre id="d-data" class="bg-white border border-surface-border rounded-lg p-md text-label-sm font-label-sm overflow-auto max-h-[40vh] whitespace-pre"></pre>
${party('receiver', 'border-teal-accent', 'text-teal-accent')}
<div class="flex items-center justify-between gap-md border-t border-surface-border pt-md">
<div id="d-title" class="font-headline-sm text-headline-sm text-deep-indigo"></div>
<button id="d-read" class="hidden text-vibrant-purple hover:underline font-label-md text-label-md whitespace-nowrap">Read agreement →</button>
</div>
<div class="grid grid-cols-2 gap-lg">
<div>
<div class="font-label-sm text-label-sm text-on-surface-variant uppercase tracking-wider mb-xs">Permitted</div>
<ul id="d-permitted" class="space-y-1 text-label-sm font-label-sm text-teal-accent"></ul>
</div>
<div>
<div class="font-label-sm text-label-sm text-on-surface-variant uppercase tracking-wider mb-xs">Prohibited</div>
<ul id="d-prohibited" class="space-y-1 text-label-sm font-label-sm text-error"></ul>
</div>
</div>
<div class="flex justify-end border-t border-surface-border pt-md">
<button id="d-raw" class="text-primary hover:underline font-label-md text-label-md">View raw JSON</button>
</div>
</div>`;
}
function fillList(ul, items) {
ul.replaceChildren();
if (!items || !items.length) {
const li = document.createElement('li');
li.className = 'text-on-surface-variant/60 italic';
li.textContent = 'None';
ul.appendChild(li);
return;
}
for (const item of items) {
const li = document.createElement('li');
li.textContent = `• ${item}`;
ul.appendChild(li);
}
}
// Primary line is the short name; the DID sits underneath, faded. With no known
// short name the DID becomes the primary line (no duplicate underneath).
function setParty(panel, who, role, name, did) {
panel.querySelector(`#d-${who}-role`).textContent = role || 'no role';
const nameEl = panel.querySelector(`#d-${who}-name`);
const didEl = panel.querySelector(`#d-${who}-did`);
if (name) {
nameEl.textContent = name;
didEl.textContent = did || '';
didEl.title = did || '';
didEl.classList.toggle('hidden', !did);
} else {
nameEl.textContent = did || '—';
didEl.classList.add('hidden');
}
}
export async function openDetailsModal(eventUuid) {
close();
const overlay = document.createElement('div');
overlay.id = 'details-modal';
overlay.className = 'fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-lg';
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const panel = document.createElement('div');
panel.className =
'bg-surface-container-lowest w-[80vw] max-w-none max-h-[80vh] rounded-lg border ' +
'border-surface-border shadow-xl flex flex-col overflow-hidden';
panel.innerHTML = shell();
overlay.appendChild(panel);
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
panel.querySelector('#d-close').addEventListener('click', close);
let data;
try {
const res = await fetch(`/api/dashboard/details/${encodeURIComponent(eventUuid)}`, { credentials: 'include' });
if (!res.ok) throw new Error(`status ${res.status}`);
data = await res.json();
} catch (e) {
console.error('details', e);
close();
toast('Could not load details');
return;
}
const { event = {}, roles = {}, names = {}, agreement = {} } = data;
setParty(panel, 'sender', roles.sender, names.sender, event.senderId);
setParty(panel, 'receiver', roles.receiver, names.receiver, event.recipientId);
panel.querySelector('#d-data').textContent = JSON.stringify(event.data ?? {}, null, 2);
panel.querySelector('#d-title').textContent =
agreement.title || (event.agreementId ? `Agreement ${truncateText(event.agreementId, 12)}` : 'No agreement');
const read = panel.querySelector('#d-read');
if (agreement.title) {
read.classList.remove('hidden');
read.addEventListener('click', () => openAgreementModal(event.agreementId));
}
fillList(panel.querySelector('#d-permitted'), agreement.permitted);
fillList(panel.querySelector('#d-prohibited'), agreement.prohibited);
panel.querySelector('#d-raw').addEventListener('click', () => openJsonModal(`Event ${truncateText(event.eventId, 18)}`, event));
}

View File

@@ -0,0 +1,14 @@
export type EeSlot =
| 'cards'
| 'sidebar'
| 'nav'
| 'toolbar';
const modules = import.meta.glob('../ee/*/*.astro', { eager: true });
export function eeSlot(slot: EeSlot) {
return Object.entries(modules)
.filter(([path]) => path.includes(`/ee/${slot}/`))
.sort(([a], [b]) => a.localeCompare(b))
.map(([, mod]) => (mod as { default: any }).default);
}

View File

@@ -0,0 +1,85 @@
// Shared filter state that every panel reads from and reacts to.
const KEY = 'dashboard.filters';
const EVENT = 'dashboard:filters';
function isoDay(d) {
return d.toISOString().slice(0, 10);
}
function defaults() {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 29);
return { from: isoDay(from), to: isoDay(to), core: true, audit: true };
}
function load() {
try {
const saved = JSON.parse(localStorage.getItem(KEY) || 'null');
return saved ? { ...defaults(), ...saved } : defaults();
} catch {
return defaults();
}
}
let state = load();
export function getFilters() {
return { ...state };
}
export function setFilters(patch, { persist = false } = {}) {
state = { ...state, ...patch };
if (persist) {
try {
localStorage.setItem(KEY, JSON.stringify(state));
} catch {
/* ignore quota/availability errors */
}
}
window.dispatchEvent(new CustomEvent(EVENT, { detail: getFilters() }));
}
export function onFilters(cb) {
window.addEventListener(EVENT, (e) => cb(e.detail));
}
export function activeModules() {
const modules = [];
if (state.core) modules.push('core');
if (state.audit) modules.push('audit');
return modules;
}
// Split out from toQuery() so it unit-tests without DOM/localStorage state.
export function buildQuery({ from, to, modules }, extra = {}) {
return new URLSearchParams({
from,
to,
modules: (modules && modules.length ? modules.join(',') : 'none'),
...extra,
}).toString();
}
export function toQuery(extra = {}) {
const f = getFilters();
return buildQuery({ from: f.from, to: f.to, modules: activeModules() }, extra);
}
// Transient and separate, so a keystroke doesn't refetch the chart/summary.
const SEARCH_EVENT = 'dashboard:search';
let search = '';
export function getSearch() {
return search;
}
export function setSearch(q) {
search = q || '';
window.dispatchEvent(new CustomEvent(SEARCH_EVENT, { detail: search }));
}
export function onSearch(cb) {
window.addEventListener(SEARCH_EVENT, (e) => cb(e.detail));
}

View File

@@ -0,0 +1,21 @@
import { buildQuery } from "./filters.js";
describe("buildQuery", () => {
it("serializes the window + active modules", () => {
const q = buildQuery({ from: "2026-01-01", to: "2026-01-31", modules: ["core", "audit"] });
const p = new URLSearchParams(q);
expect(p.get("from")).toBe("2026-01-01");
expect(p.get("to")).toBe("2026-01-31");
expect(p.get("modules")).toBe("core,audit");
});
it("emits modules=none when nothing is active (server still gets a valid value)", () => {
expect(new URLSearchParams(buildQuery({ from: "a", to: "b", modules: [] })).get("modules")).toBe("none");
});
it("merges extra params (e.g. paging/sort) into the query", () => {
const p = new URLSearchParams(buildQuery({ from: "a", to: "b", modules: ["core"] }, { sort: "date", limit: "50" }));
expect(p.get("sort")).toBe("date");
expect(p.get("limit")).toBe("50");
});
});

View File

@@ -0,0 +1,38 @@
export function formatCompact(n) {
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(n || 0);
}
export function formatBytes(bytes) {
if (!bytes || bytes <= 0) return '0 Bytes'; // negatives would yield "NaN undefined"
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
export function truncateUuid(uuid) {
if (!uuid) return '';
return uuid.substring(0, 10) + '...';
}
export function truncateText(text, max = 28) {
if (!text) return '';
return text.length > max ? text.slice(0, max) + '…' : text;
}
export function formatDate(dateString) {
if (!dateString) return '—';
const d = new Date(dateString);
if (isNaN(d.getTime())) return '—';
const pad = (num) => String(num).padStart(2, '0');
const year = d.getFullYear();
const month = pad(d.getMonth() + 1);
const day = pad(d.getDate());
const hours = pad(d.getHours());
const minutes = pad(d.getMinutes());
const seconds = pad(d.getSeconds());
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
}

View File

@@ -0,0 +1,43 @@
import { formatCompact, formatBytes, truncateUuid, formatDate } from "./formatters.js";
describe("formatBytes", () => {
it("formats zero, falsy, and negatives as '0 Bytes' (never 'NaN undefined')", () => {
expect(formatBytes(0)).toBe("0 Bytes");
expect(formatBytes(undefined)).toBe("0 Bytes");
expect(formatBytes(-500)).toBe("0 Bytes");
});
it("scales to the right unit", () => {
expect(formatBytes(1024)).toBe("1 KB");
expect(formatBytes(1536)).toBe("1.5 KB");
expect(formatBytes(1048576)).toBe("1 MB");
});
});
describe("truncateUuid", () => {
it("returns '' for empty and a 10-char prefix otherwise", () => {
expect(truncateUuid("")).toBe("");
expect(truncateUuid(null)).toBe("");
expect(truncateUuid("19c3680c-aaaa-bbbb-cccc-dddddddddddd")).toBe("19c3680c-a...");
});
});
describe("formatCompact", () => {
it("compacts large numbers", () => {
expect(formatCompact(0)).toBe("0");
expect(formatCompact(2591)).toBe("2.6K");
expect(formatCompact(1_300_000)).toBe("1.3M");
});
});
describe("formatDate", () => {
it("renders YYYY/MM/DD HH:MM:SS", () => {
expect(formatDate("2026-06-01T12:34:56Z")).toMatch(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/);
});
it("renders an em dash for missing or unparseable input (never NaN)", () => {
expect(formatDate(null)).toBe("—");
expect(formatDate(undefined)).toBe("—");
expect(formatDate("not-a-date")).toBe("—");
});
});

View File

@@ -0,0 +1,63 @@
// Reusable modal for inspecting raw JSON (e.g. the "View JSON" row action).
// Renders the payload with textContent only — never innerHTML — so untrusted
// event data can't inject markup.
function close() {
document.getElementById('json-modal')?.remove();
document.removeEventListener('keydown', onKey);
}
function onKey(e) {
if (e.key === 'Escape') close();
}
/**
* Open a modal showing pretty-printed JSON.
* @param {string} title
* @param {unknown} data Any JSON-serializable value (or a raw string).
*/
export function openJsonModal(title, data) {
close(); // keep a single instance
const overlay = document.createElement('div');
overlay.id = 'json-modal';
overlay.className = 'fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-lg';
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
const panel = document.createElement('div');
panel.className =
'bg-surface-container-lowest w-full max-w-2xl max-h-[80vh] rounded-lg border ' +
'border-surface-border shadow-xl flex flex-col overflow-hidden';
const header = document.createElement('div');
header.className = 'flex items-center justify-between px-lg py-md border-b border-surface-border';
const heading = document.createElement('h3');
heading.className = 'font-headline-sm text-headline-sm text-deep-indigo';
heading.textContent = title;
const closeBtn = document.createElement('button');
closeBtn.className = 'material-symbols-outlined text-on-surface-variant hover:text-primary cursor-pointer';
closeBtn.textContent = 'close';
closeBtn.addEventListener('click', close);
header.append(heading, closeBtn);
// `whitespace-pre` (not pre-wrap): wrapping a multi-MB single-line payload
// freezes the main thread. Also cap the rendered text so a huge event_data
// blob can't lock up the page — show a truncation note instead.
const RENDER_LIMIT = 200_000;
const full = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
const body = document.createElement('pre');
body.className = 'overflow-auto p-lg text-label-sm font-label-sm text-on-surface whitespace-pre';
body.textContent = full.length > RENDER_LIMIT
? `${full.slice(0, RENDER_LIMIT)}\n\n… truncated — ${Math.round(full.length / 1024)} KB total`
: full;
panel.append(header, body);
overlay.appendChild(panel);
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
}

View File

@@ -0,0 +1,163 @@
import * as d3 from 'd3';
// Reusable primitive: treat labels/colors as untrusted in the tooltip HTML.
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/**
* Reusable, responsive multi-series time-series line chart with optional dual
* Y-axes and a hover tooltip. The one chart primitive the dashboard reuses.
*
* @param {HTMLElement} el Container element (gets `position: relative`).
* @param {object} opts
* @param {Array<{date: string} & Record<string, number>>} opts.data
* Rows oldest→newest; `date` is `YYYY-MM-DD`, plus one numeric field per series key.
* @param {Array<{key: string, label: string, color: string, dashed?: boolean, axis?: 'left'|'right'}>} opts.series
* @param {(n: number) => string} [opts.formatLeft] Left-axis / tooltip formatter (default SI).
* @param {(n: number) => string} [opts.formatRight] Right-axis / tooltip formatter (default SI).
* @returns {{ destroy: () => void }}
*/
export function timeSeriesChart(el, { data, series, formatLeft = d3.format('~s'), formatRight = d3.format('~s') }) {
const hasRight = series.some((s) => s.axis === 'right');
const margin = { top: 16, right: hasRight ? 56 : 24, bottom: 28, left: 48 };
const parseDate = d3.utcParse('%Y-%m-%d');
const rows = data.map((d) => ({ ...d, _date: parseDate(d.date) }));
const fmt = (s) => (s.axis === 'right' ? formatRight : formatLeft);
el.style.position = 'relative';
const tip = document.createElement('div');
tip.className =
'pointer-events-none absolute z-10 hidden rounded-lg border border-surface-border ' +
'bg-surface-container-lowest px-sm py-xs shadow-lg space-y-0.5';
el.appendChild(tip);
const svg = d3.select(el).append('svg').attr('width', '100%');
const bisect = d3.bisector((d) => d._date).center;
let observer;
function scaleFor(axis, height) {
const keys = series.filter((s) => (s.axis === 'right') === (axis === 'right')).map((s) => s.key);
const max = d3.max(rows, (d) => d3.max(keys, (k) => d[k])) || 1;
return d3.scaleLinear().domain([0, max]).nice().range([height - margin.bottom, margin.top]);
}
function styleTicks(g) {
g.selectAll('.tick text').attr('fill', '#494551').attr('font-family', 'JetBrains Mono').attr('font-size', 11);
}
function render() {
const width = el.clientWidth || 640;
const height = el.clientHeight || 320;
svg.attr('height', height).selectAll('*').remove();
const x = d3.scaleUtc().domain(d3.extent(rows, (d) => d._date)).range([margin.left, width - margin.right]);
const yLeft = scaleFor('left', height);
const yRight = hasRight ? scaleFor('right', height) : null;
const yFor = (s) => (s.axis === 'right' ? yRight : yLeft);
// Left axis + horizontal grid
svg
.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(d3.axisLeft(yLeft).ticks(5).tickSize(-(width - margin.left - margin.right)).tickFormat(formatLeft))
.call((g) => g.select('.domain').remove())
.call((g) => g.selectAll('.tick line').attr('stroke', '#E2E8F0').attr('stroke-dasharray', '2,2'))
.call(styleTicks);
// Right axis (no grid)
if (hasRight) {
svg
.append('g')
.attr('transform', `translate(${width - margin.right},0)`)
.call(d3.axisRight(yRight).ticks(5).tickFormat(formatRight))
.call((g) => g.select('.domain').remove())
.call((g) => g.selectAll('.tick line').remove())
.call(styleTicks);
}
// X axis
svg
.append('g')
.attr('transform', `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).ticks(6).tickFormat(d3.utcFormat('%b %d')).tickSizeOuter(0))
.call((g) => g.select('.domain').attr('stroke', '#E2E8F0'))
.call((g) => g.selectAll('.tick line').attr('stroke', '#E2E8F0'))
.call(styleTicks);
// Lines
for (const s of series) {
const y = yFor(s);
svg
.append('path')
.datum(rows)
.attr('fill', 'none')
.attr('stroke', s.color)
.attr('stroke-width', s.key === 'total' ? 3 : 2)
.attr('stroke-dasharray', s.dashed ? '5,4' : null)
.attr('d', d3.line().x((d) => x(d._date)).y((d) => y(d[s.key])).curve(d3.curveMonotoneX));
}
// Hover layer
const focus = svg.append('g').style('display', 'none');
focus
.append('line')
.attr('y1', margin.top)
.attr('y2', height - margin.bottom)
.attr('stroke', '#cbc4d2')
.attr('stroke-dasharray', '3,3');
const dots = series.map((s) =>
focus.append('circle').attr('r', 4).attr('fill', s.color).attr('stroke', '#fff').attr('stroke-width', 1.5)
);
svg
.append('rect')
.attr('x', margin.left)
.attr('y', margin.top)
.attr('width', Math.max(0, width - margin.left - margin.right))
.attr('height', Math.max(0, height - margin.top - margin.bottom))
.attr('fill', 'transparent')
.on('mouseenter', () => {
focus.style('display', null);
tip.classList.remove('hidden');
})
.on('mouseleave', () => {
focus.style('display', 'none');
tip.classList.add('hidden');
})
.on('mousemove', (event) => {
const mx = d3.pointer(event)[0];
const d = rows[bisect(rows, x.invert(mx))];
if (!d) return;
focus.select('line').attr('x1', x(d._date)).attr('x2', x(d._date));
series.forEach((s, i) => dots[i].attr('cx', x(d._date)).attr('cy', yFor(s)(d[s.key])));
tip.innerHTML =
`<div class="text-label-sm font-label-sm text-on-surface-variant mb-1">${d3.utcFormat('%b %d, %Y')(d._date)}</div>` +
series
.map(
(s) =>
`<div class="flex items-center gap-xs text-label-sm font-label-sm whitespace-nowrap">` +
`<span class="inline-block w-2 h-2 rounded-full" style="background:${esc(s.color)}"></span>` +
`${esc(s.label)}: <span class="font-medium">${fmt(s)(d[s.key])}</span></div>`
)
.join('');
const left = Math.min(x(d._date) + 12, width - tip.offsetWidth - 8);
tip.style.left = `${Math.max(margin.left, left)}px`;
tip.style.top = `${margin.top}px`;
});
}
render();
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(render);
observer.observe(el);
}
return {
destroy() {
observer?.disconnect();
svg.remove();
tip.remove();
},
};
}

View File

@@ -0,0 +1,26 @@
// Minimal toast notifications — used to surface fetch/load errors instead of
// failing silently to the console.
function host() {
let el = document.getElementById('toast-host');
if (!el) {
el = document.createElement('div');
el.id = 'toast-host';
el.className = 'fixed bottom-4 right-4 z-[200] flex flex-col gap-2 pointer-events-none';
document.body.appendChild(el);
}
return el;
}
/**
* @param {string} message
* @param {'error'|'info'} [type]
*/
export function toast(message, type = 'error') {
const colors = type === 'error' ? 'border-error text-error' : 'border-surface-border text-on-surface';
const el = document.createElement('div');
el.className = `bg-surface-container-lowest border ${colors} rounded-lg shadow-lg px-md py-sm text-label-md font-label-md`;
el.textContent = message;
host().appendChild(el);
setTimeout(() => el.remove(), 4000);
}

View File

@@ -0,0 +1,104 @@
const CHECKS = [
['validId', 'The IDs match between the audit and originating record'],
['validSignature', 'The signature on the original record is valid'],
['validAuditHash', 'The hash from the audit record matches the originating signed record'],
['validAuditSignature', 'The signature on the audit record is valid'],
['validMatchingDids', 'The DIDs that signed match on the original and audit records'],
['validEventAgreement', 'If an event, it contains a valid agreement'],
['validEventAgreementSignature', 'The agreement has been signed by the DID that signed the originating record'],
];
function close() {
document.getElementById('verify-modal')?.remove();
document.removeEventListener('keydown', onKey);
}
function onKey(e) {
if (e.key === 'Escape') close();
}
// One check row: status icon + description. `value` is true / false / undefined
// (undefined = the verifier didn't report this check, e.g. agreement checks on a
// non-event record) — shown as a muted "N/A" so it reads differently from a fail.
function checkRow(description, value) {
const row = document.createElement('div');
row.className = 'flex items-start gap-sm px-lg py-sm border-b border-surface-border last:border-b-0';
const icon = document.createElement('span');
icon.className = 'material-symbols-outlined text-[20px] shrink-0';
if (value === true) {
icon.textContent = 'check_circle';
icon.classList.add('text-teal-accent');
} else if (value === false) {
icon.textContent = 'cancel';
icon.classList.add('text-error');
} else {
icon.textContent = 'remove';
icon.classList.add('text-on-surface-variant');
}
const text = document.createElement('span');
text.className = 'font-label-md text-label-md text-on-surface';
text.textContent = description;
const state = document.createElement('span');
state.className = 'ml-auto pl-md font-label-sm text-label-sm shrink-0 ' +
(value === true ? 'text-teal-accent' : value === false ? 'text-error' : 'text-on-surface-variant');
state.textContent = value === true ? 'Pass' : value === false ? 'Fail' : 'N/A';
row.append(icon, text, state);
return row;
}
/**
* Open the verification-detail modal for one event.
* @param {string} title
* @param {{ verified?: boolean, signatureCount?: number, checks?: Record<string, boolean> | null, reason?: string }} result
*/
export function openVerifyModal(title, result) {
close(); // single instance
const overlay = document.createElement('div');
overlay.id = 'verify-modal';
overlay.className = 'fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-lg';
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
const panel = document.createElement('div');
panel.className =
'bg-surface-container-lowest w-full max-w-2xl max-h-[80vh] rounded-lg border ' +
'border-surface-border shadow-xl flex flex-col overflow-hidden';
const header = document.createElement('div');
header.className = 'flex items-center justify-between px-lg py-md border-b border-surface-border';
const heading = document.createElement('h3');
heading.className = 'font-headline-sm text-headline-sm text-deep-indigo';
heading.textContent = title;
const closeBtn = document.createElement('button');
closeBtn.className = 'material-symbols-outlined text-on-surface-variant hover:text-primary cursor-pointer';
closeBtn.textContent = 'close';
closeBtn.addEventListener('click', close);
header.append(heading, closeBtn);
const body = document.createElement('div');
body.className = 'overflow-auto';
const checks = result?.checks;
if (!checks) {
const empty = document.createElement('p');
empty.className = 'px-lg py-xl text-center font-label-md text-label-md text-on-surface-variant';
empty.textContent = result?.reason || 'No audit record — nothing to verify.';
body.appendChild(empty);
} else {
for (const [key, description] of CHECKS) body.appendChild(checkRow(description, checks[key]));
}
panel.append(header, body);
overlay.appendChild(panel);
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
}

View File

@@ -0,0 +1,179 @@
/* Self-hosted fonts (downloaded from Google Fonts, served locally). */
/* latin-ext */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxQKYbABA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
font-display: swap;
src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxQKYbABA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
font-display: swap;
src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxQKYbABA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxQKYbABA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(/fonts/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYY.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwhsk.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwhsk.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* fallback */
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 100 700;
font-display: swap;
src: url(/fonts/kJEhBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oFsI.woff2) format('woff2');
}

View File

@@ -0,0 +1,145 @@
@import "tailwindcss";
@theme {
--color-primary-fixed-dim: #cfbcff;
--color-error-container: #ffdad6;
--color-on-tertiary-container: #bdade2;
--color-tertiary-container: #4c3f6c;
--color-on-background: #191c1e;
--color-outline: #7a7582;
--color-surface-dim: #d9dadc;
--color-on-primary-container: #c0a8ff;
--color-secondary-fixed-dim: #68d6e7;
--color-secondary: #006874;
--color-on-secondary-fixed-variant: #004f58;
--color-tertiary: #362954;
--color-inverse-primary: #cfbcff;
--color-secondary-container: #80ecfe;
--color-primary: #381e73;
--color-vibrant-purple: #4F378B;
--color-primary-container: #4f378b;
--color-on-tertiary-fixed-variant: #4c3f6c;
--color-teal-accent: #31A9BA;
--color-on-surface: #191c1e;
--color-primary-fixed: #e9ddff;
--color-error: #ba1a1a;
--color-deep-indigo: #231641;
--color-outline-variant: #cbc4d2;
--color-on-secondary-container: #006b77;
--color-on-error: #ffffff;
--color-inverse-surface: #2e3132;
--color-surface-container-low: #f2f4f6;
--color-on-tertiary: #ffffff;
--color-surface-container-highest: #e1e2e4;
--color-on-secondary: #ffffff;
--color-on-surface-variant: #494551;
--color-on-primary-fixed-variant: #4f378b;
--color-secondary-fixed: #9af0ff;
--color-surface-container-high: #e7e8ea;
--color-surface-tint: #674fa5;
--color-surface-border: #E2E8F0;
--color-on-primary: #ffffff;
--color-on-error-container: #93000a;
--color-surface-variant: #e1e2e4;
--color-inverse-on-surface: #f0f1f3;
--color-on-tertiary-fixed: #20133e;
--color-background: #f8f9fb;
--color-on-primary-fixed: #22005d;
--color-on-secondary-fixed: #001f24;
--color-tertiary-fixed: #e9ddff;
--color-surface: #f8f9fb;
--color-surface-container-lowest: #ffffff;
--color-surface-bright: #f8f9fb;
--color-tertiary-fixed-dim: #cfbef4;
--color-surface-container: #edeef0;
--spacing-md: 16px;
--spacing-container-max: 1440px;
--spacing-gutter: 24px;
--spacing-sm: 8px;
--spacing-base: 4px;
--spacing-lg: 24px;
--spacing-xs: 4px;
--spacing-xl: 32px;
--font-label-md: "JetBrains Mono";
--font-body-md: "Inter";
--font-headline-lg: "IBM Plex Sans";
--font-body-lg: "Inter";
--font-headline-sm: "IBM Plex Sans";
--font-headline-lg-mobile: "IBM Plex Sans";
--font-headline-md: "IBM Plex Sans";
--font-label-sm: "JetBrains Mono";
/* Font Size Customization with Line Height, Weight, and Letter Spacing */
--text-label-md: 13px;
--text-label-md--line-height: 16px;
--text-label-md--font-weight: 500;
--text-body-md: 14px;
--text-body-md--line-height: 20px;
--text-body-md--font-weight: 400;
--text-headline-lg: 32px;
--text-headline-lg--line-height: 40px;
--text-headline-lg--letter-spacing: -0.02em;
--text-headline-lg--font-weight: 600;
--text-body-lg: 16px;
--text-body-lg--line-height: 24px;
--text-body-lg--font-weight: 400;
--text-headline-sm: 18px;
--text-headline-sm--line-height: 24px;
--text-headline-sm--font-weight: 600;
--text-headline-lg-mobile: 26px;
--text-headline-lg-mobile--line-height: 32px;
--text-headline-lg-mobile--font-weight: 600;
--text-headline-md: 24px;
--text-headline-md--line-height: 32px;
--text-headline-md--letter-spacing: -0.01em;
--text-headline-md--font-weight: 600;
--text-label-sm: 11px;
--text-label-sm--line-height: 14px;
--text-label-sm--letter-spacing: 0.05em;
--text-label-sm--font-weight: 500;
/* borderRadius: "lg": "0.25rem" → */
--radius-default: 0.125rem;
--radius-lg: 0.25rem;
--radius-xl: 0.5rem;
--radius-full: 0.75rem;
}
.tab-active { border-bottom: 2px solid #4F378B; color: #4F378B; font-weight: 600; }
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
/* render the icon-name ligatures (e.g. "add" -> the + glyph) */
font-feature-settings: 'liga';
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.chart-grid {
background-image: linear-gradient(to right, rgba(226, 232, 240, 0.5) 1px, transparent 1px),
linear-gradient(to bottom, rgba(226, 232, 240, 0.5) 1px, transparent 1px);
background-size: 40px 40px;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #f8f9fb; }
::-webkit-scrollbar-thumb { background: #cbc4d2; border-radius: 3px; }