feat: add embedded svelte frontend

This commit is contained in:
Jack O'Neill
2026-04-05 09:40:38 +02:00
parent f0e242293f
commit 6c6f4022a0
22 changed files with 2757 additions and 145 deletions

16
ui/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AMCS</title>
<meta
name="description"
content="AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools."
/>
</head>
<body class="bg-slate-950">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2193
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
ui/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "amcs-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"check": "svelte-check --tsconfig ./tsconfig.json",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.4",
"@types/node": "^24.5.2",
"svelte": "^5.28.2",
"svelte-check": "^4.1.6",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"vite": "^6.3.2"
}
}

178
ui/src/App.svelte Normal file
View File

@@ -0,0 +1,178 @@
<script lang="ts">
import { onMount } from 'svelte';
type AccessEntry = {
key_id: string;
last_accessed_at: string;
last_path: string;
request_count: number;
};
type StatusResponse = {
title: string;
description: string;
version: string;
build_date: string;
commit: string;
connected_count: number;
total_known: number;
connected_window: string;
oauth_enabled: boolean;
entries: AccessEntry[];
};
let data: StatusResponse | null = null;
let loading = true;
let error = '';
const quickLinks = [
{ href: '/llm', label: 'LLM Instructions' },
{ href: '/healthz', label: 'Health Check' },
{ href: '/readyz', label: 'Readiness Check' }
];
async function loadStatus() {
loading = true;
error = '';
try {
const response = await fetch('/api/status');
if (!response.ok) {
throw new Error(`Status request failed with ${response.status}`);
}
data = (await response.json()) as StatusResponse;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load status';
} finally {
loading = false;
}
}
function formatDate(value: string) {
return new Date(value).toLocaleString();
}
onMount(loadStatus);
</script>
<svelte:head>
<title>AMCS</title>
</svelte:head>
<div class="min-h-screen bg-slate-950 text-slate-100">
<main class="mx-auto flex min-h-screen max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8">
<section class="overflow-hidden rounded-3xl border border-white/10 bg-slate-900 shadow-2xl shadow-slate-950/40">
<img src="/images/project.jpg" alt="Avelon Memory Crystal" class="h-64 w-full object-cover object-center sm:h-80" />
<div class="grid gap-8 p-6 sm:p-8 lg:grid-cols-[1.6fr_1fr] lg:p-10">
<div class="space-y-6">
<div class="space-y-4">
<div class="inline-flex items-center gap-2 rounded-full border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-sm font-medium text-cyan-200">
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
Avalon Memory Crystal Server
</div>
<div>
<h1 class="text-3xl font-semibold tracking-tight text-white sm:text-4xl">Avelon Memory Crystal Server (AMCS)</h1>
<p class="mt-3 max-w-3xl text-base leading-7 text-slate-300 sm:text-lg">
{data?.description ?? 'AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.'}
</p>
</div>
</div>
<div class="flex flex-wrap gap-3">
{#each quickLinks as link}
<a class="inline-flex items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-2 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20" href={link.href}>{link.label}</a>
{/each}
{#if data?.oauth_enabled}
<a class="inline-flex items-center justify-center rounded-xl border border-violet-300/20 bg-violet-400/10 px-4 py-2 text-sm font-semibold text-violet-100 transition hover:border-violet-300/40 hover:bg-violet-400/20" href="/oauth-authorization-server">OAuth Authorization Server</a>
{/if}
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Connected users</p>
<p class="mt-2 text-3xl font-semibold text-white">{data?.connected_count ?? '—'}</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Known principals</p>
<p class="mt-2 text-3xl font-semibold text-white">{data?.total_known ?? '—'}</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Version</p>
<p class="mt-2 break-all text-2xl font-semibold text-white">{data?.version ?? '—'}</p>
</div>
</div>
</div>
<aside class="space-y-4 rounded-2xl border border-white/10 bg-slate-950/50 p-5">
<div>
<h2 class="text-lg font-semibold text-white">Build details</h2>
<p class="mt-1 text-sm text-slate-400">The same status info as the old landing page, just without looking like it escaped from 2004.</p>
</div>
<dl class="space-y-3 text-sm text-slate-300">
<div>
<dt class="text-slate-500">Build date</dt>
<dd class="mt-1 font-medium text-white">{data?.build_date ?? 'unknown'}</dd>
</div>
<div>
<dt class="text-slate-500">Commit</dt>
<dd class="mt-1 break-all rounded-lg bg-white/5 px-3 py-2 font-mono text-xs text-cyan-100">{data?.commit ?? 'unknown'}</dd>
</div>
<div>
<dt class="text-slate-500">Connected window</dt>
<dd class="mt-1 font-medium text-white">{data?.connected_window ?? 'last 10 minutes'}</dd>
</div>
</dl>
</aside>
</div>
</section>
<section class="mt-6 rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20 sm:p-8">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Recent access</h2>
<p class="mt-1 text-sm text-slate-400">Authenticated principals AMCS has seen recently.</p>
</div>
<button class="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-200 transition hover:bg-white/10" on:click={loadStatus}>
Refresh
</button>
</div>
{#if loading}
<div class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-400">Loading status…</div>
{:else if error}
<div class="mt-6 rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-6 text-sm text-rose-100">
<p class="font-semibold">Couldnt load the status snapshot.</p>
<p class="mt-1 text-rose-100/80">{error}</p>
</div>
{:else if data && data.entries.length === 0}
<div class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-400">No authenticated access recorded yet.</div>
{:else if data}
<div class="mt-6 overflow-hidden rounded-2xl border border-white/10">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-white/10 text-left text-sm text-slate-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Principal</th>
<th class="px-4 py-3 font-medium">Last accessed</th>
<th class="px-4 py-3 font-medium">Last path</th>
<th class="px-4 py-3 font-medium">Requests</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each data.entries as entry}
<tr class="hover:bg-white/[0.03]">
<td class="px-4 py-3 align-top"><code class="rounded bg-white/5 px-2 py-1 font-mono text-xs text-cyan-100">{entry.key_id}</code></td>
<td class="px-4 py-3 align-top text-slate-200">{formatDate(entry.last_accessed_at)}</td>
<td class="px-4 py-3 align-top"><code class="text-slate-100">{entry.last_path}</code></td>
<td class="px-4 py-3 align-top font-semibold text-white">{entry.request_count}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</section>
</main>
</div>

16
ui/src/app.css Normal file
View File

@@ -0,0 +1,16 @@
@import 'tailwindcss';
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
}
html,
body,
#app {
min-height: 100%;
}
body {
margin: 0;
}

8
ui/src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import './app.css';
import App from './App.svelte';
const app = new App({
target: document.getElementById('app')!
});
export default app;

5
ui/svelte.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
compilerOptions: {
dev: process.env.NODE_ENV !== 'production'
}
};

15
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.node.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"lib": ["ESNext", "DOM"],
"verbatimModuleSyntax": true,
"strict": true,
"allowJs": true,
"checkJs": false,
"types": ["svelte", "node"]
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "vite.config.ts"]
}

8
ui/tsconfig.node.json Normal file
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
}
}

31
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,31 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import tailwindcss from '@tailwindcss/vite';
const backendTarget = process.env.AMCS_UI_BACKEND ?? 'http://127.0.0.1:8080';
export default defineConfig({
plugins: [svelte(), tailwindcss()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': backendTarget,
'/healthz': backendTarget,
'/readyz': backendTarget,
'/llm': backendTarget,
'/images': backendTarget,
'/favicon.ico': backendTarget,
'/mcp': backendTarget,
'/files': backendTarget,
'/oauth-authorization-server': backendTarget,
'/authorize': backendTarget,
'/oauth': backendTarget,
'/.well-known': backendTarget
}
},
build: {
outDir: '../internal/app/ui/dist',
emptyOutDir: true
}
});