addition of new dashboard
This commit is contained in:
1
frontend/src/assets/astro.svg
Normal file
1
frontend/src/assets/astro.svg
Normal 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 |
1
frontend/src/assets/background.svg
Normal file
1
frontend/src/assets/background.svg
Normal 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 |
125
frontend/src/components/ApiKeys.astro
Normal file
125
frontend/src/components/ApiKeys.astro
Normal 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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
63
frontend/src/components/AppBar.astro
Normal file
63
frontend/src/components/AppBar.astro
Normal 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>
|
||||
74
frontend/src/components/FilterBar.astro
Normal file
74
frontend/src/components/FilterBar.astro
Normal 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>
|
||||
54
frontend/src/components/Sidebar.astro
Normal file
54
frontend/src/components/Sidebar.astro
Normal 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>
|
||||
121
frontend/src/components/SummaryCards.astro
Normal file
121
frontend/src/components/SummaryCards.astro
Normal 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>
|
||||
449
frontend/src/components/TransactionsTable.astro
Normal file
449
frontend/src/components/TransactionsTable.astro
Normal 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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
|
||||
108
frontend/src/components/UsageChart.astro
Normal file
108
frontend/src/components/UsageChart.astro
Normal 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 & 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>
|
||||
210
frontend/src/components/Welcome.astro
Normal file
210
frontend/src/components/Welcome.astro
Normal 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>
|
||||
27
frontend/src/layouts/Layout.astro
Normal file
27
frontend/src/layouts/Layout.astro
Normal 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>
|
||||
69
frontend/src/pages/index.astro
Normal file
69
frontend/src/pages/index.astro
Normal 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>
|
||||
126
frontend/src/resources/agreementModal.js
Normal file
126
frontend/src/resources/agreementModal.js
Normal 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();
|
||||
}
|
||||
140
frontend/src/resources/detailsModal.js
Normal file
140
frontend/src/resources/detailsModal.js
Normal 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));
|
||||
}
|
||||
14
frontend/src/resources/ee.ts
Normal file
14
frontend/src/resources/ee.ts
Normal 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);
|
||||
}
|
||||
85
frontend/src/resources/filters.js
Normal file
85
frontend/src/resources/filters.js
Normal 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));
|
||||
}
|
||||
21
frontend/src/resources/filters.test.js
Normal file
21
frontend/src/resources/filters.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
38
frontend/src/resources/formatters.js
Normal file
38
frontend/src/resources/formatters.js
Normal 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}`;
|
||||
}
|
||||
43
frontend/src/resources/formatters.test.js
Normal file
43
frontend/src/resources/formatters.test.js
Normal 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("—");
|
||||
});
|
||||
});
|
||||
63
frontend/src/resources/jsonModal.js
Normal file
63
frontend/src/resources/jsonModal.js
Normal 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);
|
||||
}
|
||||
163
frontend/src/resources/lineChart.js
Normal file
163
frontend/src/resources/lineChart.js
Normal 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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||
},
|
||||
};
|
||||
}
|
||||
26
frontend/src/resources/toast.js
Normal file
26
frontend/src/resources/toast.js
Normal 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);
|
||||
}
|
||||
104
frontend/src/resources/verifyModal.js
Normal file
104
frontend/src/resources/verifyModal.js
Normal 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);
|
||||
}
|
||||
179
frontend/src/styles/fonts.css
Normal file
179
frontend/src/styles/fonts.css
Normal 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');
|
||||
}
|
||||
145
frontend/src/styles/global.css
Normal file
145
frontend/src/styles/global.css
Normal 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; }
|
||||
Reference in New Issue
Block a user