Initial commit: trueref v0.1.0-SNAPSHOT
Some checks failed
Build and publish Docker image / Build and push (push) Failing after 1m27s

Java 21 / Spring Boot 3.5.3 multi-module Maven project.
Hybrid BM25+HNSW search with RRF, cross-encoder reranker,
ONNX Runtime 1.22.0 (CPU + CUDA 12 GPU variants).
This commit is contained in:
moze
2026-05-06 00:49:16 +02:00
commit c5f950c2c0
132 changed files with 11287 additions and 0 deletions

63
trueref-frontend/pom.xml Normal file
View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.trueref</groupId>
<artifactId>trueref-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>trueref-frontend</artifactId>
<name>trueref-frontend</name>
<description>SvelteKit static UI built with frontend-maven-plugin and packaged as a resource jar.</description>
<packaging>jar</packaging>
<build>
<resources>
<!-- Point directly at the SvelteKit build output so resources:resources
(bound to process-resources) finds the files that npm-build already
created in generate-resources. The intermediate copy-frontend-build
step used target/frontend-dist as the resource directory, but that
directory is only populated later in process-resources, causing an
empty JAR on clean builds. -->
<resource>
<directory>web/build</directory>
<targetPath>static</targetPath>
</resource>
</resources>
<plugins>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<configuration>
<workingDirectory>web</workingDirectory>
<installDirectory>${project.build.directory}</installDirectory>
<nodeVersion>${node.version}</nodeVersion>
<npmVersion>${npm.version}</npmVersion>
</configuration>
<executions>
<execution>
<id>install-node-and-npm</id>
<goals><goal>install-node-and-npm</goal></goals>
<phase>generate-resources</phase>
</execution>
<execution>
<id>npm-install</id>
<goals><goal>npm</goal></goals>
<phase>generate-resources</phase>
<configuration><arguments>install</arguments></configuration>
</execution>
<execution>
<id>npm-build</id>
<goals><goal>npm</goal></goals>
<phase>generate-resources</phase>
<configuration><arguments>run build</arguments></configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

9
trueref-frontend/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
.DS_Store
*.log

View File

@@ -0,0 +1 @@
engine-strict=false

View File

@@ -0,0 +1,9 @@
{
"useTabs": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

2202
trueref-frontend/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "trueref-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"svelte": "^5.39.0",
"svelte-check": "^4.2.0",
"shiki": "^3.2.0",
"typescript": "^5.9.0",
"vite": "^6.3.0"
}
}

View File

@@ -0,0 +1,190 @@
:root {
--bg: #0b0d12;
--bg-alt: #0f131a;
--bg-card: #141925;
--bg-card-hover: #1a2030;
--border: #1f2533;
--fg: #e6e8ee;
--fg-dim: #b2b7c3;
--muted: #6b7280;
--accent: #7aa2f7;
--accent-dim: #3b4a6b;
--ok: #9ece6a;
--warn: #e0af68;
--err: #f7768e;
--mono: 'JetBrains Mono', 'Fira Code', 'Menlo', 'Consolas', monospace;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font-family:
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1,
h2,
h3 {
margin: 0;
font-weight: 600;
}
h1 {
font-size: 22px;
}
h2 {
font-size: 17px;
}
h3 {
font-size: 14px;
color: var(--fg-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
}
button {
background: var(--bg-card);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
button:hover {
background: var(--bg-card-hover);
border-color: var(--accent-dim);
}
button.primary {
background: var(--accent);
color: #0b0d12;
border-color: var(--accent);
font-weight: 600;
}
button.primary:hover {
filter: brightness(1.05);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
input,
select,
textarea {
background: var(--bg-alt);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
}
label {
font-size: 12px;
color: var(--fg-dim);
display: flex;
flex-direction: column;
gap: 4px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
th {
color: var(--fg-dim);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
}
.grid {
display: grid;
gap: 16px;
}
.page-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.muted {
color: var(--muted);
}
.mono {
font-family: var(--mono);
}
.empty {
color: var(--muted);
font-style: italic;
padding: 12px 0;
}
/* dialog / modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
z-index: 900;
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
min-width: 420px;
max-width: 560px;
}

12
trueref-frontend/web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
// See https://svelte.dev/docs/kit/types#app
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>trueref</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,119 @@
import { pushToast } from './toast';
import type {
CreateRepositoryRequest,
JobDto,
MetricsDto,
RepositoryDto,
ResolveMatchDto,
ResolveRequest,
ResourcesDto,
SearchRequest,
SearchResponseDto,
VersionDto
} from './types';
export class ApiError extends Error {
status: number;
body: unknown;
constructor(status: number, message: string, body: unknown) {
super(message);
this.status = status;
this.body = body;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
try {
const res = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...(init?.headers ?? {})
}
});
if (!res.ok) {
let body: unknown = null;
try {
body = await res.json();
} catch {
try {
body = await res.text();
} catch {
/* empty */
}
}
const msg = `${init?.method ?? 'GET'} ${path}${res.status}`;
pushToast({ level: 'error', message: msg });
throw new ApiError(res.status, msg, body);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
} catch (err) {
if (err instanceof ApiError) throw err;
const msg = err instanceof Error ? err.message : String(err);
pushToast({ level: 'error', message: `Network error: ${msg}` });
throw err;
}
}
function qs(params: Record<string, string | number | boolean | null | undefined>): string {
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '');
if (entries.length === 0) return '';
const sp = new URLSearchParams();
for (const [k, v] of entries) sp.append(k, String(v));
return `?${sp.toString()}`;
}
// ---------- Repos ----------
export const listRepos = (): Promise<RepositoryDto[]> => request('/api/repos');
export const getRepo = (id: string): Promise<RepositoryDto> => request(`/api/repos/${id}`);
export const createRepo = (body: CreateRepositoryRequest): Promise<RepositoryDto> =>
request('/api/repos', { method: 'POST', body: JSON.stringify(body) });
export const deleteRepo = (id: string): Promise<void> =>
request(`/api/repos/${id}`, { method: 'DELETE' });
export const discoverTags = (id: string): Promise<JobDto> =>
request(`/api/repos/${id}/discover`, { method: 'POST' });
export const listVersions = (id: string): Promise<VersionDto[]> =>
request(`/api/repos/${id}/versions`);
export const indexVersion = (id: string, tag: string): Promise<JobDto> =>
request(`/api/repos/${id}/versions/${encodeURIComponent(tag)}/index`, { method: 'POST' });
export const reindexVersion = (id: string, tag: string): Promise<JobDto> =>
request(`/api/repos/${id}/versions/${encodeURIComponent(tag)}/reindex`, { method: 'POST' });
// ---------- Jobs ----------
export interface JobFilter {
repoId?: string;
versionId?: string;
status?: string;
limit?: number;
offset?: number;
}
export const listJobs = (f: JobFilter = {}): Promise<JobDto[]> =>
request(`/api/jobs${qs(f as Record<string, string | number | undefined>)}`);
export const getJob = (id: string): Promise<JobDto> => request(`/api/jobs/${id}`);
// ---------- Search / Resolve ----------
export const search = (body: SearchRequest): Promise<SearchResponseDto> =>
request('/api/search', { method: 'POST', body: JSON.stringify(body) });
export const resolveLibrary = (body: ResolveRequest): Promise<ResolveMatchDto[]> =>
request(`/api/resolve${qs({ q: body.libraryName })}`);
// ---------- Observability ----------
export const getResources = (): Promise<ResourcesDto> => request('/api/observability/resources');
export const getMetrics = (): Promise<MetricsDto> => request('/api/observability/metrics');

View File

@@ -0,0 +1,73 @@
<script lang="ts">
interface Datum {
label: string;
value: number;
}
interface Props {
data: Datum[];
height?: number;
format?: (n: number) => string;
}
let { data, height = 180, format = (n) => n.toLocaleString() }: Props = $props();
let safeData = $derived(data ?? []);
let max = $derived(Math.max(1, ...safeData.map((d) => d.value)));
</script>
<div class="bars" style="--h: {height}px;">
{#each safeData as d (d.label)}
<div class="row">
<div class="lbl" title={d.label}>{d.label}</div>
<div class="track">
<div class="fill" style="width: {(d.value / max) * 100}%"></div>
</div>
<div class="val">{format(d.value)}</div>
</div>
{:else}
<div class="empty">No data yet.</div>
{/each}
</div>
<style>
.bars {
display: flex;
flex-direction: column;
gap: 6px;
max-height: var(--h);
overflow-y: auto;
}
.row {
display: grid;
grid-template-columns: 160px 1fr 90px;
gap: 10px;
align-items: center;
font-size: 12px;
}
.lbl {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg-dim);
}
.track {
background: var(--bg-alt);
border: 1px solid var(--border);
height: 10px;
border-radius: 4px;
overflow: hidden;
}
.fill {
background: var(--accent);
height: 100%;
}
.val {
text-align: right;
font-family: var(--mono);
color: var(--fg-dim);
}
.empty {
color: var(--muted);
font-style: italic;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { onMount } from 'svelte';
import { codeToHtml } from 'shiki/bundle/web';
interface Props {
code: string;
language?: string | null;
startLine?: number;
}
let { code, language, startLine = 1 }: Props = $props();
// Languages the shiki web bundle reliably ships. Unknown values fall back to "text".
const supported = new Set<string>([
'java',
'javascript',
'typescript',
'tsx',
'jsx',
'python',
'go',
'rust',
'c',
'cpp',
'ruby',
'php',
'kotlin',
'scala',
'swift',
'sql',
'json',
'yaml',
'toml',
'xml',
'html',
'css',
'markdown',
'shellscript',
'bash'
]);
function normalize(lang: string | null | undefined): string {
if (!lang) return 'text';
const l = lang.toLowerCase();
const alias: Record<string, string> = {
sh: 'shellscript',
shell: 'shellscript',
md: 'markdown',
yml: 'yaml',
'c++': 'cpp',
js: 'javascript',
ts: 'typescript'
};
const mapped = alias[l] ?? l;
return supported.has(mapped) ? mapped : 'text';
}
let html = $state<string>('');
onMount(() => {
let cancelled = false;
(async () => {
try {
const rendered = await codeToHtml(code, {
lang: normalize(language) as never,
theme: 'github-dark'
});
if (!cancelled) html = rendered;
} catch {
if (!cancelled) {
const escaped = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
html = `<pre><code>${escaped}</code></pre>`;
}
}
})();
return () => {
cancelled = true;
};
});
</script>
<div class="code-block" style="--start-line: {startLine};">
{#if html}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html html}
{:else}
<pre><code>{code}</code></pre>
{/if}
</div>
<style>
.code-block :global(pre.shiki),
.code-block > pre {
margin: 0;
padding: 10px 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
background: #0d1117 !important;
}
.code-block :global(code) {
font-family: var(--mono);
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import type { JobDto } from '$lib/types';
import { formatRelative } from '$lib/format';
import VersionBadge from './VersionBadge.svelte';
interface Props {
job: JobDto;
onclick?: (j: JobDto) => void;
}
let { job, onclick }: Props = $props();
let totals = $derived.by(() => {
let processed = 0;
let total = 0;
for (const s of (job.stages ?? [])) {
processed += s.itemsProcessed;
total += s.itemsTotal;
}
return { processed, total };
});
</script>
<button class="job-row" type="button" onclick={() => onclick?.(job)}>
<div class="col id">{job.id.slice(0, 8)}</div>
<div class="col type">{job.type}</div>
<div class="col target">
<div>{job.repoName ?? job.repoId.slice(0, 8)}</div>
{#if job.versionTag}<div class="tag">{job.versionTag}</div>{/if}
</div>
<div class="col status"><VersionBadge status={job.status} /></div>
<div class="col started">{formatRelative(job.startedAt)}</div>
<div class="col stages">{(job.stages ?? []).filter((s) => s.status === 'SUCCEEDED').length} / {(job.stages ?? []).length}</div>
</button>
<style>
.job-row {
display: grid;
grid-template-columns: 90px 140px 1fr 120px 120px 80px;
gap: 12px;
align-items: center;
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
color: inherit;
text-align: left;
cursor: pointer;
font-size: 13px;
}
.job-row:hover {
background: var(--bg-card-hover);
border-color: var(--accent-dim);
}
.col.id {
font-family: var(--mono);
color: var(--fg-dim);
}
.tag {
font-family: var(--mono);
font-size: 11px;
color: var(--fg-dim);
}
.col.stages {
text-align: right;
font-family: var(--mono);
color: var(--fg-dim);
}
</style>

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import { onDestroy, tick, untrack } from 'svelte';
import { sseAppendStore } from '$lib/sse';
import type { JobLogEventDto } from '$lib/types';
interface Props {
jobId: string;
jobStatus?: string;
capacity?: number;
height?: string;
}
let { jobId, jobStatus, capacity = 2000, height = '380px' }: Props = $props();
const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'CANCELLED']);
let done = $derived(jobStatus != null && TERMINAL.has(jobStatus));
// Do NOT use $derived here: wrapping a store in $derived makes $log return
// the Readable object itself (not its subscribed value), so $log.items is undefined.
// jobId is a mount-time prop; the component is destroyed and recreated if it changes.
// untrack() tells Svelte we intentionally want the snapshot value here, not a reactive link.
const log = sseAppendStore<JobLogEventDto>(`/api/jobs/${untrack(() => jobId)}/log`, {
parse: (raw) => JSON.parse(raw) as JobLogEventDto,
event: 'log',
capacity: untrack(() => capacity)
});
let container: HTMLDivElement | undefined = $state();
let autoScroll = $state(true);
let lastCount = 0;
$effect(() => {
const items = $log.items;
if (items.length !== lastCount) {
lastCount = items.length;
if (autoScroll) {
tick().then(() => {
if (container) container.scrollTop = container.scrollHeight;
});
}
}
});
function onScroll() {
if (!container) return;
const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
autoScroll = nearBottom;
}
onDestroy(() => {
// store cleans up via readable subscriber
});
function levelClass(lvl: string): string {
return `lvl-${lvl.toLowerCase()}`;
}
</script>
<div class="log-tail">
<div class="log-header">
<span class="status" class:on={$log.connected}>
{#if $log.connected}
● live
{:else if done}
{jobStatus === 'SUCCEEDED' ? '● finished' : '✗ failed'}
{:else}
○ connecting…
{/if}
</span>
{#if $log.error}<span class="err">{$log.error}</span>{/if}
<label class="auto">
<input type="checkbox" bind:checked={autoScroll} /> auto-scroll
</label>
</div>
<div
class="log-body"
style="height: {height};"
bind:this={container}
onscroll={onScroll}
role="log"
aria-live="polite"
>
{#each $log.items as ev (ev.ts + ev.message)}
<div class="line {levelClass(ev.level)}">
<span class="ts">{new Date(ev.ts).toLocaleTimeString()}</span>
<span class="lvl">{ev.level}</span>
{#if ev.stage}<span class="stage">{ev.stage}</span>{/if}
<span class="msg">{ev.message}</span>
</div>
{:else}
<div class="empty">Waiting for log events…</div>
{/each}
</div>
</div>
<style>
.log-tail {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-alt);
overflow: hidden;
}
.log-header {
display: flex;
gap: 12px;
align-items: center;
padding: 6px 10px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.status {
color: var(--muted);
font-family: var(--mono);
}
.status.on {
color: var(--ok);
}
.err {
color: var(--err);
}
.auto {
margin-left: auto;
color: var(--fg-dim);
display: flex;
gap: 6px;
align-items: center;
}
.log-body {
overflow-y: auto;
padding: 8px 10px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
}
.line {
display: grid;
grid-template-columns: 80px 50px auto 1fr;
gap: 8px;
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
}
.ts {
color: var(--muted);
}
.lvl {
font-weight: 600;
}
.stage {
color: var(--accent);
}
.lvl-debug {
opacity: 0.65;
}
.lvl-info .lvl {
color: var(--accent);
}
.lvl-warn .lvl {
color: var(--warn);
}
.lvl-error .lvl,
.lvl-error .msg {
color: var(--err);
}
.empty {
color: var(--muted);
font-style: italic;
}
</style>

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import type { RepositoryDto } from '$lib/types';
import { formatRelative } from '$lib/format';
interface Props {
repo: RepositoryDto;
onopen?: (r: RepositoryDto) => void;
ondiscover?: (r: RepositoryDto) => void;
}
let { repo, onopen, ondiscover }: Props = $props();
</script>
<div class="card">
<div class="head">
<div class="name">
<button class="linklike" type="button" onclick={() => onopen?.(repo)}>{repo.name}</button>
</div>
<div class="meta">
{repo.managedClone ? 'managed' : 'local'} · updated {formatRelative(repo.updatedAt)}
</div>
</div>
<div class="stats">
<div><span class="k">versions</span><span class="v">{repo.versionCount ?? '—'}</span></div>
<div><span class="k">indexed</span><span class="v">{repo.indexedVersionCount ?? '—'}</span></div>
<div><span class="k">chunks</span><span class="v">{repo.chunkCount?.toLocaleString() ?? '—'}</span></div>
</div>
<div class="actions">
<button type="button" onclick={() => ondiscover?.(repo)}>Discover tags</button>
<button type="button" class="primary" onclick={() => onopen?.(repo)}>Open</button>
</div>
</div>
<style>
.card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
}
.head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
}
.name {
font-size: 15px;
font-weight: 600;
}
.meta {
font-size: 11px;
color: var(--muted);
}
.stats {
display: flex;
gap: 14px;
font-size: 12px;
}
.stats .k {
color: var(--fg-dim);
margin-right: 4px;
}
.stats .v {
font-family: var(--mono);
}
.actions {
display: flex;
gap: 8px;
margin-top: auto;
}
.linklike {
background: none;
border: none;
color: var(--accent);
padding: 0;
font: inherit;
cursor: pointer;
}
.linklike:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,54 @@
<script lang="ts">
interface Props {
data: number[];
height?: number;
max?: number;
color?: string;
label?: string;
}
let { data, height = 80, max, color = 'var(--accent)', label }: Props = $props();
let safeData = $derived(data ?? []);
let viewMax = $derived(max ?? Math.max(1, ...safeData));
let path = $derived.by(() => {
if (!data || data.length < 2) return '';
const w = 100;
const h = 100;
const step = w / (safeData.length - 1);
return safeData
.map((v, i) => {
const x = (i * step).toFixed(2);
const y = (h - (v / viewMax) * h).toFixed(2);
return `${i === 0 ? 'M' : 'L'}${x},${y}`;
})
.join(' ');
});
</script>
<div class="sparkline" style="--h: {height}px;">
{#if label}<div class="label">{label}</div>{/if}
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{#if path}
<path d={path} fill="none" stroke={color} stroke-width="1.5" vector-effect="non-scaling-stroke" />
{/if}
</svg>
</div>
<style>
.sparkline {
display: flex;
flex-direction: column;
gap: 4px;
}
.sparkline svg {
width: 100%;
height: var(--h);
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 4px;
}
.label {
font-size: 11px;
color: var(--fg-dim);
}
</style>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import type { JobStageDto } from '$lib/types';
import { percent } from '$lib/format';
import VersionBadge from './VersionBadge.svelte';
interface Props {
stage: JobStageDto;
}
let { stage }: Props = $props();
let pct = $derived(percent(stage.itemsProcessed, stage.itemsTotal));
let active = $derived(stage.status === 'RUNNING');
</script>
<div class="row">
<div class="name">{stage.name}</div>
<div class="bar" class:active>
<div class="fill" style="width: {pct}%"></div>
</div>
<div class="counts">
{stage.itemsProcessed.toLocaleString()} / {stage.itemsTotal.toLocaleString()}
</div>
<div class="status"><VersionBadge status={stage.status} /></div>
</div>
<style>
.row {
display: grid;
grid-template-columns: 120px 1fr 140px 100px;
gap: 10px;
align-items: center;
padding: 4px 0;
font-size: 13px;
}
.name {
font-family: var(--mono);
color: var(--fg-dim);
}
.bar {
position: relative;
height: 8px;
background: var(--bg-alt);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border);
}
.fill {
height: 100%;
background: var(--accent);
transition: width 300ms ease;
}
.bar.active .fill {
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #fff));
animation: pulse 1.6s ease-in-out infinite;
}
.counts {
font-family: var(--mono);
font-size: 12px;
color: var(--fg-dim);
text-align: right;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import { toasts, dismissToast } from '$lib/toast';
</script>
<div class="toast-stack" aria-live="polite" aria-atomic="false">
{#each $toasts as t (t.id)}
<div class="toast lvl-{t.level}" role="status">
<span class="msg">{t.message}</span>
<button class="close" type="button" aria-label="dismiss" onclick={() => dismissToast(t.id)}
>×</button
>
</div>
{/each}
</div>
<style>
.toast-stack {
position: fixed;
bottom: 18px;
right: 18px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 1000;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: 10px;
min-width: 260px;
max-width: 420px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--fg);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
font-size: 13px;
}
.toast.lvl-error {
border-color: color-mix(in srgb, var(--err) 55%, transparent);
background: color-mix(in srgb, var(--err) 12%, var(--bg-card));
}
.toast.lvl-warn {
border-color: color-mix(in srgb, var(--warn) 55%, transparent);
}
.toast.lvl-success {
border-color: color-mix(in srgb, var(--ok) 55%, transparent);
}
.msg {
flex: 1;
word-break: break-word;
}
.close {
background: none;
border: none;
color: var(--fg-dim);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.close:hover {
color: var(--fg);
}
</style>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import type { VersionStatus, JobStatus, JobStageStatus } from '$lib/types';
interface Props {
status: VersionStatus | JobStatus | JobStageStatus | string;
label?: string | null;
}
let { status, label }: Props = $props();
function colorFor(s: string): string {
switch (s) {
case 'INDEXED':
case 'SUCCEEDED':
return 'var(--ok)';
case 'INDEXING':
case 'RUNNING':
case 'QUEUED':
return 'var(--accent)';
case 'FAILED':
return 'var(--err)';
case 'SKIPPED':
case 'INACTIVE':
case 'CANCELLED':
return 'var(--muted)';
case 'DISCOVERED':
case 'PENDING':
default:
return 'var(--warn)';
}
}
</script>
<span class="badge" style="--badge-color: {colorFor(status)};">{label ?? status}</span>
<style>
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--badge-color);
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--badge-color) 45%, transparent);
line-height: 1.2;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,45 @@
export function formatBytes(bytes: number | null | undefined): string {
if (bytes == null || !Number.isFinite(bytes)) return '—';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`;
}
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return '—';
if (ms < 1000) return `${ms.toFixed(0)} ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(1)}s`;
const m = Math.floor(s / 60);
const rem = Math.floor(s % 60);
return `${m}m ${rem}s`;
}
export function formatRelative(iso: string | null | undefined): string {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (!Number.isFinite(t)) return iso ?? '—';
const diff = Date.now() - t;
const s = Math.max(0, Math.floor(diff / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
export function clamp(n: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, n));
}
export function percent(part: number, total: number): number {
if (!total || total <= 0) return 0;
return clamp((part / total) * 100, 0, 100);
}

View File

@@ -0,0 +1,186 @@
import { readable, type Readable } from 'svelte/store';
import { browser } from '$app/environment';
export interface SseOptions<T> {
parse?: (raw: string) => T;
event?: string; // named SSE event (default: 'message')
initial?: T | null;
reconnectMs?: number;
}
export interface SseState<T> {
value: T | null;
connected: boolean;
error: string | null;
}
/**
* Returns a Svelte store connected to an EventSource with auto-reconnect.
* The store yields the latest parsed value, connection status, and last error.
*/
export function sseStore<T>(url: string, opts: SseOptions<T> = {}): Readable<SseState<T>> {
const parse = opts.parse ?? ((raw: string) => JSON.parse(raw) as T);
const eventName = opts.event ?? 'message';
const reconnectMs = opts.reconnectMs ?? 2000;
return readable<SseState<T>>(
{ value: opts.initial ?? null, connected: false, error: null },
(set) => {
if (!browser) return () => {};
let es: EventSource | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let closed = false;
let state: SseState<T> = { value: opts.initial ?? null, connected: false, error: null };
const push = (patch: Partial<SseState<T>>) => {
state = { ...state, ...patch };
set(state);
};
const connect = () => {
if (closed) return;
try {
es = new EventSource(url);
} catch (err) {
push({ error: err instanceof Error ? err.message : String(err), connected: false });
schedule();
return;
}
es.addEventListener('open', () => push({ connected: true, error: null }));
es.addEventListener(eventName, (ev) => {
const me = ev as MessageEvent<string>;
try {
push({ value: parse(me.data) });
} catch (err) {
push({ error: err instanceof Error ? err.message : String(err) });
}
});
es.addEventListener('error', () => {
push({ connected: false });
try {
es?.close();
} catch {
/* noop */
}
es = null;
schedule();
});
};
const schedule = () => {
if (closed || timer) return;
timer = setTimeout(() => {
timer = null;
connect();
}, reconnectMs);
};
connect();
return () => {
closed = true;
if (timer) clearTimeout(timer);
timer = null;
if (es) {
try {
es.close();
} catch {
/* noop */
}
}
};
}
);
}
/**
* Append-only SSE store — accumulates events into a ring buffer. Useful for logs.
*/
export function sseAppendStore<T>(
url: string,
opts: SseOptions<T> & { capacity?: number } = {}
): Readable<{ items: T[]; connected: boolean; error: string | null }> {
const parse = opts.parse ?? ((raw: string) => JSON.parse(raw) as T);
const eventName = opts.event ?? 'message';
const reconnectMs = opts.reconnectMs ?? 2000;
const capacity = opts.capacity ?? 2000;
return readable<{ items: T[]; connected: boolean; error: string | null }>(
{ items: [], connected: false, error: null },
(set) => {
if (!browser) return () => {};
let es: EventSource | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let closed = false;
let items: T[] = [];
let connected = false;
let error: string | null = null;
const push = () => set({ items: items.slice(), connected, error });
const connect = () => {
if (closed) return;
try {
es = new EventSource(url);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
connected = false;
push();
schedule();
return;
}
es.addEventListener('open', () => {
connected = true;
error = null;
push();
});
es.addEventListener(eventName, (ev) => {
const me = ev as MessageEvent<string>;
try {
items.push(parse(me.data));
if (items.length > capacity) items.splice(0, items.length - capacity);
push();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
push();
}
});
es.addEventListener('error', () => {
connected = false;
push();
try {
es?.close();
} catch {
/* noop */
}
es = null;
schedule();
});
};
const schedule = () => {
if (closed || timer) return;
timer = setTimeout(() => {
timer = null;
connect();
}, reconnectMs);
};
connect();
return () => {
closed = true;
if (timer) clearTimeout(timer);
timer = null;
if (es) {
try {
es.close();
} catch {
/* noop */
}
}
};
}
);
}

View File

@@ -0,0 +1,27 @@
import { writable } from 'svelte/store';
export type ToastLevel = 'info' | 'success' | 'warn' | 'error';
export interface Toast {
id: number;
level: ToastLevel;
message: string;
ttl: number;
}
let nextId = 1;
export const toasts = writable<Toast[]>([]);
export function pushToast(t: { level: ToastLevel; message: string; ttl?: number }): void {
const id = nextId++;
const ttl = t.ttl ?? 5000;
toasts.update((list) => [...list, { id, level: t.level, message: t.message, ttl }]);
if (typeof window !== 'undefined') {
setTimeout(() => dismissToast(id), ttl);
}
}
export function dismissToast(id: number): void {
toasts.update((list) => list.filter((t) => t.id !== id));
}

View File

@@ -0,0 +1,173 @@
// Mirrors the Java DTOs described in ARCHITECTURE §4 & §8.
// Shapes are the agreed contract between frontend and the REST adapter;
// any backend divergence is noted in FRONTEND_NOTES.
export type VersionStatus = 'DISCOVERED' | 'INDEXING' | 'INDEXED' | 'FAILED' | 'INACTIVE';
export type JobType = 'DISCOVER_TAGS' | 'INDEX_VERSION' | 'COMPACT' | 'REFRESH';
export type JobStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
export type JobStageName =
| 'CLONE'
| 'FETCH'
| 'CHECKOUT'
| 'DISCOVER_FILES'
| 'PARSE'
| 'CHUNK'
| 'EMBED'
| 'INDEX'
| 'COMMIT';
export type JobStageStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'SKIPPED';
export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
export interface RepositoryDto {
id: string;
name: string;
remoteUrl: string | null;
localPath: string;
managedClone: boolean;
ignoreGlobs: string[];
maxFileSizeBytes: number;
pollIntervalSec: number;
versionMappingRules: string[];
createdAt: string;
updatedAt: string;
// Optional summary fields the backend may inline for list responses.
versionCount?: number;
indexedVersionCount?: number;
chunkCount?: number;
}
export interface CreateRepositoryRequest {
name: string;
remoteUrl?: string | null;
localPath?: string | null;
managedClone?: boolean;
ignoreGlobs?: string[];
maxFileSizeBytes?: number;
pollIntervalSec?: number;
versionMappingRules?: string[];
}
export interface VersionDto {
id: string;
repoId: string;
tag: string;
commitSha: string;
status: VersionStatus;
indexedAt: string | null;
chunkCount: number;
errorMessage: string | null;
}
export interface JobStageDto {
name: JobStageName;
status: JobStageStatus;
startedAt: string | null;
finishedAt: string | null;
itemsProcessed: number;
itemsTotal: number;
bytesProcessed: number;
errorMessage: string | null;
}
export interface JobDto {
id: string;
repoId: string;
repoName?: string;
versionId: string | null;
versionTag?: string | null;
type: JobType;
status: JobStatus;
startedAt: string | null;
finishedAt: string | null;
stages: JobStageDto[];
}
export interface JobLogEventDto {
jobId: string;
ts: string;
level: LogLevel;
stage: JobStageName | null;
message: string;
}
export interface SearchScope {
repoId: string;
versionId?: string | null;
tag?: string | null;
}
export interface SearchRequest {
text: string;
topic?: string | null;
scope: { repoId: string; versionId: string }[];
tokensBudget?: number;
maxHits?: number;
}
export interface SearchHitDto {
chunkId: string;
score: number;
repoId: string;
repoName: string;
versionId: string;
tag: string;
filePath: string;
startLine: number;
endLine: number;
language: string;
symbol: string | null;
content: string;
}
export interface SearchResponseDto {
hits: SearchHitDto[];
totalTokens: number;
tookMs: number;
}
export interface ResolveRequest {
libraryName: string;
query?: string | null;
}
export interface ResolveMatchDto {
libraryId: string;
name: string;
description: string | null;
snippetCount: number;
versions: string[];
score: number;
}
export interface ResourcesDto {
/** Nested heap object from /api/observability/resources */
heap: { usedBytes: number; maxBytes: number; totalBytes: number };
luceneIndexBytes: number;
embeddingCacheBytes: number;
trueRefHome: string;
/** null when nvidia-smi is absent or the device is not found */
gpu: { deviceId: number; usedBytes: number; freeBytes: number; totalBytes: number } | null;
}
export interface MetricsDto {
totalChunks: number;
totalRepositories: number;
totalVersionsIndexed: number;
jobsByStatus: Record<string, number>;
jobsSampled: number;
jobsSampleLimit: number;
embedderAvailable: boolean;
rerankerAvailable: boolean;
}
export interface Page<T> {
items: T[];
total: number;
limit: number;
offset: number;
}

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { page } from '$app/state';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import { listJobs } from '$lib/api';
import '../app.css';
let { children } = $props();
let runningCount = $state<number | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
async function pollRunning() {
try {
const res = await listJobs({ status: 'RUNNING', limit: 1 });
runningCount = res.length;
} catch {
// handled via toast
}
}
onMount(() => {
pollRunning();
timer = setInterval(pollRunning, 5000);
});
onDestroy(() => {
if (timer) clearInterval(timer);
});
const nav = [
{ href: '/', label: 'Dashboard' },
{ href: '/repositories', label: 'Repositories' },
{ href: '/jobs', label: 'Jobs' },
{ href: '/search', label: 'Search' },
{ href: '/resources', label: 'Resources' }
];
function isActive(href: string): boolean {
const p = page.url.pathname;
if (href === '/') return p === '/';
return p === href || p.startsWith(href + '/');
}
</script>
<div class="shell">
<aside class="side">
<div class="brand">
<span class="logo"></span>
<span class="name">trueref</span>
</div>
<nav>
{#each nav as item (item.href)}
<a href={item.href} class:active={isActive(item.href)}>{item.label}</a>
{/each}
</nav>
<div class="side-foot">
<div class="env">{import.meta.env.DEV ? 'dev' : 'prod'}</div>
</div>
</aside>
<div class="main">
<header class="topbar">
<div class="title">trueref</div>
<div class="spacer"></div>
<div class="live">
<span class="dot" class:on={(runningCount ?? 0) > 0}></span>
<span class="txt">
{runningCount === null ? '…' : runningCount} running
</span>
</div>
</header>
<main class="content">
{@render children()}
</main>
</div>
<ToastContainer />
</div>
<style>
.shell {
display: grid;
grid-template-columns: 220px 1fr;
min-height: 100vh;
}
.side {
border-right: 1px solid var(--border);
background: var(--bg-alt);
display: flex;
flex-direction: column;
padding: 16px 10px;
gap: 16px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
}
.brand .logo {
color: var(--accent);
font-size: 22px;
}
.brand .name {
font-weight: 700;
letter-spacing: 0.04em;
}
nav {
display: flex;
flex-direction: column;
gap: 2px;
}
nav a {
display: block;
padding: 8px 10px;
border-radius: 6px;
color: var(--fg-dim);
text-decoration: none;
font-size: 14px;
}
nav a:hover {
background: var(--bg-card);
color: var(--fg);
}
nav a.active {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--fg);
}
.side-foot {
margin-top: auto;
font-size: 11px;
color: var(--muted);
padding: 6px 10px;
}
.env {
text-transform: uppercase;
letter-spacing: 0.1em;
}
.topbar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg);
position: sticky;
top: 0;
z-index: 10;
}
.title {
font-weight: 700;
letter-spacing: 0.08em;
}
.spacer {
flex: 1;
}
.live {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--fg-dim);
}
.live .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.live .dot.on {
background: var(--ok);
box-shadow: 0 0 8px var(--ok);
animation: blink 1.4s ease-in-out infinite;
}
@keyframes blink {
50% {
opacity: 0.5;
}
}
.content {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
</style>

View File

@@ -0,0 +1,4 @@
// Static SPA; disable SSR so SvelteKit emits a single index.html fallback.
export const prerender = false;
export const ssr = false;
export const trailingSlash = 'never';

View File

@@ -0,0 +1,218 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getMetrics, getResources, listJobs } from '$lib/api';
import { sseStore } from '$lib/sse';
import type { JobDto, MetricsDto, ResourcesDto } from '$lib/types';
import JobRow from '$lib/components/JobRow.svelte';
import StageProgress from '$lib/components/StageProgress.svelte';
import BarChart from '$lib/components/BarChart.svelte';
import { formatBytes, percent } from '$lib/format';
let recentJobs = $state<JobDto[]>([]);
let resources = $state<ResourcesDto | null>(null);
let metrics = $state<MetricsDto | null>(null);
const liveJobs = sseStore<JobDto[]>('/api/jobs/stream', {
parse: (raw) => {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as JobDto[]) : ([parsed] as JobDto[]);
},
event: 'job',
initial: []
});
let recentTimer: ReturnType<typeof setInterval> | null = null;
let resTimer: ReturnType<typeof setInterval> | null = null;
async function reloadRecent() {
try {
const r = await listJobs({ limit: 20 });
recentJobs = r;
} catch {
/* toasted */
}
}
async function reloadResources() {
try {
[resources, metrics] = await Promise.all([getResources(), getMetrics()]);
} catch {
/* toasted */
}
}
onMount(() => {
reloadRecent();
reloadResources();
recentTimer = setInterval(reloadRecent, 10000);
resTimer = setInterval(reloadResources, 10000);
});
onDestroy(() => {
if (recentTimer) clearInterval(recentTimer);
if (resTimer) clearInterval(resTimer);
});
let totalChunks = $derived(metrics?.totalChunks ?? 0);
let chunkBars = $derived<{ label: string; value: number }[]>([]);
</script>
<div class="page-header">
<h1>Dashboard</h1>
<div class="muted">live via SSE + 10s polling</div>
</div>
<div class="grid layout">
<section class="card live-jobs">
<div class="card-head">
<h2>Jobs running now</h2>
<span class="muted">{$liveJobs.connected ? 'live' : ($liveJobs.error ? 'reconnecting…' : 'connecting…')}</span>
</div>
{#if ($liveJobs.value?.length ?? 0) === 0}
<div class="empty">No jobs running.</div>
{:else}
<div class="live-list">
{#each $liveJobs.value! as j (j.id)}
<div class="live-job">
<div class="live-job-head">
<div>
<strong>{j.repoName ?? j.repoId.slice(0, 8)}</strong>
{#if j.versionTag}<span class="mono tag">@ {j.versionTag}</span>{/if}
</div>
<a href={`/jobs/${j.id}`}>open →</a>
</div>
<div class="stages">
{#each j.stages ?? [] as s (s.name)}
<StageProgress stage={s} />
{/each}
</div>
</div>
{/each}
</div>
{/if}
</section>
<section class="card resources">
<div class="card-head">
<h2>Resources</h2>
</div>
{#if !resources}
<div class="empty">Loading…</div>
{:else}
<div class="res-grid">
<div>
<h3>Heap</h3>
<div class="big">{formatBytes(resources.heap.usedBytes)}</div>
<div class="muted">
of {formatBytes(resources.heap.maxBytes)} ({percent(resources.heap.usedBytes, resources.heap.maxBytes).toFixed(0)}%)
</div>
</div>
<div>
<h3>Index size</h3>
<div class="big">{formatBytes(resources.luceneIndexBytes)}</div>
<div class="muted">embedding cache {formatBytes(resources.embeddingCacheBytes)}</div>
</div>
<div>
<h3>Jobs</h3>
<div class="big">{metrics?.jobsByStatus?.['RUNNING'] ?? 0} running</div>
<div class="muted">{metrics?.jobsByStatus?.['FAILED'] ?? 0} failed · {metrics?.jobsByStatus?.['QUEUED'] ?? 0} queued</div>
</div>
<div>
<h3>Indexed</h3>
<div class="big">{metrics?.totalChunks?.toLocaleString() ?? '—'} chunks</div>
<div class="muted">{metrics?.totalVersionsIndexed ?? 0} versions · {metrics?.totalRepositories ?? 0} repos</div>
</div>
</div>
{/if}
</section>
<section class="card chunks">
<div class="card-head">
<h2>Chunks indexed</h2>
<span class="mono">{totalChunks.toLocaleString()}</span>
</div>
<BarChart data={chunkBars} height={220} />
</section>
<section class="card recent">
<div class="card-head">
<h2>Last 20 jobs</h2>
<a href="/jobs">all jobs →</a>
</div>
{#if recentJobs.length === 0}
<div class="empty">No jobs yet.</div>
{:else}
<div class="rows">
{#each recentJobs as j (j.id)}
<JobRow job={j} onclick={(jj) => goto(`/jobs/${jj.id}`)} />
{/each}
</div>
{/if}
</section>
</div>
<style>
.layout {
grid-template-columns: repeat(2, 1fr);
}
.live-jobs,
.recent {
grid-column: span 2;
}
.card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
}
.rows {
display: flex;
flex-direction: column;
gap: 6px;
}
.live-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.live-job {
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
background: var(--bg-alt);
}
.live-job-head {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-bottom: 6px;
}
.tag {
color: var(--fg-dim);
margin-left: 6px;
}
.stages {
display: flex;
flex-direction: column;
gap: 2px;
}
.res-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.big {
font-size: 20px;
font-weight: 600;
font-family: var(--mono);
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.live-jobs,
.recent {
grid-column: span 1;
}
}
</style>

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { goto } from '$app/navigation';
import { listJobs, listRepos } from '$lib/api';
import JobRow from '$lib/components/JobRow.svelte';
import type { JobDto, JobStatus, RepositoryDto } from '$lib/types';
const PAGE_SIZE = 25;
const statuses: (JobStatus | '')[] = ['', 'QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'];
let repos = $state<RepositoryDto[]>([]);
let jobs = $state<JobDto[]>([]);
let total = $state(0);
let offset = $state(0);
let loading = $state(false);
let repoFilter = $state('');
let statusFilter = $state<JobStatus | ''>('');
let timer: ReturnType<typeof setInterval> | null = null;
async function reload() {
loading = true;
try {
const r = await listJobs({
repoId: repoFilter || undefined,
status: statusFilter || undefined,
limit: PAGE_SIZE,
offset
});
jobs = r;
total = r.length;
} catch {
/* toasted */
} finally {
loading = false;
}
}
onMount(async () => {
try {
repos = await listRepos();
} catch {
/* toasted */
}
await reload();
timer = setInterval(reload, 5000);
});
onDestroy(() => {
if (timer) clearInterval(timer);
});
function onFilterChange() {
offset = 0;
reload();
}
</script>
<div class="page-header">
<h1>Jobs</h1>
<div class="muted">{total.toLocaleString()} total</div>
</div>
<div class="card filters">
<label>
Repository
<select bind:value={repoFilter} onchange={onFilterChange}>
<option value="">all</option>
{#each repos as r (r.id)}
<option value={r.id}>{r.name}</option>
{/each}
</select>
</label>
<label>
Status
<select bind:value={statusFilter} onchange={onFilterChange}>
{#each statuses as s (s)}
<option value={s}>{s || 'all'}</option>
{/each}
</select>
</label>
</div>
{#if loading && jobs.length === 0}
<div class="empty">Loading…</div>
{:else if jobs.length === 0}
<div class="card empty">No jobs match.</div>
{:else}
<div class="rows">
{#each jobs as j (j.id)}
<JobRow job={j} onclick={(jj) => goto(`/jobs/${jj.id}`)} />
{/each}
</div>
<div class="pager">
<button disabled={offset === 0} onclick={() => { offset = Math.max(0, offset - PAGE_SIZE); reload(); }}>
← Prev
</button>
<span class="mono muted">
{offset + 1}{Math.min(offset + jobs.length, total)} of {total}
</span>
<button
disabled={offset + jobs.length >= total}
onclick={() => { offset += PAGE_SIZE; reload(); }}
>
Next →
</button>
</div>
{/if}
<style>
.filters {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.rows {
display: flex;
flex-direction: column;
gap: 6px;
}
.pager {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { page } from '$app/state';
import { getJob } from '$lib/api';
import type { JobDto } from '$lib/types';
import VersionBadge from '$lib/components/VersionBadge.svelte';
import StageProgress from '$lib/components/StageProgress.svelte';
import LogTail from '$lib/components/LogTail.svelte';
import { formatRelative } from '$lib/format';
let job = $state<JobDto | null>(null);
let loading = $state(true);
let timer: ReturnType<typeof setInterval> | null = null;
let id = $derived(page.params.id ?? '');
async function reload() {
try {
job = await getJob(id);
} catch {
/* toasted */
} finally {
loading = false;
}
}
onMount(() => {
reload();
timer = setInterval(reload, 3000);
});
onDestroy(() => {
if (timer) clearInterval(timer);
});
</script>
<div class="page-header">
<div>
<div class="muted"><a href="/jobs">← Jobs</a></div>
<h1>Job {id.slice(0, 8)}</h1>
</div>
{#if job}<VersionBadge status={job.status} />{/if}
</div>
{#if loading && !job}
<div class="empty">Loading…</div>
{:else if job}
<section class="card meta">
<div><h3>Type</h3><div>{job.type}</div></div>
<div>
<h3>Repository</h3>
<div><a href={`/repositories/${job.repoId}`}>{job.repoName ?? job.repoId.slice(0, 8)}</a></div>
</div>
<div><h3>Version</h3><div class="mono">{job.versionTag ?? '—'}</div></div>
<div><h3>Started</h3><div>{formatRelative(job.startedAt)}</div></div>
<div><h3>Finished</h3><div>{formatRelative(job.finishedAt)}</div></div>
</section>
<section class="card">
<div class="card-head"><h2>Stages</h2></div>
<div class="stages">
{#each job.stages as s (s.name)}
<StageProgress stage={s} />
{/each}
</div>
</section>
<section class="card">
<div class="card-head"><h2>Log</h2></div>
<LogTail jobId={id} height="420px" jobStatus={job.status} />
</section>
{/if}
<style>
.meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.card {
margin-bottom: 16px;
}
.card-head {
margin-bottom: 10px;
}
.stages {
display: flex;
flex-direction: column;
gap: 4px;
}
</style>

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { createRepo, discoverTags, listRepos } from '$lib/api';
import RepoCard from '$lib/components/RepoCard.svelte';
import { pushToast } from '$lib/toast';
import type { RepositoryDto } from '$lib/types';
let repos = $state<RepositoryDto[]>([]);
let loading = $state(true);
let showDialog = $state(false);
let form = $state({
name: '',
remoteUrl: '',
localPath: '',
managedClone: true,
maxFileSizeBytes: 1048576,
pollIntervalSec: 3600
});
async function reload() {
loading = true;
try {
repos = await listRepos();
} catch {
/* toasted */
} finally {
loading = false;
}
}
onMount(reload);
async function submit(e: SubmitEvent) {
e.preventDefault();
try {
await createRepo({
name: form.name,
remoteUrl: form.remoteUrl || null,
localPath: form.localPath || null,
managedClone: form.managedClone,
maxFileSizeBytes: form.maxFileSizeBytes,
pollIntervalSec: form.pollIntervalSec
});
pushToast({ level: 'success', message: `Registered ${form.name}` });
showDialog = false;
form = {
name: '',
remoteUrl: '',
localPath: '',
managedClone: true,
maxFileSizeBytes: 1048576,
pollIntervalSec: 3600
};
await reload();
} catch {
/* toasted */
}
}
async function onDiscover(r: RepositoryDto) {
try {
await discoverTags(r.id);
pushToast({ level: 'info', message: `Tag discovery started for ${r.name}` });
} catch {
/* toasted */
}
}
</script>
<div class="page-header">
<h1>Repositories</h1>
<button class="primary" type="button" onclick={() => (showDialog = true)}>Add repository</button>
</div>
{#if loading}
<div class="empty">Loading…</div>
{:else if repos.length === 0}
<div class="card empty">No repositories yet. Click "Add repository" to register one.</div>
{:else}
<div class="grid cards">
{#each repos as r (r.id)}
<RepoCard repo={r} onopen={(rr) => goto(`/repositories/${rr.id}`)} ondiscover={onDiscover} />
{/each}
</div>
{/if}
{#if showDialog}
<div
class="modal-backdrop"
role="button"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) showDialog = false;
}}
onkeydown={(e) => {
if (e.key === 'Escape') showDialog = false;
}}
>
<form class="modal" onsubmit={submit}>
<h2>Register repository</h2>
<label>
Name
<input required bind:value={form.name} placeholder="spring-projects/spring-boot" />
</label>
<label>
Remote URL (optional)
<input bind:value={form.remoteUrl} placeholder="https://github.com/…" />
</label>
<label>
Local path (optional)
<input bind:value={form.localPath} placeholder="/abs/path/to/repo" />
</label>
<label class="row">
<input type="checkbox" bind:checked={form.managedClone} />
Managed clone (trueref clones & fetches)
</label>
<div class="two">
<label>
Max file size (bytes)
<input type="number" min="1024" bind:value={form.maxFileSizeBytes} />
</label>
<label>
Poll interval (sec)
<input type="number" min="0" bind:value={form.pollIntervalSec} />
</label>
</div>
<div class="actions">
<button type="button" onclick={() => (showDialog = false)}>Cancel</button>
<button type="submit" class="primary">Register</button>
</div>
</form>
</div>
{/if}
<style>
.cards {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.modal label {
margin-bottom: 10px;
}
.modal label.row {
flex-direction: row;
align-items: center;
gap: 8px;
}
.two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,232 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import {
discoverTags,
getRepo,
indexVersion,
listVersions,
reindexVersion
} from '$lib/api';
import type { RepositoryDto, VersionDto } from '$lib/types';
import VersionBadge from '$lib/components/VersionBadge.svelte';
import { pushToast } from '$lib/toast';
import { formatRelative } from '$lib/format';
let repo = $state<RepositoryDto | null>(null);
let versions = $state<VersionDto[]>([]);
let loading = $state(true);
let filter = $state('');
let adHocTag = $state('');
let id = $derived(page.params.id ?? '');
async function reload() {
loading = true;
try {
const [r, v] = await Promise.all([getRepo(id), listVersions(id)]);
repo = r;
versions = v;
} catch {
/* toasted */
} finally {
loading = false;
}
}
onMount(reload);
let filtered = $derived.by(() => {
const q = filter.trim().toLowerCase();
if (!q) return versions;
return versions.filter(
(v) => v.tag.toLowerCase().includes(q) || v.commitSha.toLowerCase().includes(q)
);
});
async function onDiscover() {
try {
await discoverTags(id);
pushToast({ level: 'info', message: 'Tag discovery started' });
} catch {
/* toasted */
}
}
async function doIndex(tag: string, force = false) {
try {
if (force) await reindexVersion(id, tag);
else await indexVersion(id, tag);
pushToast({ level: 'info', message: `${force ? 'Re-index' : 'Index'} queued for ${tag}` });
await reload();
} catch {
/* toasted */
}
}
let reindexingAll = $state(false);
async function reindexAllFailed() {
const failed = versions.filter((v) => v.status === 'FAILED');
if (failed.length === 0) return;
reindexingAll = true;
try {
await Promise.all(failed.map((v) => reindexVersion(id, v.tag).catch(() => {})));
pushToast({ level: 'info', message: `Re-index queued for ${failed.length} failed version(s)` });
await reload();
} finally {
reindexingAll = false;
}
}
async function submitAdHoc(e: SubmitEvent) {
e.preventDefault();
const t = adHocTag.trim();
if (!t) return;
await doIndex(t, false);
adHocTag = '';
}
</script>
<div class="page-header">
<div>
<div class="muted"><a href="/repositories">← Repositories</a></div>
<h1>{repo?.name ?? id}</h1>
</div>
<div class="actions">
<button type="button" onclick={onDiscover}>Discover tags</button>
</div>
</div>
{#if loading}
<div class="empty">Loading…</div>
{:else if repo}
<section class="card meta">
<div>
<h3>Remote</h3>
<div class="mono">{repo.remoteUrl ?? '—'}</div>
</div>
<div>
<h3>Local path</h3>
<div class="mono">{repo.localPath}</div>
</div>
<div>
<h3>Managed clone</h3>
<div>{repo.managedClone ? 'yes' : 'no'}</div>
</div>
<div>
<h3>Poll interval</h3>
<div>{repo.pollIntervalSec}s</div>
</div>
<div>
<h3>Max file size</h3>
<div>{repo.maxFileSizeBytes.toLocaleString()} B</div>
</div>
<div>
<h3>Updated</h3>
<div>{formatRelative(repo.updatedAt)}</div>
</div>
</section>
<section class="card">
<div class="card-head">
<h2>Index specific tag</h2>
</div>
<form class="adhoc" onsubmit={submitAdHoc}>
<input bind:value={adHocTag} placeholder="v1.2.3 or any git ref" />
<button type="submit" class="primary" disabled={!adHocTag.trim()}>Index</button>
</form>
<p class="muted">User-supplied tags are accepted; if the ref exists in the repo it will be checked out and indexed.</p>
</section>
<section class="card">
<div class="card-head">
<h2>Versions ({versions.length})</h2>
<div class="head-actions">
{#if versions.some((v) => v.status === 'FAILED')}
<button type="button" class="danger" onclick={reindexAllFailed} disabled={reindexingAll}>
{reindexingAll ? 'Queuing…' : `Re-index all failed (${versions.filter((v) => v.status === 'FAILED').length})`}
</button>
{/if}
<input type="search" bind:value={filter} placeholder="filter by tag or sha…" />
</div>
</div>
{#if filtered.length === 0}
<div class="empty">No versions match.</div>
{:else}
<table>
<thead>
<tr>
<th>Tag</th>
<th>Commit</th>
<th>Status</th>
<th>Chunks</th>
<th>Indexed</th>
<th></th>
</tr>
</thead>
<tbody>
{#each filtered as v (v.id)}
<tr>
<td class="mono">{v.tag}</td>
<td class="mono muted">{v.commitSha.slice(0, 10)}</td>
<td><VersionBadge status={v.status} /></td>
<td class="mono">{v.chunkCount.toLocaleString()}</td>
<td class="muted">{formatRelative(v.indexedAt)}</td>
<td class="row-actions">
{#if v.status === 'INDEXED'}
<button type="button" onclick={() => doIndex(v.tag, true)}>Re-index</button>
{:else}
<button type="button" onclick={() => doIndex(v.tag, false)}>Index</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
{/if}
<style>
.meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.card {
margin-bottom: 16px;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
gap: 10px;
}
.adhoc {
display: flex;
gap: 8px;
align-items: center;
}
.adhoc input {
flex: 1;
}
.row-actions {
text-align: right;
}
.head-actions {
display: flex;
align-items: center;
gap: 8px;
}
button.danger {
background: var(--error, #c0392b);
color: #fff;
border-color: transparent;
}
button.danger:hover:not(:disabled) {
filter: brightness(1.1);
}
</style>

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { getResources } from '$lib/api';
import type { ResourcesDto } from '$lib/types';
import Sparkline from '$lib/components/Sparkline.svelte';
import { formatBytes, percent } from '$lib/format';
const CAPACITY = 120; // ~20 min at 10s cadence
let current = $state<ResourcesDto | null>(null);
let heap = $state<number[]>([]);
let gpu = $state<number[]>([]);
let index = $state<number[]>([]);
let timer: ReturnType<typeof setInterval> | null = null;
function push(arr: number[], v: number): number[] {
const next = arr.concat(v);
if (next.length > CAPACITY) next.splice(0, next.length - CAPACITY);
return next;
}
async function tick() {
try {
const r = await getResources();
current = r;
heap = push(heap, r.heap.usedBytes);
gpu = push(gpu, r.gpu?.usedBytes ?? 0);
index = push(index, r.luceneIndexBytes);
} catch {
/* toasted */
}
}
onMount(() => {
tick();
timer = setInterval(tick, 10000);
});
onDestroy(() => {
if (timer) clearInterval(timer);
});
</script>
<div class="page-header">
<h1>Resources</h1>
<div class="muted">sampled every 10s · {heap.length}/{CAPACITY} points</div>
</div>
<div class="grid cols">
<section class="card">
<h2>Heap</h2>
<div class="big">{formatBytes(current?.heap.usedBytes)}</div>
<div class="muted">
of {formatBytes(current?.heap.maxBytes)}
({current ? percent(current.heap.usedBytes, current.heap.maxBytes).toFixed(0) : '—'}%)
</div>
<Sparkline data={heap} max={current?.heap.maxBytes} label="used bytes" height={120} />
</section>
<section class="card">
<h2>GPU memory <span class="muted" style="font-size:12px">device {current?.gpu?.deviceId ?? '—'}</span></h2>
{#if current?.gpu}
<div class="big">{formatBytes(current.gpu.usedBytes)}</div>
<div class="muted">
of {formatBytes(current.gpu.totalBytes)}
({percent(current.gpu.usedBytes, current.gpu.totalBytes).toFixed(0)}%)
· {formatBytes(current.gpu.freeBytes)} free
</div>
{:else}
<div class="big"></div>
<div class="muted">nvidia-smi unavailable</div>
{/if}
<Sparkline data={gpu} color="var(--warn)" label="used bytes" height={120} />
</section>
<section class="card">
<h2>Lucene index</h2>
<div class="big">{formatBytes(current?.luceneIndexBytes)}</div>
<div class="muted">embedding cache {formatBytes(current?.embeddingCacheBytes)}</div>
<Sparkline data={index} color="var(--ok)" label="index bytes" height={120} />
</section>
<section class="card">
<h2>Home</h2>
<div class="big" style="font-size:13px;word-break:break-all">{current?.trueRefHome ?? '—'}</div>
</section>
</div>
<style>
.cols {
grid-template-columns: repeat(2, 1fr);
}
.big {
font-size: 24px;
font-weight: 600;
font-family: var(--mono);
margin-bottom: 2px;
}
@media (max-width: 900px) {
.cols {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,246 @@
<script lang="ts">
import { onMount } from 'svelte';
import { listRepos, listVersions, search } from '$lib/api';
import type { RepositoryDto, SearchHitDto, SearchScope, VersionDto } from '$lib/types';
import CodeBlock from '$lib/components/CodeBlock.svelte';
let repos = $state<RepositoryDto[]>([]);
let selectedRepoIds = $state<string[]>([]);
let versionsByRepo = $state<Record<string, VersionDto[]>>({});
let selectedVersionIds = $state<Record<string, string[]>>({});
let query = $state('');
let topic = $state('');
let tokens = $state(5000);
let hits = $state<SearchHitDto[]>([]);
let tookMs = $state<number | null>(null);
let totalTokens = $state<number | null>(null);
let loading = $state(false);
onMount(async () => {
try {
repos = await listRepos();
} catch {
/* toasted */
}
});
async function toggleRepo(id: string, on: boolean) {
if (on) {
if (!selectedRepoIds.includes(id)) selectedRepoIds = [...selectedRepoIds, id];
if (!versionsByRepo[id]) {
try {
const all = await listVersions(id);
versionsByRepo[id] = all.filter((v) => v.status === 'INDEXED');
} catch {
/* toasted */
}
}
} else {
selectedRepoIds = selectedRepoIds.filter((x) => x !== id);
}
}
function buildScopes(): SearchScope[] {
const scopes: SearchScope[] = [];
for (const rid of selectedRepoIds) {
const vs = selectedVersionIds[rid] ?? [];
if (vs.length === 0) {
scopes.push({ repoId: rid });
} else {
for (const v of vs) scopes.push({ repoId: rid, versionId: v });
}
}
return scopes;
}
async function submit(e: SubmitEvent) {
e.preventDefault();
const scopes = buildScopes();
if (scopes.length === 0) return;
loading = true;
try {
const res = await search({
text: query,
topic: topic || null,
scope: scopes
.filter((s) => s.versionId != null)
.map((s) => ({ repoId: s.repoId, versionId: s.versionId! })),
tokensBudget: tokens
});
hits = res.hits;
tookMs = res.tookMs;
totalTokens = res.totalTokens;
} catch {
hits = [];
} finally {
loading = false;
}
}
</script>
<div class="page-header">
<h1>Search</h1>
{#if tookMs !== null}
<div class="muted mono">
{hits.length} hits · {tookMs} ms · {totalTokens?.toLocaleString()} tokens
</div>
{/if}
</div>
<form class="card form" onsubmit={submit}>
<label class="full" for="query">
Query
<input id="query" name="query" required bind:value={query} placeholder="how to configure HikariCP pool size" />
</label>
<label for="topic">
Topic (optional)
<input id="topic" name="topic" bind:value={topic} placeholder="configuration" />
</label>
<label>
Token budget
<div class="slider">
<input type="range" min="500" max="50000" step="500" bind:value={tokens} />
<span class="mono">{tokens.toLocaleString()}</span>
</div>
</label>
<fieldset class="full scopes">
<legend>Scope</legend>
{#if repos.length === 0}
<div class="muted">No repositories. Register one first.</div>
{:else}
{#each repos as r (r.id)}
{@const sel = selectedRepoIds.includes(r.id)}
<div class="scope">
<label class="row">
<input
type="checkbox"
checked={sel}
onchange={(e) => toggleRepo(r.id, (e.target as HTMLInputElement).checked)}
/>
{r.name}
</label>
{#if sel && versionsByRepo[r.id]}
<label class="vers">
Versions
<select
multiple
size={Math.min(6, Math.max(3, versionsByRepo[r.id].length))}
bind:value={selectedVersionIds[r.id]}
>
{#each versionsByRepo[r.id] as v (v.id)}
<option value={v.id}>{v.tag} · {v.status}</option>
{/each}
</select>
</label>
{/if}
</div>
{/each}
{/if}
</fieldset>
<div class="actions full">
<button class="primary" type="submit" disabled={loading || selectedRepoIds.length === 0}>
{loading ? 'Searching…' : 'Search'}
</button>
</div>
</form>
{#if hits.length > 0}
<div class="hits">
{#each hits as h, i (h.chunkId + i)}
<article class="hit card">
<header>
<div class="head-left">
<strong>{h.repoName}</strong>
<span class="mono muted">@ {h.tag}</span>
<span class="mono path">{h.filePath}</span>
<span class="mono muted">L{h.startLine}-{h.endLine}</span>
</div>
<div class="head-right mono muted">
{h.language}{h.symbol ? ` · ${h.symbol}` : ''} · score {h.score.toFixed(3)}
</div>
</header>
<CodeBlock code={h.content} language={h.language} startLine={h.startLine} />
</article>
{/each}
</div>
{:else if tookMs !== null}
<div class="card empty">No results.</div>
{/if}
<style>
.form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 20px;
}
.full {
grid-column: 1 / -1;
}
.slider {
display: flex;
align-items: center;
gap: 10px;
}
.slider input {
flex: 1;
}
.scopes {
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.scopes legend {
padding: 0 6px;
color: var(--fg-dim);
font-size: 12px;
}
.scope {
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
}
.scope label.row {
flex-direction: row;
align-items: center;
gap: 6px;
}
.vers {
margin-top: 6px;
}
.vers select {
width: 100%;
}
.actions {
display: flex;
justify-content: flex-end;
}
.hits {
display: flex;
flex-direction: column;
gap: 14px;
}
.hit header {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.head-left {
display: flex;
gap: 8px;
align-items: baseline;
flex-wrap: wrap;
}
.path {
color: var(--accent);
}
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0b0d12"/><path d="M7 10h18M10 10v12h4v-8h4v8h4V10" stroke="#7aa2f7" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 255 B

View File

@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false
})
}
};
export default config;

View File

@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View File

@@ -0,0 +1,13 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': { target: 'http://localhost:8080', changeOrigin: true, ws: false },
'/mcp': { target: 'http://localhost:8080', changeOrigin: true, ws: false },
'/actuator': { target: 'http://localhost:8080', changeOrigin: true, ws: false }
}
}
});