addition of new dashboard
This commit is contained in:
28
frontend/.gitignore
vendored
Normal file
28
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
4
frontend/.vscode/extensions.json
vendored
Normal file
4
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
frontend/.vscode/launch.json
vendored
Normal file
11
frontend/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
31
frontend/README.md
Normal file
31
frontend/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Frontend
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
│ └── favicon.svg
|
||||
├── src
|
||||
│ ├── assets
|
||||
│ │ └── astro.svg
|
||||
│ ├── components
|
||||
│ │ └── Welcome.astro
|
||||
│ ├── layouts
|
||||
│ │ └── Layout.astro
|
||||
│ └── pages
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
20
frontend/astro.config.mjs
Normal file
20
frontend/astro.config.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: "http://localhost:9090",
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [tailwindcss()]
|
||||
}
|
||||
});
|
||||
6539
frontend/package-lock.json
generated
Normal file
6539
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "jlinc-server-ui",
|
||||
"version": "1.0.0",
|
||||
"description": "JLINC Server",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"astro": "^6.3.7",
|
||||
"d3": "^7.9.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"xlsx-js-style": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
14
frontend/playwright.config.js
Normal file
14
frontend/playwright.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
// npm run test:e2e (needs `npx playwright install chromium` once).
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 30_000,
|
||||
use: { baseURL: "http://localhost:4321" },
|
||||
webServer: {
|
||||
command: "npm run build && npm run preview -- --port 4321",
|
||||
url: "http://localhost:4321",
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 655 B |
9
frontend/public/favicon.svg
Normal file
9
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
BIN
frontend/public/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2
Normal file
BIN
frontend/public/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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; }
|
||||
131
frontend/tests/e2e/smoke.spec.js
Normal file
131
frontend/tests/e2e/smoke.spec.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// API responses mocked in the SAME envelope shapes the real backend returns
|
||||
// (verified against /api/v1/data/dashboard/*). The smoke asserts each panel
|
||||
// consumes the contract and renders DATA — and that interactions (verify) work —
|
||||
// not just that containers exist.
|
||||
const EVENT_ID = "f6a042ca-efbf-4c4d-b99c-dac41373eb63";
|
||||
|
||||
const MOCKS = {
|
||||
"/api/dashboard/whoami": { username: "dev", modules: ["core", "archive"] },
|
||||
"/api/dashboard/summary": {
|
||||
apiUsage: { core: 2005, audit: 2002, total: 4007 },
|
||||
apiKeys: 2,
|
||||
endpoints: 2,
|
||||
storage: {
|
||||
onDisk: { core: 353712, audit: 1321330, total: 1675042 },
|
||||
logical: { core: 349712, audit: 1311320, total: 1661032 },
|
||||
},
|
||||
},
|
||||
"/api/dashboard/usage-series": {
|
||||
series: [
|
||||
{ date: "2026-06-20", core: 76, audit: 0, total: 76 },
|
||||
{ date: "2026-06-21", core: 64, audit: 0, total: 64 },
|
||||
],
|
||||
},
|
||||
"/api/dashboard/storage-series": {
|
||||
series: [
|
||||
{ date: "2026-06-20", coreDisk: 322617, auditDisk: 0, totalDisk: 322617 },
|
||||
{ date: "2026-06-21", coreDisk: 333904, auditDisk: 0, totalDisk: 333904 },
|
||||
],
|
||||
},
|
||||
"/api/dashboard/keys": [],
|
||||
};
|
||||
|
||||
const coreRow = {
|
||||
eventUuid: EVENT_ID,
|
||||
sender: "did:jlinc:sender",
|
||||
receiver: "did:jlinc:receiver",
|
||||
date: "2026-06-23T11:37:40.664Z",
|
||||
agreementUuid: "9ab250b9-b3e3-48aa-1111-222233334444",
|
||||
size: 655,
|
||||
};
|
||||
// Two audit records for ONE event (produce + process): same eventUuid, distinct
|
||||
// auditId. On the audit tab it is auditId — not eventUuid — that identifies a row.
|
||||
const auditRows = [
|
||||
{ ...coreRow, auditId: 9, auditDigest: "572b052a11aa" },
|
||||
{ ...coreRow, auditId: 10, auditDigest: "4029d4a6da22" },
|
||||
];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/dashboard/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = url.pathname;
|
||||
if (path === "/api/dashboard/transactions") {
|
||||
const audit = url.searchParams.get("type") === "audit";
|
||||
return route.fulfill({
|
||||
json: audit
|
||||
? { rows: auditRows, total: 2, limit: 50, offset: 0 }
|
||||
: { rows: [coreRow], total: 1, limit: 50, offset: 0 },
|
||||
});
|
||||
}
|
||||
if (path === "/api/dashboard/verify") {
|
||||
const list = (k) => (url.searchParams.get(k) || "").split(",").filter(Boolean);
|
||||
const pass = (extra) => ({ status: "verified", verified: true, signatureCount: 1, auditCount: 1, checks: { validId: true }, ...extra });
|
||||
return route.fulfill({
|
||||
json: {
|
||||
results: [
|
||||
...list("ids").map((eventUuid) => pass({ eventUuid, auditId: null })),
|
||||
// Audit 9 passes, audit 10 fails. Two rows of the SAME event getting
|
||||
// different verdicts is only expressible if each row is its own
|
||||
// verification target.
|
||||
...list("auditIds").map(Number).map((auditId) =>
|
||||
auditId === 9
|
||||
? pass({ eventUuid: EVENT_ID, auditId })
|
||||
: { eventUuid: EVENT_ID, auditId, status: "invalid", verified: false, signatureCount: 1, auditCount: 1, checks: { validAuditHash: false } }),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(MOCKS, path)) return route.fulfill({ json: MOCKS[path] });
|
||||
return route.fulfill({ json: {} });
|
||||
});
|
||||
});
|
||||
|
||||
test("dashboard panels render real data from the API contract", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#api-usage-total")).toHaveText(/\d/, { timeout: 10_000 });
|
||||
await expect(page.locator("#storage-total")).toHaveText(/\d/);
|
||||
await expect(page.locator("#usage-chart svg")).toBeAttached({ timeout: 10_000 });
|
||||
await expect(page.locator("#transaction-rows tr[data-event-id]")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("Audit tab: verifying one row badges ONLY that row", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator("#tab-audit").click();
|
||||
// Two audit records share one event UUID; each is its own row.
|
||||
const rows = page.locator("#transaction-rows tr[data-event-id]");
|
||||
await expect(rows).toHaveCount(2);
|
||||
|
||||
// The bug: clicking Verify on one row badged BOTH, because rows were keyed by
|
||||
// the event UUID they share. The request must target the row's audit id.
|
||||
const [request] = await Promise.all([
|
||||
page.waitForRequest((r) => r.url().includes("/api/dashboard/verify")),
|
||||
rows.nth(0).locator(".btn-verify").click(),
|
||||
]);
|
||||
expect(new URL(request.url()).searchParams.get("auditIds")).toBe("9");
|
||||
|
||||
await expect(rows.nth(0).locator(".verify-badge")).toContainText("Verified");
|
||||
await expect(rows.nth(1).locator(".verify-badge")).toBeHidden();
|
||||
});
|
||||
|
||||
test("Audit tab: Validate All gives each audit row its own verdict", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator("#tab-audit").click();
|
||||
await expect(page.locator("#transaction-rows tr[data-event-id]")).toHaveCount(2);
|
||||
|
||||
await page.locator("#validate-all").click();
|
||||
|
||||
// Same event, different audit records -> independent results. A per-event
|
||||
// verification could not produce two different badges here.
|
||||
const rows = page.locator("#transaction-rows tr[data-event-id]");
|
||||
await expect(rows.nth(0).locator(".verify-badge")).toContainText("Verified");
|
||||
await expect(rows.nth(1).locator(".verify-badge")).toContainText("Invalid");
|
||||
});
|
||||
|
||||
test("sidebar switches to the API Keys view", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#api-keys-view")).toBeAttached();
|
||||
await page.locator("#nav-api-keys").click();
|
||||
await expect(page.locator("#api-keys-view")).toBeVisible();
|
||||
});
|
||||
5
frontend/tsconfig.json
Normal file
5
frontend/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
11
frontend/vitest.config.js
Normal file
11
frontend/vitest.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Unit tests for the pure resource modules (formatters, filter-state query
|
||||
// building). Astro components and DOM glue are covered by the Playwright smoke.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.js"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user