Files
oikos/web/src/App.svelte
dtoro 94c94c0758
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat(web): merge Entities + Graph into a single Knowledge Base page
Replaces the separate Entities/Graph nav items with one Knowledge Base
page that browses all entities as either a table or a force-graph,
scoped by ontology layer (Infrastructure/Governance/Cognition), with a
resizable browse/detail split instead of a slide-over sheet.

- New KnowledgeBase.svelte: layer tabs, view toggle, resizable
  browse/detail split (pattern from Chat.svelte's rail).
- EntityTable/EntityGraph extracted as presentational sub-components;
  their search/filter/root/depth toolbars live in the shared page
  toolbar (not the resizable pane) so they don't truncate when the
  divider is dragged narrow, and both views start flush with the
  detail pane for consistent height.
- EntityTable columns are sortable (slug/type/name/state/health).
- EntityDetailContent redesigned as a single-column list of
  collapsible sections (DetailSection.svelte), collapsed by default
  when empty; relation entries are clickable and select the entity in
  the browse pane + detail pane (and drill in-place in EntitySheet
  wherever it's used elsewhere in the app).
- api.ts: add layer filter to fetchEntities, add fetchEntityTypes for
  client-side graph layer scoping (the graph endpoint has no layer
  param).

Old hash routes (#/entities, #/graph) redirect to #/kb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-12 22:21:29 +02:00

246 lines
9.7 KiB
Svelte

<script lang="ts">
import Chat from './pages/Chat.svelte'
import Overview from './pages/Overview.svelte'
import KnowledgeBase from './pages/KnowledgeBase.svelte'
import Ops from './pages/Ops.svelte'
import Signals from './pages/Signals.svelte'
import EntityDetail from './pages/EntityDetail.svelte'
import Knowledge from './pages/Knowledge.svelte'
import Learning from './pages/Learning.svelte'
import Config from './pages/Config.svelte'
import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
import { connectionState } from '$lib/stores/events'
import { isConfigured } from '$lib/config'
import { onMount } from 'svelte'
import * as Sidebar from '$lib/components/ui/sidebar'
import * as Sheet from '$lib/components/ui/sheet'
import { Button } from '$lib/components/ui/button'
import { Badge } from '$lib/components/ui/badge'
import { Separator } from '$lib/components/ui/separator'
import { Toaster } from '$lib/components/ui/sonner'
import PlusIcon from '@lucide/svelte/icons/plus'
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
import DatabaseIcon from '@lucide/svelte/icons/database'
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
import SirenIcon from '@lucide/svelte/icons/siren'
import SearchIcon from '@lucide/svelte/icons/search'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
import SettingsIcon from '@lucide/svelte/icons/settings'
let page = $state('overview')
let routeParam = $state('')
let drawerOpen = $state(false)
let configured = $state(isConfigured())
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
const openSignals = $derived(openSignalCount($summary))
onMount(() => {
function sync() {
const path = location.hash.slice(2) || 'overview'
const [head, ...rest] = path.split('/')
// Entities + Graph were merged into Knowledge Base — keep old links working.
if (head === 'entities' || head === 'graph') {
location.hash = '#/kb'
return
}
page = head || 'overview'
routeParam = rest.join('/')
}
sync()
window.addEventListener('hashchange', sync)
return () => window.removeEventListener('hashchange', sync)
})
// Context (dashboard summary + approvals poll) and the SSE stream both
// authenticate — don't subscribe until a token exists.
$effect(() => {
if (!configured) return
return subscribeContext()
})
function navigate(p: string) {
location.hash = '#/' + p
}
const navItems = [
{ id: 'overview', label: 'Overview', icon: LayoutDashboardIcon },
{ id: 'kb', label: 'Knowledge Base', icon: DatabaseIcon },
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
{ id: 'learning', label: 'Learning', icon: TrendingUpIcon }
]
</script>
{#if !configured}
<Config
onConnected={() => (configured = true)}
onCancel={isConfigured() ? () => (configured = true) : undefined}
/>
{:else}
<Toaster />
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
<Sidebar.Root collapsible="icon" variant="inset">
<Sidebar.Header>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
class="data-[slot=sidebar-menu-button]:!p-1.5"
onclick={() => navigate('overview')}
tooltipContent="Oikos"
>
{#snippet child({ props })}
<button {...props}>
<svg viewBox="0 0 91 100" class="!size-5 shrink-0 fill-white" aria-hidden="true" role="img">
<title>Oikos</title>
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
</svg>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
onclick={() => { newChat(); navigate('chat') }}
tooltipContent="New task"
>
{#snippet child({ props })}
<button {...props}>
<PlusIcon />
<span>New task</span>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Menu>
{#each navItems as item}
<Sidebar.MenuItem>
<Sidebar.MenuButton
isActive={page === item.id || (item.id === 'overview' && page === 'chat')}
onclick={() => navigate(item.id)}
tooltipContent={item.label}
>
{#snippet child({ props })}
<button {...props}>
<item.icon />
<span>{item.label}</span>
</button>
{/snippet}
</Sidebar.MenuButton>
{#if item.badge?.()}
<Sidebar.MenuBadge>{item.badge()}</Sidebar.MenuBadge>
{/if}
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<Button
variant="ghost"
size="sm"
class="justify-start gap-2"
onclick={() => (drawerOpen = true)}
title="Chat over the current page without navigating away"
>
<PanelRightIcon />
<span>Chat drawer</span>
</Button>
<Button
variant="ghost"
size="sm"
class="justify-start gap-2"
onclick={() => (configured = false)}
title="Server connection settings"
>
<SettingsIcon />
<span>Connection</span>
</Button>
</Sidebar.Footer>
</Sidebar.Root>
<Sidebar.Inset class="min-h-0 overflow-hidden">
<header class="flex h-(--header-height) shrink-0 items-center gap-1 border-b px-4 lg:gap-2 lg:px-6">
<Sidebar.Trigger class="-ms-1" />
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
{#if page === 'chat'}
<button type="button" class="text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Overview</button>
<span class="text-muted-foreground">/</span>
<span class="text-base font-medium">Conversation</span>
{:else}
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page}</span>
{/if}
<div class="ms-auto flex items-center gap-2.5">
{#if $summary}
<div class="hidden items-center gap-2.5 text-xs text-muted-foreground sm:flex">
<span class="flex items-center gap-1" title="healthy"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
<span class="flex items-center gap-1" title="degraded"><span class="size-2 rounded-full bg-warning"></span>{$summary.health.degraded}</span>
<span class="flex items-center gap-1" title="down"><span class="size-2 rounded-full bg-destructive"></span>{$summary.health.down}</span>
</div>
{#if approvalsPending}
<button type="button" onclick={() => navigate('ops')}>
<Badge variant="destructive" class="cursor-pointer">{approvalsPending} approval{approvalsPending === 1 ? '' : 's'}</Badge>
</button>
{/if}
{#if openSignals}
<button type="button" onclick={() => navigate('signals')}>
<Badge variant="secondary" class="cursor-pointer">{openSignals} signal{openSignals === 1 ? '' : 's'}</Badge>
</button>
{/if}
{/if}
<span
class="size-2 rounded-full {$connectionState === 'open' ? 'bg-success' : $connectionState === 'connecting' ? 'animate-pulse bg-warning' : 'bg-destructive'}"
title="event stream: {$connectionState}"
></span>
</div>
</header>
<main class="min-h-0 flex-1 overflow-hidden">
{#if page === 'overview'}
<Overview />
{:else if page === 'kb'}
<KnowledgeBase />
{:else if page === 'entity' && routeParam}
<EntityDetail slug={routeParam} />
{:else if page === 'ops'}
<Ops />
{:else if page === 'signals'}
<Signals />
{:else if page === 'knowledge'}
<Knowledge />
{:else if page === 'learning'}
<Learning />
{:else}
<Chat />
{/if}
</main>
</Sidebar.Inset>
</Sidebar.Provider>
<Sheet.Root bind:open={drawerOpen}>
<Sheet.Content side="right" class="w-[400px] p-0 sm:max-w-[400px]">
<Sheet.Header class="sr-only">
<Sheet.Title>Nomos chat</Sheet.Title>
<Sheet.Description>Persistent chat drawer</Sheet.Description>
</Sheet.Header>
<div class="flex h-full flex-col">
<Chat showRail={false} />
</div>
</Sheet.Content>
</Sheet.Root>
{/if}