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,124 @@
<script lang="ts">
// Right pane: the discovery half of the wiki. From any note you can walk
// to the entity it's about, and from there sideways to every other note
// that concerns the same entity or shares a tag — this is what makes the
// knowledge base a graph to browse rather than a flat list to scroll.
//
// "Related" and "tag neighbours" are derived client-side from the list
// already loaded by Knowledge.svelte (KnowledgeListItem carries `about`
// and `tags`), not a separate endpoint — with ~100 notes total, filtering
// an in-memory array is cheaper and simpler than a bespoke backlinks
// query, and it's exactly the same data WikiTree's group-by-entity/tag
// modes already use.
import type { KnowledgeListItem } from '$lib/api'
import { openEntityWindow } from '$lib/stores/windows'
import DetailSection from '$lib/components/DetailSection.svelte'
import { kindMeta } from './kinds'
import LinkIcon from '@lucide/svelte/icons/link'
let {
item,
allItems,
onSelect
}: {
item: KnowledgeListItem | null
allItems: KnowledgeListItem[]
onSelect: (slug: string) => void
} = $props()
const related = $derived.by(() => {
if (!item || item.about.length === 0) return []
const aboutSet = new Set(item.about)
return allItems
.filter((it) => it.slug !== item.slug && it.about.some((s) => aboutSet.has(s)))
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
})
const tagNeighbours = $derived.by(() => {
if (!item || item.tags.length === 0) return []
const tagSet = new Set(item.tags)
return allItems
.filter((it) => it.slug !== item.slug && it.tags.some((t) => tagSet.has(t)))
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
.slice(0, 20) // common tags (e.g. "backup") can otherwise pull in most of the KB
})
</script>
<div class="flex h-full flex-col gap-2 overflow-y-auto pr-1">
{#if !item}
<p class="py-8 text-center text-xs text-muted-foreground">Nothing selected.</p>
{:else}
<DetailSection title="About" count={item.about.length} defaultOpen={true}>
{#if item.about.length === 0}
<p class="text-xs text-muted-foreground">Not linked to any entity.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each item.about as slug (slug)}
<button
type="button"
class="flex items-center gap-1.5 rounded px-1 py-0.5 text-left font-mono text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
onclick={() => openEntityWindow(slug)}
>
<LinkIcon class="size-3 shrink-0" />
<span class="truncate">{slug}</span>
</button>
{/each}
</div>
{/if}
</DetailSection>
<DetailSection
title="Also about these entities"
count={related.length}
defaultOpen={related.length > 0}
>
{#if related.length === 0}
<p class="text-xs text-muted-foreground">No other notes share a linked entity.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each related as it (it.slug)}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
class="group flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs hover:bg-muted/50"
onclick={() => onSelect(it.slug)}
title={it.title}
>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<span class="min-w-0 flex-1 truncate group-hover:text-primary">{it.title}</span>
</button>
{/each}
</div>
{/if}
</DetailSection>
<!-- Only auto-open a *tight* neighbour set. A generic tag like "container"
is on 20 notes, and expanding all of those by default buries the
stronger entity-based links above it under a wall of weak matches;
a handful of shared-tag notes is a real cluster worth surfacing. -->
<DetailSection
title="Tag neighbours"
count={tagNeighbours.length}
defaultOpen={related.length === 0 && tagNeighbours.length > 0 && tagNeighbours.length <= 6}
>
{#if tagNeighbours.length === 0}
<p class="text-xs text-muted-foreground">No other notes share a tag.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each tagNeighbours as it (it.slug)}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
class="group flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs hover:bg-muted/50"
onclick={() => onSelect(it.slug)}
title={it.title}
>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<span class="min-w-0 flex-1 truncate group-hover:text-primary">{it.title}</span>
</button>
{/each}
</div>
{/if}
</DetailSection>
{/if}
</div>