feat(ui): add maintenance page for task management
Some checks failed
CI / build-and-test (push) Failing after -31m53s

* Implement maintenance page with task and log display
* Add backfill and metadata retry functionality
* Integrate grid component for project display in thoughts page
* Update types for maintenance tasks and logs
* Enhance sidebar and shell for new maintenance navigation
This commit is contained in:
2026-04-26 23:13:41 +02:00
parent b39cd3ba72
commit 927a118338
48 changed files with 2228 additions and 868 deletions

View File

@@ -11,22 +11,22 @@
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.4",
"@types/node": "^24.5.2",
"svelte": "^5.28.2",
"svelte-check": "^4.1.6",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"vite": "^6.3.2"
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.4",
"@types/node": "^25.6.0",
"svelte": "^5.55.5",
"svelte-check": "^4.4.6",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"vite": "^8.0.10"
},
"dependencies": {
"@sentry/svelte": "^10.49.0",
"@sentry/svelte": "^10.50.0",
"@skeletonlabs/skeleton": "^4.15.2",
"@skeletonlabs/skeleton-svelte": "^4.15.2",
"@tanstack/svelte-virtual": "^3.13.24",
"@warkypublic/artemis-kit": "file:../../artemis-kit",
"@warkypublic/resolvespec-js": "^1.0.1",
"@warkypublic/svelix": "^0.1.31"
"@warkypublic/svelix": "^0.1.39"
}
}
}

1034
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -111,7 +111,27 @@
if (!response.ok) {
throw new Error(`Status request failed with ${response.status}`);
}
data = (await response.json()) as StatusResponse;
const raw = (await response.json()) as Partial<StatusResponse> | null;
data = {
title: raw?.title ?? 'AMCS',
description: raw?.description ?? '',
version: raw?.version ?? 'unknown',
build_date: raw?.build_date ?? 'unknown',
commit: raw?.commit ?? 'unknown',
connected_count: raw?.connected_count ?? 0,
total_known: raw?.total_known ?? 0,
connected_window: raw?.connected_window ?? 'last 10 minutes',
oauth_enabled: !!raw?.oauth_enabled,
entries: Array.isArray(raw?.entries) ? raw.entries : [],
metrics: {
total_requests: raw?.metrics?.total_requests ?? 0,
unique_principals: raw?.metrics?.unique_principals ?? 0,
unique_ips: raw?.metrics?.unique_ips ?? 0,
unique_agents: raw?.metrics?.unique_agents ?? 0,
top_ips: Array.isArray(raw?.metrics?.top_ips) ? raw.metrics.top_ips : [],
top_agents: Array.isArray(raw?.metrics?.top_agents) ? raw.metrics.top_agents : []
}
};
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load status';
} finally {

View File

@@ -94,20 +94,21 @@ async function rsCall<T>(
function rsReadMany<T>(
entity: string,
options?: { filters?: ResolveSpecFilter[]; limit?: number; sort?: { column: string; direction: 'asc' | 'desc' }[] }
options?: { filters?: ResolveSpecFilter[]; limit?: number; offset?: number; sort?: { column: string; direction: 'asc' | 'desc' }[] }
): Promise<T[]> {
return rsCall<T[]>(`/api/rs/public/${entity}`, 'read', {
return rsCall<T[] | null>(`/api/rs/public/${entity}`, 'read', {
options: {
...(options?.filters?.length ? { filters: options.filters } : {}),
...(options?.sort?.length ? { sort: options.sort } : {}),
...(options?.limit ? { limit: options.limit } : {})
...(options?.limit ? { limit: options.limit } : {}),
...(options?.offset ? { offset: options.offset } : {})
}
});
}).then((rows) => (Array.isArray(rows) ? rows : []));
}
export const api = {
projects: {
list: async () => {
list: async (params?: { limit?: number; offset?: number; q?: string }) => {
type ProjectRow = {
guid: string;
name: string;
@@ -117,7 +118,12 @@ export const api = {
thought_count?: number;
};
const projects = await rsCall<ProjectRow[]>('/api/rs/public/projects', 'read', {
const filters: ResolveSpecFilter[] = [];
if (params?.q) {
filters.push({ column: 'name', operator: 'ilike', value: `%${params.q}%` });
}
const projectRows = await rsCall<ProjectRow[] | null>('/api/rs/public/projects', 'read', {
options: {
columns: ['guid', 'name', 'description', 'created_at', 'last_active_at'],
computedColumns: [
@@ -126,11 +132,14 @@ export const api = {
expression: 'COALESCE((SELECT COUNT(*) FROM public.thoughts t WHERE t.project_id = projects.guid), 0)'
}
],
...(filters.length ? { filters } : {}),
sort: [{ column: 'created_at', direction: 'desc' }],
limit: 500
limit: params?.limit ?? 100,
offset: params?.offset ?? 0
}
});
const projects = Array.isArray(projectRows) ? projectRows : [];
return projects.map((project) => ({
id: project.guid,
name: project.name,
@@ -146,7 +155,7 @@ export const api = {
})
},
thoughts: {
list: (params: { q?: string; project_id?: string; limit?: number; include_archived?: boolean }) => {
list: (params: { q?: string; project_id?: string; limit?: number; offset?: number; include_archived?: boolean }) => {
const filters: ResolveSpecFilter[] = [];
if (params.q) {
filters.push({ column: 'content', operator: 'ilike', value: `%${params.q}%` });
@@ -160,10 +169,65 @@ export const api = {
return rsReadMany<import('./types').Thought>('thoughts', {
filters,
limit: params.limit ?? 100,
...(params.offset !== undefined ? { offset: params.offset } : {}),
sort: [{ column: 'created_at', direction: 'desc' }]
});
}).then((rows) =>
rows.map((row) => ({
...row,
content: typeof row.content === 'string' ? row.content : '',
metadata: {
...(row.metadata ?? {}),
people: row.metadata?.people ?? [],
action_items: row.metadata?.action_items ?? [],
dates_mentioned: row.metadata?.dates_mentioned ?? [],
topics: row.metadata?.topics ?? [],
type: row.metadata?.type ?? '',
source: row.metadata?.source ?? '',
metadata_status: row.metadata?.metadata_status ?? ''
}
}))
);
},
get: (id: string) => rsCall<import('./types').Thought>(`/api/rs/public/thoughts/${id}`, 'read'),
links: async (thoughtID: string) => {
const numericID = Number.parseInt(thoughtID, 10);
if (Number.isNaN(numericID)) return [];
const [outbound, inbound] = await Promise.all([
rsReadMany<import('./types').ThoughtLink>('thought_links', {
filters: [{ column: 'from_id', operator: 'eq', value: numericID }],
limit: 200,
sort: [{ column: 'created_at', direction: 'desc' }]
}),
rsReadMany<import('./types').ThoughtLink>('thought_links', {
filters: [{ column: 'to_id', operator: 'eq', value: numericID }],
limit: 200,
sort: [{ column: 'created_at', direction: 'desc' }]
})
]);
const byID = new Map<number, import('./types').ThoughtLink>();
for (const link of outbound) byID.set(link.id, link);
for (const link of inbound) byID.set(link.id, link);
return Array.from(byID.values()).sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
},
get: (id: string) =>
rsCall<import('./types').Thought>(`/api/rs/public/thoughts/${id}`, 'read').then((row) => ({
...row,
content: typeof row.content === 'string' ? row.content : '',
metadata: {
...(row.metadata ?? {}),
people: row.metadata?.people ?? [],
action_items: row.metadata?.action_items ?? [],
dates_mentioned: row.metadata?.dates_mentioned ?? [],
topics: row.metadata?.topics ?? [],
type: row.metadata?.type ?? '',
source: row.metadata?.source ?? '',
metadata_status: row.metadata?.metadata_status ?? ''
}
})),
delete: (id: string) => rsCall<void>(`/api/rs/public/thoughts/${id}`, 'delete'),
archive: (id: string) =>
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', {
@@ -209,6 +273,46 @@ export const api = {
});
}
},
maintenance: {
tasks: () =>
rsReadMany<import('./types').MaintenanceTask>('maintenance_tasks', {
limit: 200,
sort: [{ column: 'next_due', direction: 'asc' }]
}),
logs: () =>
rsReadMany<import('./types').MaintenanceLog>('maintenance_logs', {
limit: 200,
sort: [{ column: 'completed_at', direction: 'desc' }]
}),
runBackfill: (input?: {
project?: string;
limit?: number;
include_archived?: boolean;
older_than_days?: number;
dry_run?: boolean;
}) =>
post<import('./types').BackfillResult>('/api/admin/actions/backfill', {
limit: input?.limit ?? 100,
project: input?.project,
include_archived: input?.include_archived ?? false,
older_than_days: input?.older_than_days ?? 0,
dry_run: input?.dry_run ?? false
}),
runRetryMetadata: (input?: {
project?: string;
limit?: number;
include_archived?: boolean;
older_than_days?: number;
dry_run?: boolean;
}) =>
post<import('./types').MetadataRetryResult>('/api/admin/actions/retry-metadata', {
limit: input?.limit ?? 100,
project: input?.project,
include_archived: input?.include_archived ?? false,
older_than_days: input?.older_than_days ?? 1,
dry_run: input?.dry_run ?? false
})
},
stats: async () => {
type StatsThoughtRow = {
metadata?: {

View File

@@ -16,6 +16,7 @@
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Principal</th>
<th class="px-4 py-3 font-medium">IP</th>
<th class="px-4 py-3 font-medium">Last accessed</th>
<th class="px-4 py-3 font-medium">Last path</th>
<th class="px-4 py-3 font-medium">Agent</th>
@@ -26,6 +27,7 @@
{#each entries as entry}
<tr class="hover:bg-white/[0.03]">
<td class="px-4 py-3 align-top"><code class="rounded bg-white/5 px-2 py-1 font-mono text-xs text-cyan-100">{entry.key_id}</code></td>
<td class="px-4 py-3 align-top"><code class="text-xs text-slate-200">{entry.remote_addr || '—'}</code></td>
<td class="px-4 py-3 align-top text-slate-200">{formatDate(entry.last_accessed_at)}</td>
<td class="px-4 py-3 align-top"><code class="text-slate-100">{entry.last_path}</code></td>
<td class="max-w-[16rem] truncate px-4 py-3 align-top text-xs text-slate-400">{entry.user_agent ?? '—'}</td>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import type { RequestAggregate } from '../../types';
const {
title,
entries,
emptyLabel
}: {
title: string;
entries: RequestAggregate[];
emptyLabel: string;
} = $props();
</script>
<section class="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20 sm:p-8">
<h3 class="text-xl font-semibold text-white">{title}</h3>
{#if entries.length === 0}
<div class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-500">
{emptyLabel}
</div>
{:else}
<div class="mt-6 overflow-hidden rounded-2xl border border-white/10">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-white/10 text-left text-sm text-slate-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Value</th>
<th class="px-4 py-3 text-right font-medium">Requests</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each entries as entry}
<tr class="hover:bg-white/[0.03]">
<td class="max-w-[40rem] truncate px-4 py-3 align-top"><code class="text-xs text-slate-200">{entry.key}</code></td>
<td class="px-4 py-3 align-top text-right font-semibold text-white">{entry.request_count}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</section>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { StatusResponse } from '../../types';
import AccessTable from './AccessTable.svelte';
import ConnectionBreakdown from './ConnectionBreakdown.svelte';
import StatusCards from './StatusCards.svelte';
const {
@@ -47,3 +48,18 @@
{#if data && data.entries.length > 0}
<AccessTable entries={data.entries} />
{/if}
{#if data}
<div class="mt-6 grid gap-6 xl:grid-cols-2">
<ConnectionBreakdown
title="Requests By IP Address"
entries={data.metrics.top_ips}
emptyLabel="No connection requests recorded yet."
/>
<ConnectionBreakdown
title="Requests By User Agent"
entries={data.metrics.top_agents}
emptyLabel="No user agents recorded yet."
/>
</div>
{/if}

View File

@@ -4,7 +4,7 @@
const { data }: { data: StatusResponse } = $props();
</script>
<div class="mt-6 grid gap-4 sm:grid-cols-3">
<div class="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Connected users</p>
<p class="mt-2 text-3xl font-semibold text-white">{data.connected_count}</p>
@@ -13,8 +13,26 @@
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Known principals</p>
<p class="mt-2 text-3xl font-semibold text-white">{data.total_known}</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Total requests</p>
<p class="mt-2 text-3xl font-semibold text-white">{data.metrics.total_requests}</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Unique IPs</p>
<p class="mt-2 text-3xl font-semibold text-white">{data.metrics.unique_ips}</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Unique agents</p>
<p class="mt-2 text-3xl font-semibold text-white">{data.metrics.unique_agents}</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Version</p>
<p class="mt-2 break-all text-2xl font-semibold text-white">{data.version}</p>
</div>
</div>
<div class="mt-4 rounded-2xl border border-white/10 bg-slate-950/40 p-4 text-xs text-slate-400">
<p><span class="text-slate-200">Build date:</span> {data.build_date}</p>
<p class="mt-1"><span class="text-slate-200">Commit:</span> {data.commit}</p>
<p class="mt-1"><span class="text-slate-200">OAuth enabled:</span> {data.oauth_enabled ? 'yes' : 'no'}</p>
</div>

View File

@@ -0,0 +1,219 @@
<script lang="ts">
import { GridlerFull, type GridlerColumn } from "@warkypublic/svelix";
import { GlobalStateStore } from "../../shellState";
import { adminGridTheme } from "../../gridTheme";
import type { Learning } from "../../types";
let selectedLearning = $state<Learning | null>(null);
let gridTotal = $state<number | null>(null);
const learningsDataSourceOptions = {
url: "/api/rs",
authToken: GlobalStateStore.getState().session.authToken,
schema: "public",
entity: "learnings",
uniqueID: "id",
sort: [{ column: "created_at", direction: "desc" }],
} as unknown as {
url: string;
authToken?: string;
schema: string;
entity: string;
uniqueID: string;
};
const columns: GridlerColumn[] = [
{ id: "summary", title: "Summary", dataKey: "summary", width: 420 },
{ id: "category", title: "Category", dataKey: "category", width: 140 },
{ id: "area", title: "Area", dataKey: "area", width: 140 },
{ id: "status", title: "Status", dataKey: "status", width: 130 },
{ id: "priority", title: "Priority", dataKey: "priority", width: 120 },
{
id: "action_required",
title: "Action",
dataKey: "action_required",
width: 100,
},
{
id: "created_at",
title: "Created",
dataKey: "created_at",
width: 210,
format: "datetime",
},
];
function normalizeLearning(rowData: Record<string, unknown>): Learning {
return {
id: String(rowData.id ?? ""),
summary: typeof rowData.summary === "string" ? rowData.summary : "",
details: typeof rowData.details === "string" ? rowData.details : "",
category: typeof rowData.category === "string" ? rowData.category : "",
area: typeof rowData.area === "string" ? rowData.area : "",
status: typeof rowData.status === "string" ? rowData.status : "",
priority: typeof rowData.priority === "string" ? rowData.priority : "",
confidence:
typeof rowData.confidence === "string" ? rowData.confidence : "",
action_required: Boolean(rowData.action_required),
source_type:
typeof rowData.source_type === "string" ? rowData.source_type : undefined,
source_ref:
typeof rowData.source_ref === "string" ? rowData.source_ref : undefined,
tags: typeof rowData.tags === "string" ? rowData.tags : undefined,
project_id:
typeof rowData.project_id === "string" ? rowData.project_id : undefined,
related_thought_id:
typeof rowData.related_thought_id === "string"
? rowData.related_thought_id
: undefined,
related_skill_id:
typeof rowData.related_skill_id === "string"
? rowData.related_skill_id
: undefined,
reviewed_at:
typeof rowData.reviewed_at === "string" ? rowData.reviewed_at : undefined,
reviewed_by:
typeof rowData.reviewed_by === "string" ? rowData.reviewed_by : undefined,
created_at: String(rowData.created_at ?? ""),
updated_at: String(rowData.updated_at ?? ""),
};
}
function onRowClick(
_row: number,
rowData: Record<string, unknown> | undefined,
) {
if (!rowData) {
selectedLearning = null;
return;
}
selectedLearning = normalizeLearning(rowData);
}
function onGridEvent(
type: string,
_item?: unknown,
_column?: unknown,
_coords?: unknown,
detail?: Record<string, unknown>,
) {
if (type !== "page_loaded" && type !== "load") return;
const total = detail?.total;
if (typeof total === "number") gridTotal = total;
}
function formatDate(value?: string): string {
if (!value) return "—";
return new Date(value).toLocaleString();
}
</script>
<div class="space-y-4 w-full">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Learnings</h2>
<p class="mt-1 text-sm text-slate-400">
{#if gridTotal === null}
Server-backed grid
{:else}
{gridTotal} result{gridTotal !== 1 ? "s" : ""}
{/if}
</p>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[1.6fr_1fr]">
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={560}
width="100%"
pageSize={40}
dataSource="resolvespec"
dataSourceOptions={learningsDataSourceOptions}
serverSideSearch={true}
searchColumns={["summary", "details", "category", "area", "status"]}
{onGridEvent}
{onRowClick}
/>
</div>
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
<div class="flex items-start justify-between gap-3">
<h3 class="text-sm font-semibold text-white">Learning Inspector</h3>
</div>
{#if !selectedLearning}
<p class="mt-3 text-sm text-slate-500">
Select a learning row to inspect details and metadata.
</p>
{:else}
<div class="mt-3 space-y-3 text-sm text-slate-300">
<p class="text-slate-200">{selectedLearning.summary}</p>
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
<p>
<strong class="text-slate-100">Category:</strong>
{selectedLearning.category || "—"}
</p>
<p>
<strong class="text-slate-100">Area:</strong>
{selectedLearning.area || "—"}
</p>
<p>
<strong class="text-slate-100">Status:</strong>
{selectedLearning.status || "—"}
</p>
<p>
<strong class="text-slate-100">Priority:</strong>
{selectedLearning.priority || "—"}
</p>
<p>
<strong class="text-slate-100">Confidence:</strong>
{selectedLearning.confidence || "—"}
</p>
<p>
<strong class="text-slate-100">Action Required:</strong>
{selectedLearning.action_required ? "yes" : "no"}
</p>
<p>
<strong class="text-slate-100">Created:</strong>
{formatDate(selectedLearning.created_at)}
</p>
<p>
<strong class="text-slate-100">Updated:</strong>
{formatDate(selectedLearning.updated_at)}
</p>
<p>
<strong class="text-slate-100">Reviewed:</strong>
{formatDate(selectedLearning.reviewed_at)}
</p>
<p>
<strong class="text-slate-100">Reviewed By:</strong>
{selectedLearning.reviewed_by || "—"}
</p>
</div>
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Details</p>
<p class="mt-2 whitespace-pre-wrap text-slate-300">
{selectedLearning.details || "No details provided."}
</p>
</div>
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Source</p>
<p class="mt-2 text-slate-300">
{selectedLearning.source_type || "—"}
{#if selectedLearning.source_ref}
· {selectedLearning.source_ref}
{/if}
</p>
<p class="mt-1 text-slate-400">Tags: {selectedLearning.tags || "—"}</p>
</div>
</div>
{/if}
</aside>
</div>
</div>

View File

@@ -0,0 +1,184 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '../../api';
import type { BackfillResult, MaintenanceLog, MaintenanceTask, MetadataRetryResult } from '../../types';
let tasks = $state<MaintenanceTask[]>([]);
let logs = $state<MaintenanceLog[]>([]);
let loading = $state(true);
let error = $state('');
let busyAction = $state<'backfill' | 'retry' | null>(null);
let actionError = $state('');
let actionMessage = $state('');
let dryRun = $state(true);
async function load() {
loading = true;
error = '';
try {
const [taskRows, logRows] = await Promise.all([api.maintenance.tasks(), api.maintenance.logs()]);
tasks = taskRows;
logs = logRows;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load maintenance data';
} finally {
loading = false;
}
}
function formatDate(value?: string): string {
if (!value) return '—';
return new Date(value).toLocaleString();
}
function formatCost(value?: number): string {
if (value === undefined || value === null) return '—';
return `$${value.toFixed(2)}`;
}
function summarizeBackfill(result: BackfillResult): string {
return `Backfill (${result.dry_run ? 'dry-run' : 'run'}) scanned ${result.scanned}, embedded ${result.embedded}, failed ${result.failed}.`;
}
function summarizeRetry(result: MetadataRetryResult): string {
return `Metadata retry (${result.dry_run ? 'dry-run' : 'run'}) scanned ${result.scanned}, retried ${result.retried}, updated ${result.updated}, failed ${result.failed}.`;
}
async function runBackfill() {
busyAction = 'backfill';
actionError = '';
actionMessage = '';
try {
const result = await api.maintenance.runBackfill({ dry_run: dryRun });
actionMessage = summarizeBackfill(result);
} catch (e) {
actionError = e instanceof Error ? e.message : 'Backfill failed';
} finally {
busyAction = null;
}
}
async function runRetryMetadata() {
busyAction = 'retry';
actionError = '';
actionMessage = '';
try {
const result = await api.maintenance.runRetryMetadata({ dry_run: dryRun });
actionMessage = summarizeRetry(result);
} catch (e) {
actionError = e instanceof Error ? e.message : 'Retry failed';
} finally {
busyAction = null;
}
}
onMount(load);
</script>
<div class="space-y-6">
<div class="flex items-end justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Maintenance</h2>
<p class="mt-1 text-sm text-slate-400">Operational state and safe maintenance actions.</p>
</div>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={load}
>Refresh</button>
</div>
<section class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
<div class="flex flex-wrap items-center gap-3">
<label class="flex items-center gap-2 text-sm text-slate-300">
<input type="checkbox" class="accent-cyan-400" bind:checked={dryRun} />
Dry run
</label>
<button
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20 disabled:opacity-50"
onclick={runBackfill}
disabled={busyAction !== null}
>{busyAction === 'backfill' ? 'Running backfill…' : 'Run Backfill'}</button>
<button
class="rounded-xl border border-amber-300/30 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-100 transition hover:bg-amber-400/20 disabled:opacity-50"
onclick={runRetryMetadata}
disabled={busyAction !== null}
>{busyAction === 'retry' ? 'Running retry…' : 'Run Metadata Retry'}</button>
</div>
{#if actionError}
<p class="mt-3 text-sm text-rose-300">{actionError}</p>
{/if}
{#if actionMessage}
<p class="mt-3 text-sm text-emerald-300">{actionMessage}</p>
{/if}
</section>
{#if loading}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
{:else if error}
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
{:else}
<section class="space-y-4">
<h3 class="text-lg font-semibold text-white">Tasks ({tasks.length})</h3>
{#if tasks.length === 0}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-10 text-center text-slate-500">No maintenance tasks.</div>
{:else}
<div class="overflow-hidden rounded-2xl border border-white/10">
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
<tr>
<th class="px-4 py-3 text-left font-medium">Task</th>
<th class="px-4 py-3 text-left font-medium">Category</th>
<th class="px-4 py-3 text-left font-medium">Priority</th>
<th class="px-4 py-3 text-right font-medium">Next Due</th>
<th class="px-4 py-3 text-right font-medium">Last Completed</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each tasks as task}
<tr class="hover:bg-white/[0.03]">
<td class="px-4 py-3 font-medium text-white">{task.name}</td>
<td class="px-4 py-3 text-slate-400">{task.category || '—'}</td>
<td class="px-4 py-3 text-slate-300">{task.priority || 'medium'}</td>
<td class="px-4 py-3 text-right text-slate-300">{formatDate(task.next_due)}</td>
<td class="px-4 py-3 text-right text-slate-400">{formatDate(task.last_completed)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<section class="space-y-4">
<h3 class="text-lg font-semibold text-white">Recent Logs ({logs.length})</h3>
{#if logs.length === 0}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-10 text-center text-slate-500">No maintenance logs.</div>
{:else}
<div class="overflow-hidden rounded-2xl border border-white/10">
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
<tr>
<th class="px-4 py-3 text-left font-medium">Completed</th>
<th class="px-4 py-3 text-left font-medium">Task ID</th>
<th class="px-4 py-3 text-left font-medium">By</th>
<th class="px-4 py-3 text-right font-medium">Cost</th>
<th class="px-4 py-3 text-left font-medium">Notes</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each logs as log}
<tr class="hover:bg-white/[0.03]">
<td class="px-4 py-3 text-slate-300">{formatDate(log.completed_at)}</td>
<td class="px-4 py-3"><code class="text-xs text-cyan-100">{log.task_id}</code></td>
<td class="px-4 py-3 text-slate-400">{log.performed_by || '—'}</td>
<td class="px-4 py-3 text-right tabular-nums text-slate-300">{formatCost(log.cost)}</td>
<td class="max-w-sm truncate px-4 py-3 text-slate-400">{log.notes || '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
{/if}
</div>

View File

@@ -1,6 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { GridlerFull, TextInputCtrl, type GridlerColumn } from '@warkypublic/svelix';
import { api } from '../../api';
import { GlobalStateStore } from '../../shellState';
import { adminGridTheme } from '../../gridTheme';
import type { ProjectSummary } from '../../types';
let projects = $state<ProjectSummary[]>([]);
@@ -11,12 +14,39 @@
let newName = $state('');
let newDesc = $state('');
let createError = $state('');
let selectedProject = $state<ProjectSummary | null>(null);
const projectDataSourceOptions = {
url: '/api/rs',
authToken: GlobalStateStore.getState().session.authToken,
schema: 'public',
entity: 'projects',
uniqueID: 'id',
hotfields: ['id', 'guid'],
sort: [{ column: 'created_at', direction: 'desc' }]
} as unknown as {
url: string;
authToken?: string;
schema: string;
entity: string;
uniqueID: string;
hotfields: string[];
};
const columns: GridlerColumn[] = [
{ id: 'name', title: 'Name', dataKey: 'name', width: 260 },
{ id: 'description', title: 'Description', dataKey: 'description', width: 360 },
{ id: 'last_active_at', title: 'Last Active', dataKey: 'last_active_at', width: 200, format: 'datetime' },
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 200, format: 'datetime' }
];
async function load() {
loading = true;
error = '';
try {
projects = await api.projects.list();
if (selectedProject) {
selectedProject = projects.find((project) => project.id === selectedProject?.id) ?? null;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load projects';
} finally {
@@ -41,8 +71,20 @@
}
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString();
function onProjectRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
if (!rowData) {
selectedProject = null;
return;
}
const id = String(rowData.guid ?? rowData.id ?? '');
selectedProject = {
id,
name: String(rowData.name ?? ''),
description: String(rowData.description ?? ''),
created_at: String(rowData.created_at ?? ''),
last_active_at: String(rowData.last_active_at ?? ''),
thought_count: Number(rowData.thought_count ?? 0)
};
}
onMount(load);
@@ -70,16 +112,15 @@
<div class="rounded-2xl border border-cyan-400/20 bg-slate-900 p-5">
<h3 class="text-sm font-semibold text-white">Create project</h3>
<div class="mt-3 space-y-3">
<input
type="text"
<TextInputCtrl
label="Project name"
placeholder="Name"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none"
required
bind:value={newName}
/>
<input
type="text"
<TextInputCtrl
label="Description"
placeholder="Description (optional)"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none"
bind:value={newDesc}
/>
{#if createError}<p class="text-xs text-rose-300">{createError}</p>{/if}
@@ -109,29 +150,25 @@
No projects yet.
</div>
{:else}
<div class="overflow-hidden rounded-2xl border border-white/10">
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
<tr>
<th class="px-4 py-3 text-left font-medium">Name</th>
<th class="px-4 py-3 text-left font-medium">Description</th>
<th class="px-4 py-3 text-right font-medium">Thoughts</th>
<th class="px-4 py-3 text-right font-medium">Last active</th>
<th class="px-4 py-3 text-right font-medium">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each projects as p}
<tr class="hover:bg-white/[0.03]">
<td class="px-4 py-3 font-medium text-white">{p.name}</td>
<td class="max-w-xs truncate px-4 py-3 text-slate-400">{p.description || '—'}</td>
<td class="px-4 py-3 text-right tabular-nums text-slate-200">{p.thought_count}</td>
<td class="px-4 py-3 text-right text-slate-400">{formatDate(p.last_active_at)}</td>
<td class="px-4 py-3 text-right text-slate-400">{formatDate(p.created_at)}</td>
</tr>
{/each}
</tbody>
</table>
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={420}
dataSource="resolvespec"
dataSourceOptions={projectDataSourceOptions}
serverSideSearch={true}
searchColumns={['name', 'description']}
onRowClick={onProjectRowClick}
/>
</div>
{#if selectedProject}
<div class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
<h3 class="text-sm font-semibold text-white">Selected project</h3>
<p class="mt-2 text-sm text-slate-300"><strong class="text-slate-100">{selectedProject.name}</strong> · {selectedProject.thought_count} thoughts</p>
<p class="mt-1 text-sm text-slate-400">{selectedProject.description || 'No description.'}</p>
</div>
{/if}
{/if}
</div>

View File

@@ -2,6 +2,8 @@
import type { ShellPage, StatusResponse } from '../../types';
import FilesPage from '../files/FilesPage.svelte';
import GuardrailsPage from '../guardrails/GuardrailsPage.svelte';
import LearningsPage from '../learnings/LearningsPage.svelte';
import MaintenancePage from '../maintenance/MaintenancePage.svelte';
import DashboardPage from '../dashboard/DashboardPage.svelte';
import ProjectsPage from '../projects/ProjectsPage.svelte';
import SkillsPage from '../skills/SkillsPage.svelte';
@@ -37,12 +39,16 @@
<ProjectsPage />
{:else if currentPage === 'thoughts'}
<ThoughtsPage />
{:else if currentPage === 'learnings'}
<LearningsPage />
{:else if currentPage === 'skills'}
<SkillsPage />
{:else if currentPage === 'guardrails'}
<GuardrailsPage />
{:else if currentPage === 'files'}
<FilesPage />
{:else if currentPage === 'maintenance'}
<MaintenancePage />
{/if}
</main>
</div>

View File

@@ -15,9 +15,11 @@
{ id: 'dashboard', label: 'Dashboard', description: 'System overview and status.' },
{ id: 'projects', label: 'Projects', description: 'Browse and manage projects.' },
{ id: 'thoughts', label: 'Thoughts', description: 'Search and inspect thoughts.' },
{ id: 'learnings', label: 'Learnings', description: 'Curated insights and outcomes.' },
{ id: 'skills', label: 'Skills', description: 'Agent skill registry.' },
{ id: 'guardrails', label: 'Guardrails', description: 'Agent guardrail registry.' },
{ id: 'files', label: 'Files', description: 'Stored file inventory.' }
{ id: 'files', label: 'Files', description: 'Stored file inventory.' },
{ id: 'maintenance', label: 'Maintenance', description: 'Task state and upkeep actions.' }
];
</script>

View File

@@ -1,165 +1,389 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '../../api';
import type { Thought, SearchResult } from '../../types';
import {
GridlerFull,
type GridColumnFilters,
type GridlerColumn,
} from "@warkypublic/svelix";
import { api } from "../../api";
import { GlobalStateStore } from "../../shellState";
import { adminGridTheme } from "../../gridTheme";
import type { StoredFile, Thought, ThoughtLink } from "../../types";
type Row = Thought | SearchResult;
let rows = $state<Row[]>([]);
let loading = $state(true);
let error = $state('');
let query = $state('');
let includeArchived = $state(false);
let actionBusy = $state<string | null>(null);
let actionError = $state('');
let actionError = $state("");
let inspectorBusy = $state(false);
let selectedThought = $state<Thought | null>(null);
let relatedLinks = $state<ThoughtLink[]>([]);
let relatedFiles = $state<StoredFile[]>([]);
let gridTotal = $state<number | null>(null);
const thoughtsDataSourceOptions = {
url: "/api/rs",
authToken: GlobalStateStore.getState().session.authToken,
schema: "public",
entity: "thoughts",
uniqueID: "id",
hotfields: ["guid", "metadata", "project_id", "archived_at"],
sort: [{ column: "created_at", direction: "desc" }],
} as unknown as {
url: string;
authToken?: string;
schema: string;
entity: string;
uniqueID: string;
hotfields: string[];
};
let searchTimer: ReturnType<typeof setTimeout>;
const columns: GridlerColumn[] = [
{ id: "id", title: "ID", dataKey: "id", width: 90, format: "number" },
{ id: "content", title: "Content", dataKey: "content", width: 520 },
{
id: "created_at",
title: "Created",
dataKey: "created_at",
width: 210,
format: "datetime",
},
{
id: "updated_at",
title: "Updated",
dataKey: "updated_at",
width: 210,
format: "datetime",
},
{
id: "archived_at",
title: "Archived",
dataKey: "archived_at",
width: 210,
format: "datetime",
},
];
async function load() {
loading = true;
error = '';
try {
rows = await api.thoughts.list({ q: query || undefined, include_archived: includeArchived || undefined, limit: 100 }) as Row[];
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load thoughts';
} finally {
loading = false;
}
}
function onQueryInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(load, 350);
}
const baseFilters = $derived<GridColumnFilters>(
includeArchived
? {}
: {
archived_at: {
value: "",
op: "isNull",
},
},
);
async function archive(id: string) {
actionBusy = id;
actionError = '';
actionError = "";
try {
await api.thoughts.archive(id);
await load();
if (selectedThought?.id === id)
selectedThought.archived_at = new Date().toISOString();
} catch (e) {
actionError = e instanceof Error ? e.message : 'Archive failed';
actionError = e instanceof Error ? e.message : "Archive failed";
} finally {
actionBusy = null;
}
}
async function remove(id: string) {
if (!confirm('Permanently delete this thought?')) return;
if (!confirm("Permanently delete this thought?")) return;
actionBusy = id;
actionError = '';
actionError = "";
try {
await api.thoughts.delete(id);
await load();
if (selectedThought?.id === id) {
selectedThought = null;
relatedLinks = [];
relatedFiles = [];
}
} catch (e) {
actionError = e instanceof Error ? e.message : 'Delete failed';
actionError = e instanceof Error ? e.message : "Delete failed";
} finally {
actionBusy = null;
}
}
function isArchived(row: Row): boolean {
return 'archived_at' in row && !!row.archived_at;
function normalizeThought(rowData: Record<string, unknown>): Thought {
const metadataObj = (rowData.metadata ?? {}) as Record<string, unknown>;
return {
id: String(rowData.id ?? ""),
guid: rowData.guid ? String(rowData.guid) : undefined,
content: typeof rowData.content === "string" ? rowData.content : "",
project_id: rowData.project_id ? String(rowData.project_id) : undefined,
archived_at: rowData.archived_at
? String(rowData.archived_at)
: undefined,
created_at: String(rowData.created_at ?? ""),
updated_at: String(rowData.updated_at ?? ""),
metadata: {
people: Array.isArray(metadataObj.people)
? (metadataObj.people as string[])
: [],
action_items: Array.isArray(metadataObj.action_items)
? (metadataObj.action_items as string[])
: [],
dates_mentioned: Array.isArray(metadataObj.dates_mentioned)
? (metadataObj.dates_mentioned as string[])
: [],
topics: Array.isArray(metadataObj.topics)
? (metadataObj.topics as string[])
: [],
type: typeof metadataObj.type === "string" ? metadataObj.type : "",
source:
typeof metadataObj.source === "string" ? metadataObj.source : "",
metadata_status:
typeof metadataObj.metadata_status === "string"
? metadataObj.metadata_status
: "",
metadata_error:
typeof metadataObj.metadata_error === "string"
? metadataObj.metadata_error
: undefined,
attachments: Array.isArray(metadataObj.attachments)
? (metadataObj.attachments as Thought["metadata"]["attachments"])
: [],
},
};
}
function content(row: Row): string {
return row.content.length > 120 ? row.content.slice(0, 120) + '…' : row.content;
async function inspectThought(row: Thought | null) {
selectedThought = row;
relatedLinks = [];
relatedFiles = [];
if (!row) return;
inspectorBusy = true;
try {
const thoughtRef = row.guid ?? row.id;
const [links, files] = await Promise.all([
api.thoughts.links(row.id),
api.files.list({ thought_id: thoughtRef }),
]);
relatedLinks = links;
relatedFiles = files;
} finally {
inspectorBusy = false;
}
}
function formatDate(value: string) {
function onRowClick(
_row: number,
rowData: Record<string, unknown> | undefined,
) {
if (!rowData) {
void inspectThought(null);
return;
}
void inspectThought(normalizeThought(rowData));
}
function onGridEvent(
type: string,
_item?: unknown,
_column?: unknown,
_coords?: unknown,
detail?: Record<string, unknown>,
) {
if (type !== "page_loaded" && type !== "load") return;
const total = detail?.total;
if (typeof total === "number") gridTotal = total;
}
function formatDate(value?: string) {
if (!value) return "—";
return new Date(value).toLocaleString();
}
onMount(load);
function isSelectedArchived(): boolean {
return !!selectedThought?.archived_at;
}
</script>
<div class="space-y-4">
<div class="space-y-4 w-full">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Thoughts</h2>
<p class="mt-1 text-sm text-slate-400">{rows.length} result{rows.length !== 1 ? 's' : ''}</p>
<p class="mt-1 text-sm text-slate-400">
{#if gridTotal === null}
Server-backed grid
{:else}
{gridTotal} result{gridTotal !== 1 ? "s" : ""}
{/if}
</p>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-slate-400">
<input type="checkbox" class="accent-cyan-400" bind:checked={includeArchived} onchange={load} />
<input
type="checkbox"
class="accent-cyan-400"
bind:checked={includeArchived}
/>
Archived
</label>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={load}
>Refresh</button>
onclick={() => {
selectedThought = null;
relatedLinks = [];
relatedFiles = [];
}}>Refresh</button
>
</div>
</div>
<input
type="search"
placeholder="Search thoughts…"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none"
bind:value={query}
oninput={onQueryInput}
/>
{#if actionError}
<p class="text-sm text-rose-300">{actionError}</p>
{/if}
{#if loading}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
{:else if error}
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
{:else if rows.length === 0}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No thoughts found.</div>
{:else}
<div class="overflow-hidden rounded-2xl border border-white/10">
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
<tr>
<th class="px-4 py-3 text-left font-medium">Content</th>
<th class="px-4 py-3 text-left font-medium">Type</th>
<th class="px-4 py-3 text-left font-medium">Status</th>
<th class="px-4 py-3 text-right font-medium">Created</th>
<th class="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each rows as row}
<tr class={`hover:bg-white/[0.03] ${isArchived(row) ? 'opacity-50' : ''}`}>
<td class="max-w-sm px-4 py-3 align-top">
<p class="text-white">{content(row)}</p>
{#if row.metadata.topics?.length}
<p class="mt-1 text-xs text-slate-500">{row.metadata.topics.slice(0,3).join(', ')}</p>
{/if}
</td>
<td class="px-4 py-3 align-top">
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-300">
{row.metadata.type || '—'}
</span>
</td>
<td class="px-4 py-3 align-top text-xs text-slate-400">
{isArchived(row) ? 'archived' : (row.metadata.metadata_status || 'active')}
</td>
<td class="px-4 py-3 align-top text-right text-slate-400">{formatDate(row.created_at)}</td>
<td class="px-4 py-3 align-top text-right">
<div class="flex justify-end gap-2">
{#if !isArchived(row)}
<button
class="text-xs text-slate-400 underline-offset-2 hover:text-slate-200"
onclick={() => archive(row.id)}
disabled={actionBusy === row.id}
>Archive</button>
{/if}
<button
class="text-xs text-rose-400 underline-offset-2 hover:text-rose-300"
onclick={() => remove(row.id)}
disabled={actionBusy === row.id}
>Delete</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
<div class="grid gap-4 xl:grid-cols-[1.6fr_1fr]">
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={560}
width="100%"
pageSize={40}
dataSource="resolvespec"
dataSourceOptions={thoughtsDataSourceOptions}
serverSideSearch={true}
searchColumns={["content"]}
filters={baseFilters}
{onGridEvent}
{onRowClick}
/>
</div>
{/if}
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
<div class="flex items-start justify-between gap-3">
<h3 class="text-sm font-semibold text-white">Thought Inspector</h3>
{#if selectedThought}
<div class="flex gap-2">
{#if !isSelectedArchived()}
<button
class="text-xs text-slate-300 hover:text-white"
onclick={() => {
if (selectedThought) void archive(selectedThought.id);
}}
disabled={actionBusy === selectedThought?.id}>Archive</button
>
{/if}
<button
class="text-xs text-rose-300 hover:text-rose-200"
onclick={() => {
if (selectedThought) void remove(selectedThought.id);
}}
disabled={actionBusy === selectedThought?.id}>Delete</button
>
</div>
{/if}
</div>
{#if !selectedThought}
<p class="mt-3 text-sm text-slate-500">
Select a thought row to inspect metadata, links, and file records.
</p>
{:else}
<div class="mt-3 space-y-3 text-sm text-slate-300">
<p class="text-slate-200">{selectedThought.content}</p>
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
<p>
<strong class="text-slate-100">Type:</strong>
{selectedThought.metadata.type || "—"}
</p>
<p>
<strong class="text-slate-100">Status:</strong>
{selectedThought.metadata.metadata_status || "active"}
</p>
{#if selectedThought.metadata.metadata_error}
<p class="mt-1 text-rose-300">
<strong>Error:</strong>
{selectedThought.metadata.metadata_error}
</p>
{/if}
<p class="mt-1">
<strong class="text-slate-100">Created:</strong>
{formatDate(selectedThought.created_at)}
</p>
<p>
<strong class="text-slate-100">Updated:</strong>
{formatDate(selectedThought.updated_at)}
</p>
</div>
<div>
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">
Related Links ({relatedLinks.length})
</p>
{#if inspectorBusy}
<p class="mt-2 text-xs text-slate-500">Loading links…</p>
{:else if relatedLinks.length === 0}
<p class="mt-2 text-xs text-slate-500">No related links.</p>
{:else}
<ul class="mt-2 space-y-1 text-xs">
{#each relatedLinks as link}
<li
class="rounded-lg border border-white/10 bg-white/5 px-2 py-1"
>
<code class="text-cyan-100">{link.from_id}</code>
<code class="text-cyan-100">{link.to_id}</code>
· {link.relation}
</li>
{/each}
</ul>
{/if}
</div>
<div>
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">
Attachments ({selectedThought.metadata.attachments?.length ?? 0})
</p>
{#if (selectedThought.metadata.attachments?.length ?? 0) === 0}
<p class="mt-2 text-xs text-slate-500">No attachment metadata.</p>
{:else}
<ul class="mt-2 space-y-1 text-xs">
{#each selectedThought.metadata.attachments ?? [] as attachment}
<li
class="rounded-lg border border-white/10 bg-white/5 px-2 py-1"
>
{attachment.name} · {attachment.media_type} · {attachment.size_bytes}
bytes
</li>
{/each}
</ul>
{/if}
</div>
<div>
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">
Stored File Records ({relatedFiles.length})
</p>
{#if inspectorBusy}
<p class="mt-2 text-xs text-slate-500">Loading files…</p>
{:else if relatedFiles.length === 0}
<p class="mt-2 text-xs text-slate-500">
No stored files linked to this thought.
</p>
{:else}
<ul class="mt-2 space-y-1 text-xs">
{#each relatedFiles as file}
<li
class="rounded-lg border border-white/10 bg-white/5 px-2 py-1"
>
<a
href={`/files/${file.id}`}
target="_blank"
rel="noreferrer"
class="text-cyan-300 hover:text-cyan-200">{file.name}</a
>
· {file.media_type}
</li>
{/each}
</ul>
{/if}
</div>
</div>
{/if}
</aside>
</div>
</div>

18
ui/src/gridTheme.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { GridlerTheme } from '@warkypublic/svelix';
export const adminGridTheme: Partial<GridlerTheme> = {
accentColor: '#22d3ee',
accentFg: '#06232b',
bgCell: '#020617',
bgHeader: '#0f172a',
bgHeaderHasFocus: '#164e63',
bgSearchResult: '#083344',
borderColor: '#1e293b',
foreground: '#cbd5e1',
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: 13,
headerFontSize: 12,
lineHeight: 1.4,
cellHorizontalPadding: 10,
cellVerticalPadding: 8
};

View File

@@ -2,10 +2,25 @@ export type AccessEntry = {
key_id: string;
last_accessed_at: string;
last_path: string;
remote_addr: string;
user_agent: string;
request_count: number;
};
export type RequestAggregate = {
key: string;
request_count: number;
};
export type AccessMetrics = {
total_requests: number;
unique_principals: number;
unique_ips: number;
unique_agents: number;
top_ips: RequestAggregate[];
top_agents: RequestAggregate[];
};
export type StatusResponse = {
title: string;
description: string;
@@ -17,6 +32,7 @@ export type StatusResponse = {
connected_window: string;
oauth_enabled: boolean;
entries: AccessEntry[];
metrics: AccessMetrics;
};
export type NavItem = {
@@ -26,7 +42,7 @@ export type NavItem = {
disabled?: boolean;
};
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'skills' | 'guardrails' | 'files';
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'skills' | 'guardrails' | 'files' | 'maintenance';
export type Project = {
id: string;
@@ -49,10 +65,21 @@ export type ThoughtMetadata = {
source: string;
metadata_status: string;
metadata_error?: string;
attachments?: ThoughtAttachment[];
};
export type ThoughtAttachment = {
file_id: string;
name: string;
media_type: string;
kind?: string;
size_bytes: number;
sha256?: string;
};
export type Thought = {
id: string;
guid?: string;
content: string;
metadata: ThoughtMetadata;
project_id?: string;
@@ -61,6 +88,14 @@ export type Thought = {
updated_at: string;
};
export type ThoughtLink = {
id: number;
from_id: number;
to_id: number;
relation: string;
created_at: string;
};
export type SearchResult = {
id: string;
content: string;
@@ -109,3 +144,66 @@ export type ThoughtStats = {
top_topics: { key: string; count: number }[];
top_people: { key: string; count: number }[];
};
export type Learning = {
id: string;
summary: string;
details: string;
category: string;
area: string;
status: string;
priority: string;
confidence: string;
action_required: boolean;
source_type?: string;
source_ref?: string;
tags?: string;
project_id?: string;
related_thought_id?: string;
related_skill_id?: string;
reviewed_at?: string;
reviewed_by?: string;
created_at: string;
updated_at: string;
};
export type MaintenanceTask = {
id: string;
name: string;
category?: string;
priority: string;
frequency_days?: number;
next_due?: string;
last_completed?: string;
notes?: string;
created_at: string;
updated_at: string;
};
export type MaintenanceLog = {
id: string;
task_id: string;
completed_at: string;
performed_by?: string;
cost?: number;
notes?: string;
next_action?: string;
};
export type BackfillResult = {
model: string;
scanned: number;
embedded: number;
skipped: number;
failed: number;
dry_run: boolean;
};
export type MetadataRetryResult = {
scanned: number;
retried: number;
updated: number;
skipped: number;
failed: number;
dry_run: boolean;
};