Initial commit: trueref v0.1.0-SNAPSHOT
Some checks failed
Build and publish Docker image / Build and push (push) Failing after 1m27s
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:
63
trueref-frontend/pom.xml
Normal file
63
trueref-frontend/pom.xml
Normal 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
9
trueref-frontend/web/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
*.log
|
||||
1
trueref-frontend/web/.npmrc
Normal file
1
trueref-frontend/web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=false
|
||||
9
trueref-frontend/web/.prettierrc
Normal file
9
trueref-frontend/web/.prettierrc
Normal 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
2202
trueref-frontend/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
trueref-frontend/web/package.json
Normal file
22
trueref-frontend/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
190
trueref-frontend/web/src/app.css
Normal file
190
trueref-frontend/web/src/app.css
Normal 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
12
trueref-frontend/web/src/app.d.ts
vendored
Normal 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 {};
|
||||
13
trueref-frontend/web/src/app.html
Normal file
13
trueref-frontend/web/src/app.html
Normal 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>
|
||||
119
trueref-frontend/web/src/lib/api.ts
Normal file
119
trueref-frontend/web/src/lib/api.ts
Normal 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');
|
||||
73
trueref-frontend/web/src/lib/components/BarChart.svelte
Normal file
73
trueref-frontend/web/src/lib/components/BarChart.svelte
Normal 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>
|
||||
108
trueref-frontend/web/src/lib/components/CodeBlock.svelte
Normal file
108
trueref-frontend/web/src/lib/components/CodeBlock.svelte
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
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>
|
||||
69
trueref-frontend/web/src/lib/components/JobRow.svelte
Normal file
69
trueref-frontend/web/src/lib/components/JobRow.svelte
Normal 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>
|
||||
169
trueref-frontend/web/src/lib/components/LogTail.svelte
Normal file
169
trueref-frontend/web/src/lib/components/LogTail.svelte
Normal 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>
|
||||
85
trueref-frontend/web/src/lib/components/RepoCard.svelte
Normal file
85
trueref-frontend/web/src/lib/components/RepoCard.svelte
Normal 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>
|
||||
54
trueref-frontend/web/src/lib/components/Sparkline.svelte
Normal file
54
trueref-frontend/web/src/lib/components/Sparkline.svelte
Normal 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>
|
||||
71
trueref-frontend/web/src/lib/components/StageProgress.svelte
Normal file
71
trueref-frontend/web/src/lib/components/StageProgress.svelte
Normal 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>
|
||||
@@ -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>
|
||||
50
trueref-frontend/web/src/lib/components/VersionBadge.svelte
Normal file
50
trueref-frontend/web/src/lib/components/VersionBadge.svelte
Normal 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>
|
||||
45
trueref-frontend/web/src/lib/format.ts
Normal file
45
trueref-frontend/web/src/lib/format.ts
Normal 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);
|
||||
}
|
||||
186
trueref-frontend/web/src/lib/sse.ts
Normal file
186
trueref-frontend/web/src/lib/sse.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
27
trueref-frontend/web/src/lib/toast.ts
Normal file
27
trueref-frontend/web/src/lib/toast.ts
Normal 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));
|
||||
}
|
||||
173
trueref-frontend/web/src/lib/types.ts
Normal file
173
trueref-frontend/web/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
187
trueref-frontend/web/src/routes/+layout.svelte
Normal file
187
trueref-frontend/web/src/routes/+layout.svelte
Normal 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>
|
||||
4
trueref-frontend/web/src/routes/+layout.ts
Normal file
4
trueref-frontend/web/src/routes/+layout.ts
Normal 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';
|
||||
218
trueref-frontend/web/src/routes/+page.svelte
Normal file
218
trueref-frontend/web/src/routes/+page.svelte
Normal 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>
|
||||
128
trueref-frontend/web/src/routes/jobs/+page.svelte
Normal file
128
trueref-frontend/web/src/routes/jobs/+page.svelte
Normal 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>
|
||||
91
trueref-frontend/web/src/routes/jobs/[id]/+page.svelte
Normal file
91
trueref-frontend/web/src/routes/jobs/[id]/+page.svelte
Normal 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>
|
||||
160
trueref-frontend/web/src/routes/repositories/+page.svelte
Normal file
160
trueref-frontend/web/src/routes/repositories/+page.svelte
Normal 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>
|
||||
232
trueref-frontend/web/src/routes/repositories/[id]/+page.svelte
Normal file
232
trueref-frontend/web/src/routes/repositories/[id]/+page.svelte
Normal 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>
|
||||
103
trueref-frontend/web/src/routes/resources/+page.svelte
Normal file
103
trueref-frontend/web/src/routes/resources/+page.svelte
Normal 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>
|
||||
246
trueref-frontend/web/src/routes/search/+page.svelte
Normal file
246
trueref-frontend/web/src/routes/search/+page.svelte
Normal 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>
|
||||
1
trueref-frontend/web/static/favicon.svg
Normal file
1
trueref-frontend/web/static/favicon.svg
Normal 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 |
18
trueref-frontend/web/svelte.config.js
Normal file
18
trueref-frontend/web/svelte.config.js
Normal 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;
|
||||
14
trueref-frontend/web/tsconfig.json
Normal file
14
trueref-frontend/web/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
trueref-frontend/web/vite.config.ts
Normal file
13
trueref-frontend/web/vite.config.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user