oikos-web: extract the client stack from dtoro/oikos
Some checks failed
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Phase 1 of the hexagonal-architecture plan (dtoro/oikos
plans/2026-08-15-hexagonal-architecture.md). Moves the delivery stack
for the control-room UI into its own repo with its own pipeline:

- web/ — Svelte 5 SPA, verbatim (vendor/ included)
- desktop/ — Wails v3 wrapper, updateURL repointed to
  dtoro/oikos-web releases
- compose/ — Dockerfile + Caddyfile, verbatim (the /wails/* 404 and
  asset no-fallback quirks are load-bearing)
- docker-compose.yml — single web service, same 8091:80 publish,
  mem/cpu limits, and restart policy as the oikos stack's web service
- scripts/deploy.sh — mirrors oikos deploy essentials: CI-green gate,
  TOCTOU guard, version-tagged oikos-web:v$VERSION, prune to 3
- cmd/webhook + scripts/install-webhook.sh — standalone push-to-deploy
  receiver on :9798 (env-only secrets, no Infisical dependency)
- CI: the web job from oikos's ci.yml + the desktop build/release
  workflow, path-adjusted

Own VERSION (0.33.0) with the same bump-on-main rule; starts above
oikos's 0.32.x so the desktop updater sees an upgrade.
This commit is contained in:
dtoro
2026-08-15 22:20:54 +02:00
commit ed8a3145b3
365 changed files with 61308 additions and 0 deletions

View File

@@ -0,0 +1,626 @@
<script lang="ts">
// Maintenance view for the knowledge base's own drift — duplicates, tag
// casing splits, orphaned notes, and the trash. Surfaced as its own mode
// rather than folded into the main three-pane view because none of this
// is "browse a note," it's "audit the collection," and mixing the two
// would clutter the read/edit flow with tools most visits don't need.
//
// Every action here (merge, rename, restore) is deliberately one click
// away from a review step, never automatic — see fetchKnowledgeDuplicates'
// own doc comment on why title-similarity clustering can't be trusted as
// a verdict (the five "Lifecycle: <verb> a node" runbooks cluster despite
// being genuinely distinct documents).
import {
fetchKnowledgeDuplicates,
fetchKnowledgeTags,
fetchKnowledgeOrphans,
fetchKnowledgeTrash,
renameKnowledgeTag,
mergeKnowledge,
restoreKnowledge,
KnowledgeApiError,
type KnowledgeDuplicateCluster,
type KnowledgeTag,
type KnowledgeOrphan,
type KnowledgeTrashItem
} from '$lib/api'
import * as Tabs from '$lib/components/ui/tabs'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
import { toast } from 'svelte-sonner'
import { kindMeta } from './kinds'
import { relativeTime } from '$lib/utils'
import CopyIcon from '@lucide/svelte/icons/copy'
import TagIcon from '@lucide/svelte/icons/tag'
import GhostIcon from '@lucide/svelte/icons/ghost'
import Trash2Icon from '@lucide/svelte/icons/trash-2'
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
import CheckIcon from '@lucide/svelte/icons/check'
let {
onSelect,
onChanged
}: {
onSelect: (slug: string) => void
// Fired after any mutation (merge, tag rename, restore) so the parent's
// note list — which this view reads a filtered copy of, indirectly —
// stays in sync.
onChanged: () => void
} = $props()
let tab = $state<'duplicates' | 'tags' | 'orphans' | 'trash'>('duplicates')
// Shared across every loader/action below: the read helpers in api.ts now
// throw KnowledgeApiError on a failed request instead of quietly returning
// an empty list, so a real outage can't be mistaken for "nothing to
// clean up" — see api.ts's comment on listKnowledge for the same fix
// applied to the main note list.
function errMsg(e: unknown): string {
return e instanceof KnowledgeApiError ? e.message : 'Request failed.'
}
// ─── Duplicates ───────────────────────────────────────────────────────
let clusters = $state<KnowledgeDuplicateCluster[] | null>(null)
let duplicatesError = $state('')
// Per cluster (indexed by the cluster's first member slug — stable across
// a reload since clusters are keyed by content, not array position):
// which slug is the merge target and which sources are checked.
let mergeTarget = $state<Record<string, string>>({})
let mergeSources = $state<Record<string, Set<string>>>({})
let merging = $state<string | null>(null)
let mergeError = $state('')
async function loadDuplicates(): Promise<void> {
clusters = null
duplicatesError = ''
try {
const result = await fetchKnowledgeDuplicates()
clusters = result
const targets: Record<string, string> = {}
const sources: Record<string, Set<string>> = {}
for (const c of result) {
const key = c.members[0].slug
targets[key] = c.members[0].slug // newest first — see the Go handler's sort
sources[key] = new Set(c.members.slice(1).map((m) => m.slug))
}
mergeTarget = targets
mergeSources = sources
} catch (e) {
duplicatesError = errMsg(e)
clusters = [] // clear the loading skeleton — the error message above explains the empty state
}
}
// A merge target switch leaves the PREVIOUS target unchecked (it's not in
// `sources` since it used to be excluded as "the target"), so recompute
// the whole source set relative to the new target rather than leaving it
// stale — otherwise the old target silently drops out of the merge
// instead of folding in like every other member.
function setMergeTarget(clusterKey: string, newTarget: string, allSlugs: string[]): void {
mergeTarget = { ...mergeTarget, [clusterKey]: newTarget }
mergeSources = {
...mergeSources,
[clusterKey]: new Set(allSlugs.filter((s) => s !== newTarget))
}
}
function toggleSource(clusterKey: string, slug: string): void {
const set = new Set(mergeSources[clusterKey])
if (set.has(slug)) set.delete(slug)
else set.add(slug)
mergeSources = { ...mergeSources, [clusterKey]: set }
}
async function doMerge(clusterKey: string): Promise<void> {
const target = mergeTarget[clusterKey]
const sources = [...(mergeSources[clusterKey] ?? [])]
if (!target || sources.length === 0) return
merging = clusterKey
mergeError = ''
try {
const result = await mergeKnowledge(target, sources)
toast.success(`Merged ${result.merged.length} note${result.merged.length === 1 ? '' : 's'}`)
onChanged()
await loadDuplicates()
} catch (e) {
mergeError = errMsg(e)
} finally {
merging = null
}
}
// ─── Tags ─────────────────────────────────────────────────────────────
let tags = $state<KnowledgeTag[] | null>(null)
let tagsError = $state('')
let renaming = $state<string | null>(null)
let renameDraft = $state('')
let renameBusy = $state(false)
async function loadTags(): Promise<void> {
tags = null
tagsError = ''
try {
tags = await fetchKnowledgeTags()
} catch (e) {
tagsError = errMsg(e)
tags = []
}
}
async function normalize(t: KnowledgeTag): Promise<void> {
renameBusy = true
try {
const n = await renameKnowledgeTag(t.variants, t.tag)
toast.success(`Normalized "${t.tag}" across ${n} note${n === 1 ? '' : 's'}`)
onChanged()
await loadTags()
} catch (e) {
toast.error(errMsg(e))
} finally {
renameBusy = false
}
}
function startRename(t: KnowledgeTag): void {
renaming = t.tag
renameDraft = t.tag
}
async function confirmRename(t: KnowledgeTag): Promise<void> {
const to = renameDraft.trim().toLowerCase()
if (!to || to === t.tag) {
renaming = null
return
}
renameBusy = true
try {
const n = await renameKnowledgeTag(t.variants, to)
toast.success(`Renamed "${t.tag}" to "${to}" across ${n} note${n === 1 ? '' : 's'}`)
onChanged()
await loadTags()
renaming = null
} catch (e) {
// Leave the rename input open on failure — the operator's typed value
// (and their reason for changing it) shouldn't vanish along with the
// error, forcing them to retype it to try again.
toast.error(errMsg(e))
} finally {
renameBusy = false
}
}
// ─── Orphans ──────────────────────────────────────────────────────────
let orphans = $state<KnowledgeOrphan[] | null>(null)
let orphanCounts = $state<Record<string, number>>({})
let orphansError = $state('')
async function loadOrphans(): Promise<void> {
orphans = null
orphansError = ''
try {
const result = await fetchKnowledgeOrphans()
orphans = result.items
orphanCounts = result.counts
} catch (e) {
orphansError = errMsg(e)
orphans = []
}
}
// ─── Trash ────────────────────────────────────────────────────────────
let trash = $state<KnowledgeTrashItem[] | null>(null)
let trashError = $state('')
let restoring = $state<string | null>(null)
async function loadTrash(): Promise<void> {
trash = null
trashError = ''
try {
trash = await fetchKnowledgeTrash()
} catch (e) {
trashError = errMsg(e)
trash = []
}
}
async function doRestore(slug: string): Promise<void> {
restoring = slug
try {
await restoreKnowledge(slug)
toast.success('Note restored')
onChanged()
await loadTrash()
} catch (e) {
toast.error(errMsg(e))
} finally {
restoring = null
}
}
function activate(t: typeof tab): void {
tab = t
if (t === 'duplicates' && clusters === null) loadDuplicates()
else if (t === 'tags' && tags === null) loadTags()
else if (t === 'orphans' && orphans === null) loadOrphans()
else if (t === 'trash' && trash === null) loadTrash()
}
// Initial tab's data.
loadDuplicates()
</script>
<div class="flex h-full flex-col gap-2">
<Tabs.Root bind:value={tab} class="flex min-h-0 flex-1 flex-col">
<Tabs.List class="h-8 w-fit">
<Tabs.Trigger value="duplicates" class="gap-1 text-xs" onclick={() => activate('duplicates')}>
<CopyIcon class="size-3.5" /> Duplicates
</Tabs.Trigger>
<Tabs.Trigger value="tags" class="gap-1 text-xs" onclick={() => activate('tags')}>
<TagIcon class="size-3.5" /> Tags
</Tabs.Trigger>
<Tabs.Trigger value="orphans" class="gap-1 text-xs" onclick={() => activate('orphans')}>
<GhostIcon class="size-3.5" /> Orphans
</Tabs.Trigger>
<Tabs.Trigger value="trash" class="gap-1 text-xs" onclick={() => activate('trash')}>
<Trash2Icon class="size-3.5" /> Trash
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="duplicates" class="min-h-0 flex-1 overflow-y-auto pt-2">
<p class="mb-2 text-xs text-muted-foreground">
Notes with near-identical titles, grouped for review — not a verdict. Pick a target and the
sources to fold into it; sources are soft-deleted afterward and stay recoverable from Trash.
</p>
{#if mergeError}
<p
class="mb-2 rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{mergeError}
</p>
{/if}
{#if duplicatesError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{duplicatesError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadDuplicates}
>Retry</Button
>
</p>
{/if}
{#if clusters === null}
<div class="flex flex-col gap-3">
{#each Array(2) as _, ci (ci)}
<div class="overflow-hidden rounded-lg border">
<div
class="flex items-center justify-between gap-2 border-b bg-muted/30 px-2.5 py-1.5"
>
<Skeleton class="h-3 w-28" />
<Skeleton class="h-6 w-28" />
</div>
<div class="flex flex-col gap-2 p-2.5">
{#each Array(ci === 0 ? 3 : 2) as _, ri (ri)}
<Skeleton class="h-3.5" style="width: {70 - ri * 10}%" />
{/each}
</div>
</div>
{/each}
</div>
{:else if clusters.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">No likely duplicates found.</p>
{:else}
<div class="flex flex-col gap-3">
{#each clusters as c (c.members[0].slug)}
{@const key = c.members[0].slug}
{@const sourceCount = mergeSources[key]?.size ?? 0}
<div class="overflow-hidden rounded-lg border">
<div
class="flex items-center justify-between gap-2 border-b bg-muted/30 px-2.5 py-1.5"
>
<div class="flex min-w-0 items-center gap-2 text-xs">
<span class="font-medium">{c.members.length} similar notes</span>
<!-- Similarity as a meter rather than only a number: it's a
ratio, and the bar makes a 93% pileup visibly different
from a borderline 61% at a glance down a long list. -->
<span
class="hidden h-1 w-12 shrink-0 overflow-hidden rounded-full bg-primary/15 sm:block"
title="{(c.top_similarity * 100).toFixed(0)}% title similarity"
>
<span
class="block h-full rounded-full bg-primary"
style="width: {c.top_similarity * 100}%"
></span>
</span>
<span class="shrink-0 tabular-nums text-muted-foreground"
>{(c.top_similarity * 100).toFixed(0)}%</span
>
</div>
<Button
size="sm"
class="h-6 shrink-0 gap-1 text-xs"
disabled={merging === key || sourceCount === 0}
onclick={() => doMerge(key)}
>
{merging === key ? 'Merging…' : `Merge ${sourceCount} into target`}
</Button>
</div>
<!-- Two bare inputs per row read as "…what do these do?", so
name them once per cluster. An inline legend rather than
column headers: the controls are 14px wide and the words
are not, so headers sized to the columns just collide. -->
<p class="flex items-center gap-3 px-2.5 pt-2 text-[10px] text-muted-foreground">
<span class="flex items-center gap-1">
<span
class="inline-block size-2 rounded-full ring-1 ring-muted-foreground/60"
aria-hidden="true"
></span> keep as target
</span>
<span class="flex items-center gap-1">
<span
class="inline-block size-2 rounded-[2px] ring-1 ring-muted-foreground/60"
aria-hidden="true"
></span> fold into it
</span>
</p>
<div class="flex flex-col p-1">
{#each c.members as m (m.slug)}
{@const isTarget = mergeTarget[key] === m.slug}
{@const Icon = kindMeta(m.kind).icon}
<label
class="flex items-center gap-2 rounded px-1.5 py-1 text-xs {isTarget
? 'bg-primary/5'
: 'hover:bg-muted/40'}"
>
<input
type="radio"
class="size-3.5 shrink-0 accent-[var(--primary)]"
name="target-{key}"
aria-label="Keep &quot;{m.title}&quot; as the merge target"
checked={isTarget}
onchange={() =>
setMergeTarget(
key,
m.slug,
c.members.map((mm) => mm.slug)
)}
/>
<input
type="checkbox"
class="size-3.5 shrink-0 accent-[var(--primary)]"
aria-label="Fold &quot;{m.title}&quot; into the target"
disabled={isTarget}
checked={!isTarget && (mergeSources[key]?.has(m.slug) ?? false)}
onchange={() => toggleSource(key, m.slug)}
/>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<button
type="button"
class="min-w-0 flex-1 truncate text-left hover:underline {isTarget
? 'font-medium text-primary'
: ''}"
onclick={() => onSelect(m.slug)}
>
{m.title}
</button>
<span class="shrink-0 text-[10px] text-muted-foreground"
>{relativeTime(m.updated_at)}</span
>
{#if isTarget}
<Badge
variant="outline"
class="shrink-0 border-primary/40 text-[9px] text-primary">target</Badge
>
{/if}
</label>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="tags" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if tagsError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{tagsError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTags}>Retry</Button>
</p>
{/if}
{#if tags === null}
<table class="w-full text-xs">
<thead>
<tr class="border-b text-left text-muted-foreground">
<th class="py-1 font-normal">Tag</th>
<th class="py-1 font-normal" colspan="2">Uses</th>
<th class="py-1 font-normal">Variants</th>
<th class="py-1"></th>
</tr>
</thead>
<tbody>
{#each Array(10) as _, i (i)}
<tr class="border-b border-border/50">
<td class="w-32 py-1.5 pr-2"
><Skeleton class="h-3" style="width: {60 - i * 3}%" /></td
>
<td class="w-8 py-1.5 pr-1"><Skeleton class="ml-auto h-3 w-4" /></td>
<td class="w-24 py-1.5 pr-3">
<Skeleton class="h-1 rounded-full" style="width: {90 - i * 8}%" />
</td>
<td class="py-1.5 pr-2"><Skeleton class="h-3 w-6" /></td>
<td class="py-1.5"></td>
</tr>
{/each}
</tbody>
</table>
{:else}
{@const maxUses = Math.max(1, ...tags.map((t) => t.uses))}
<table class="w-full text-xs">
<thead>
<tr class="border-b text-left text-muted-foreground">
<th class="py-1 font-normal">Tag</th>
<th class="py-1 font-normal" colspan="2">Uses</th>
<th class="py-1 font-normal">Variants</th>
<th class="py-1"></th>
</tr>
</thead>
<tbody>
{#each tags as t (t.tag)}
<tr class="border-b border-border/50">
<td class="py-1 pr-2">
{#if renaming === t.tag}
<div class="flex items-center gap-1">
<Input bind:value={renameDraft} class="h-6 w-32 text-xs" />
<Button
size="sm"
class="h-6 px-1.5"
disabled={renameBusy}
onclick={() => confirmRename(t)}
>
<CheckIcon class="size-3" />
</Button>
</div>
{:else}
<button
type="button"
class="font-mono hover:underline"
onclick={() => startRename(t)}
>
{t.tag}
</button>
{/if}
</td>
<!-- tabular-nums here (unlike the overview's standalone
figures): these are a column that has to line up. -->
<td class="w-8 py-1 pr-1 text-right tabular-nums">{t.uses}</td>
<td class="w-24 py-1 pr-3">
<!-- Magnitude, so: one hue, length-encoded, scaled to the
most-used tag. Recessive by design — it's a reading aid
down the column, not the subject of the table. -->
<span class="block h-1 overflow-hidden rounded-full bg-primary/10">
<span
class="block h-full rounded-full bg-primary/60"
style="width: {(t.uses / maxUses) * 100}%"
></span>
</span>
</td>
<td class="py-1 pr-2">
{#if t.split}
<span class="text-destructive">{t.variants.join(', ')}</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
<td class="py-1 text-right">
{#if t.split}
<Button
size="sm"
variant="outline"
class="h-6 text-xs"
disabled={renameBusy}
onclick={() => normalize(t)}
>
Normalize
</Button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Tabs.Content>
<Tabs.Content value="orphans" class="min-h-0 flex-1 overflow-y-auto pt-2">
<p class="mb-2 text-xs text-muted-foreground">
Notes untagged, unlinked to any entity, or untouched for 90+ days — invisible to most
navigation paths and easy to lose track of.
{#if orphanCounts.untagged || orphanCounts.unlinked || orphanCounts.stale}
({orphanCounts.untagged ?? 0} untagged · {orphanCounts.unlinked ?? 0} unlinked · {orphanCounts.stale ??
0} stale)
{/if}
</p>
{#if orphansError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{orphansError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadOrphans}
>Retry</Button
>
</p>
{/if}
{#if orphans === null}
<div class="flex flex-col gap-0.5">
{#each Array(7) as _, i (i)}
<div class="flex items-center gap-2 px-1.5 py-1.5">
<Skeleton class="h-3.5 flex-1" style="max-width: {60 - (i % 4) * 8}%" />
<Skeleton class="h-4 w-14 shrink-0 rounded-full" />
<Skeleton class="h-3 w-10 shrink-0" />
</div>
{/each}
</div>
{:else if orphans.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">Nothing orphaned.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each orphans as o (o.slug)}
<button
type="button"
class="flex items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted/40"
onclick={() => onSelect(o.slug)}
>
<span class="min-w-0 flex-1 truncate">{o.title}</span>
{#each o.reasons as r (r)}<Badge variant="outline" class="shrink-0 text-[9px]"
>{r}</Badge
>{/each}
<span class="shrink-0 text-[10px] text-muted-foreground"
>{relativeTime(o.updated_at)}</span
>
</button>
{/each}
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="trash" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if trashError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{trashError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTrash}>Retry</Button>
</p>
{/if}
{#if trash === null}
<div class="flex flex-col gap-0.5">
{#each Array(4) as _, i (i)}
<div class="flex items-center gap-2 px-1.5 py-1.5">
<Skeleton class="h-3.5 flex-1" style="max-width: {55 - i * 6}%" />
<Skeleton class="h-3 w-32 shrink-0" />
<Skeleton class="h-6 w-16 shrink-0" />
</div>
{/each}
</div>
{:else if trash.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">Trash is empty.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each trash as t (t.slug)}
<div class="flex items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/40">
<span class="min-w-0 flex-1 truncate">{t.title}</span>
<span class="shrink-0 text-[10px] text-muted-foreground">
deleted {relativeTime(t.deleted_at)} by {t.deleted_by || 'unknown'}
</span>
<Button
size="sm"
variant="outline"
class="h-6 shrink-0 gap-1 text-xs"
disabled={restoring === t.slug}
onclick={() => doRestore(t.slug)}
>
<RotateCcwIcon class="size-3" /> Restore
</Button>
</div>
{/each}
</div>
{/if}
</Tabs.Content>
</Tabs.Root>
</div>