style(web): fix prettier config, format entire web/ tree
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

.prettierrc.json was missing "semi": false, so prettier wanted to add
semicolons to a codebase written without them (763 semicolon-free
statements vs. 150 with, in hand-written .ts; zero hand-written .svelte
files use them at all). That's why prettier --check failed on 249 files
— not because the code was unformatted, but because the config didn't
match the actual house style. Added "semi": false; left printWidth/etc
as configured (printWidth barely moves the failure count: 218/213/212
files at 100/120/140).

Ran `prettier --write .` with the corrected config. Verified
semantics-preserving before and after:
- eslint: 142 problems both before and after, byte-identical
- build passes, 38/38 tests pass
- token-stream diff (whitespace/semicolons/quotes normalized) on all
  218 changed files: only 52 had any remaining token change, all either
  trailing-comma removal (matching trailingComma: "none") or import/
  ternary reflow — no semantic changes
- live smoke test: Knowledge, Tasks, Fleet map, and a chat window
  (AgentTrace, markdown, Scope graph, activity rail) all render
  correctly, no console errors

Most of the diff is shadcn/ui vendor files (lib/components/ui/) moving
from the CLI's own style (double quotes, tabs, semicolons) to house
style; re-running `shadcn-svelte add` on a component will need a
follow-up format pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 12:56:07 +02:00
parent b345783eef
commit 873b00ac42
217 changed files with 4945 additions and 3538 deletions

View File

@@ -26,8 +26,7 @@
<header class="shrink-0 border-b px-4 py-3">
<h1 class="text-base font-semibold">App Store</h1>
<p class="mt-0.5 text-xs text-muted-foreground">
Installable apps. Local bundles for now — a remote catalog + sandboxing
is Phase 4.
Installable apps. Local bundles for now — a remote catalog + sandboxing is Phase 4.
</p>
</header>
@@ -40,20 +39,27 @@
{@const m = entry.manifest}
{@const isOn = installedSet.has(m.id)}
<li class="flex items-start gap-3 rounded-lg border p-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/40">
<div
class="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/40"
>
<entry.icon class="size-5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h2 class="truncate text-sm font-medium">{m.title}</h2>
<span class="shrink-0 text-[10px] font-mono text-muted-foreground">v{m.version}</span>
{#if m.author}<span class="shrink-0 text-[10px] text-muted-foreground">by {m.author}</span>{/if}
<span class="shrink-0 text-[10px] font-mono text-muted-foreground"
>v{m.version}</span
>
{#if m.author}<span class="shrink-0 text-[10px] text-muted-foreground"
>by {m.author}</span
>{/if}
</div>
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{m.description}</p>
<div class="mt-1.5 flex items-center gap-1 text-[10px] text-muted-foreground">
<ShieldIcon class="size-3" />
<span>
{#if m.permissions.length}{m.permissions.join(', ')}{:else}no permissions requested{/if}
{#if m.permissions.length}{m.permissions.join(', ')}{:else}no permissions
requested{/if}
</span>
</div>
</div>
@@ -84,8 +90,8 @@
<div class="mt-6 rounded-md border border-dashed p-3 text-xs text-muted-foreground">
<p class="flex items-center gap-1.5">
<CheckCircleIcon class="size-3.5" />
Installed apps appear on the desktop immediately. Uninstalling closes
any open window for that app.
Installed apps appear on the desktop immediately. Uninstalling closes any open window for that
app.
</p>
</div>
</div>

View File

@@ -92,12 +92,15 @@
<ConfigBackground />
<div class="relative z-10 flex h-full items-center justify-center p-6">
<div class="w-full max-w-[26rem] space-y-8 rounded-2xl border border-white/8 bg-card/60 p-8 shadow-2xl shadow-black/40 backdrop-blur-xl">
<div
class="w-full max-w-[26rem] space-y-8 rounded-2xl border border-white/8 bg-card/60 p-8 shadow-2xl shadow-black/40 backdrop-blur-xl"
>
<!-- Logo + heading -->
<div class="flex flex-col items-center gap-4">
<svg viewBox="0 0 91 100" class="h-16 w-16 fill-white/90" aria-hidden="true">
<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" />
<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>
<div class="text-center">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Connect to Oikos</h1>
@@ -109,7 +112,9 @@
<div class="flex flex-col gap-1.5">
<Label for="server-url" class="text-xs font-medium">Server URL</Label>
<div class="relative">
<Server class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
<Server
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50"
/>
<Input
id="server-url"
type="url"
@@ -122,7 +127,9 @@
{#if showOidcContinue}
<!-- OIDC authenticated state -->
<div class="flex flex-col items-center gap-3 rounded-xl border border-white/5 bg-background/40 p-5">
<div
class="flex flex-col items-center gap-3 rounded-xl border border-white/5 bg-background/40 p-5"
>
<p class="text-sm text-muted-foreground">
Logged in as <span class="font-semibold text-foreground">{oidcUser}</span>
</p>
@@ -134,9 +141,7 @@
<Button type="button" variant="outline" onclick={onCancel}>Cancel</Button>
{/if}
</div>
<Button type="button" variant="ghost" size="sm" onclick={logoutOIDC}>
Sign out
</Button>
<Button type="button" variant="ghost" size="sm" onclick={logoutOIDC}>Sign out</Button>
</div>
{:else}
<!-- Auth options: Authentik + token -->
@@ -155,23 +160,25 @@
<div class="flex items-center gap-3">
<Separator decorative class="flex-1" />
<span class="text-[10px] font-medium uppercase tracking-widest text-muted-foreground/40">or use</span>
<span class="text-[10px] font-medium uppercase tracking-widest text-muted-foreground/40"
>or use</span
>
<Separator decorative class="flex-1" />
</div>
<!-- Token row -->
<form
class="flex flex-col gap-2.5"
onsubmit={(e) => { e.preventDefault(); connect() }}
onsubmit={(e) => {
e.preventDefault()
connect()
}}
>
<div class="relative">
<Lock class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
<Input
type="password"
placeholder="Bearer token"
class="pl-9"
bind:value={token}
<Lock
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50"
/>
<Input type="password" placeholder="Bearer token" class="pl-9" bind:value={token} />
</div>
<Button type="submit" disabled={connecting} class="w-full">
{connecting ? 'Connecting…' : 'Connect'}
@@ -182,7 +189,9 @@
<!-- Error -->
{#if error}
<p class="rounded-lg bg-destructive/10 px-3 py-2 text-center text-sm text-destructive">{error}</p>
<p class="rounded-lg bg-destructive/10 px-3 py-2 text-center text-sm text-destructive">
{error}
</p>
{/if}
<!-- Footer: cancel + forget -->

View File

@@ -1,6 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchAllEntities, fetchOntology, fetchGraph, type Entity, type EntityType, type Ontology } from '$lib/api'
import {
fetchAllEntities,
fetchOntology,
fetchGraph,
type Entity,
type EntityType,
type Ontology
} from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import EntityTable from '$lib/components/EntityTable.svelte'
import FleetMap from '$lib/components/FleetMap.svelte'
@@ -120,7 +127,9 @@
const hierRels = relationshipTypes.filter((rt) => rt.cardinality !== 'many-to-many')
const relTypeNames = hierRels.map((rt) => rt.name)
const cardinalityByType = new Map(hierRels.map((rt) => [rt.name, rt.cardinality]))
const specificityByType = new Map(hierRels.map((rt) => [rt.name, typeDepth(byName, rt.source_type)]))
const specificityByType = new Map(
hierRels.map((rt) => [rt.name, typeDepth(byName, rt.source_type)])
)
const slugs = new Set(entities.map((e) => e.slug))
// One whole-graph fetch instead of one rooted fetch per candidate parent
@@ -129,7 +138,10 @@
// browser's concurrent-connection limit (ERR_INSUFFICIENT_RESOURCES).
const g = await fetchGraph({ relType: relTypeNames })
const pairs = (g?.edges ?? [])
.filter((edge) => cardinalityByType.has(edge.type) && slugs.has(edge.source) && slugs.has(edge.target))
.filter(
(edge) =>
cardinalityByType.has(edge.type) && slugs.has(edge.source) && slugs.has(edge.target)
)
.map((edge) => {
const [child, parent] =
cardinalityByType.get(edge.type) === 'many-to-one'
@@ -189,14 +201,21 @@
highlights nodes, table filters rows); the Inactive toggle + count are
table-only; the graph/table switch sits inline on the right. -->
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Filter / highlight by slug or name…" bind:value={search} class="h-8 max-w-xs text-xs" />
<Input
placeholder="Filter / highlight by slug or name…"
bind:value={search}
class="h-8 max-w-xs text-xs"
/>
{#if view === 'table'}
<div class="flex items-center gap-1.5">
<Switch id="show-inactive" bind:checked={showInactive} />
<Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label>
<Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label
>
</div>
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {allEntities.length}</span>
<span class="text-xs text-muted-foreground"
>{filteredEntities.length} of {allEntities.length}</span
>
{/if}
<div class="ml-auto inline-flex overflow-hidden rounded-md border">
@@ -229,7 +248,13 @@
{#if view === 'graph'}
<FleetMap selectedSlug={lastOpened} onSelect={select} {search} />
{:else}
<EntityTable entities={filteredEntities} loading={entitiesLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} />
<EntityTable
entities={filteredEntities}
loading={entitiesLoading}
selectedSlug={lastOpened}
onSelect={select}
{childToParent}
/>
{/if}
</div>
</div>

View File

@@ -85,11 +85,15 @@
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Execution outcomes — last 30 days</Card.Title>
<Card.Description class="text-xs">Every gated action, by day it ran, succeeded vs failed.</Card.Description>
<Card.Description class="text-xs"
>Every gated action, by day it ran, succeeded vs failed.</Card.Description
>
</Card.Header>
<Card.Content>
{#if trend.length === 0}
{#if !loading}<p class="py-8 text-center text-sm text-muted-foreground">No executions in the last 30 days yet.</p>{/if}
{#if !loading}<p class="py-8 text-center text-sm text-muted-foreground">
No executions in the last 30 days yet.
</p>{/if}
{:else}
<div bind:this={chartEl} class="w-full"></div>
{/if}
@@ -98,8 +102,12 @@
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-1.5 text-sm"><TrendingUpIcon class="size-4" /> Capability timeline</Card.Title>
<Card.Description class="text-xs">What Nomos has learned to do, ordered by when it first succeeded.</Card.Description>
<Card.Title class="flex items-center gap-1.5 text-sm"
><TrendingUpIcon class="size-4" /> Capability timeline</Card.Title
>
<Card.Description class="text-xs"
>What Nomos has learned to do, ordered by when it first succeeded.</Card.Description
>
</Card.Header>
<Card.Content>
<div class="flex flex-col gap-2">
@@ -108,13 +116,17 @@
<div>
<span class="font-mono text-sm">{item.verb}</span>
<span class="ml-2 text-xs text-muted-foreground">
{item.first_success ? `first succeeded ${fmtDate(item.first_success)}` : 'no successes yet'}
{item.first_success
? `first succeeded ${fmtDate(item.first_success)}`
: 'no successes yet'}
</span>
</div>
<Badge variant={timelineVariant(item)}>{item.successes}/{item.total}</Badge>
</div>
{:else}
{#if !loading}<p class="py-8 text-center text-sm text-muted-foreground">No executions yet.</p>{/if}
{#if !loading}<p class="py-8 text-center text-sm text-muted-foreground">
No executions yet.
</p>{/if}
{/each}
</div>
</Card.Content>
@@ -123,12 +135,15 @@
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Patterns</Card.Title>
<Card.Description class="text-xs">Statistically validated behaviors, extracted from outcome feedback.</Card.Description>
<Card.Description class="text-xs"
>Statistically validated behaviors, extracted from outcome feedback.</Card.Description
>
</Card.Header>
<Card.Content>
{#if patterns.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">
No patterns learned yet — patterns emerge once outcome feedback is recorded for repeated actions.
No patterns learned yet — patterns emerge once outcome feedback is recorded for repeated
actions.
</p>
{:else}
<div class="flex flex-col gap-2">
@@ -136,7 +151,8 @@
<div class="flex items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div>
<span class="text-sm">{p.pattern}</span>
<span class="ml-2 text-xs text-muted-foreground">{p.applies_type} · {p.action}</span>
<span class="ml-2 text-xs text-muted-foreground">{p.applies_type} · {p.action}</span
>
</div>
<Badge variant="outline">{(p.confidence * 100).toFixed(0)}% conf.</Badge>
</div>
@@ -148,8 +164,12 @@
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-1.5 text-sm"><SparklesIcon class="size-4" /> Promoted skills</Card.Title>
<Card.Description class="text-xs">Procedures promoted from validated patterns.</Card.Description>
<Card.Title class="flex items-center gap-1.5 text-sm"
><SparklesIcon class="size-4" /> Promoted skills</Card.Title
>
<Card.Description class="text-xs"
>Procedures promoted from validated patterns.</Card.Description
>
</Card.Header>
<Card.Content>
{#if skills.length === 0}

View File

@@ -73,32 +73,116 @@
const pendingApprovals = $derived(approvals.filter((a) => a.status === 'pending'))
const decidedApprovals = $derived(approvals.filter((a) => a.status !== 'pending'))
const pendingColumns = $derived.by(() => [
{ key: 'subject', header: 'Subject', class: 'font-mono text-xs', width: '180px', accessor: (a: Approval) => a.subject ?? '—', truncate: true },
{ key: 'action', header: 'Action', truncate: true },
{ key: 'risk_class', header: 'Risk', render: 'status-badge', renderProps: { kind: 'risk' }, width: '120px' },
{ key: 'status', header: 'Status', render: 'status-badge', renderProps: { kind: 'execution' }, width: '100px' },
{ key: 'expires_at', header: 'Expires', render: 'date', width: '170px' },
{ key: '_actions', header: '', render: ApprovalActions,
renderProps: { deciding, onApprove: (id: string) => decide(id, 'approve'), onDeny: (id: string) => decide(id, 'deny') },
align: 'right', headerClass: 'text-right', width: '220px' },
] as DataTableColumn<Approval>[])
const pendingColumns = $derived.by(
() =>
[
{
key: 'subject',
header: 'Subject',
class: 'font-mono text-xs',
width: '180px',
accessor: (a: Approval) => a.subject ?? '—',
truncate: true
},
{ key: 'action', header: 'Action', truncate: true },
{
key: 'risk_class',
header: 'Risk',
render: 'status-badge',
renderProps: { kind: 'risk' },
width: '120px'
},
{
key: 'status',
header: 'Status',
render: 'status-badge',
renderProps: { kind: 'execution' },
width: '100px'
},
{ key: 'expires_at', header: 'Expires', render: 'date', width: '170px' },
{
key: '_actions',
header: '',
render: ApprovalActions,
renderProps: {
deciding,
onApprove: (id: string) => decide(id, 'approve'),
onDeny: (id: string) => decide(id, 'deny')
},
align: 'right',
headerClass: 'text-right',
width: '220px'
}
] as DataTableColumn<Approval>[]
)
const decidedColumns: DataTableColumn<Approval>[] = [
{ key: 'subject', header: 'Subject', class: 'font-mono text-xs', width: '180px', accessor: (a) => a.subject ?? '—', truncate: true },
{
key: 'subject',
header: 'Subject',
class: 'font-mono text-xs',
width: '180px',
accessor: (a) => a.subject ?? '—',
truncate: true
},
{ key: 'action', header: 'Action', truncate: true },
{ key: 'status', header: 'Status', render: 'status-badge', renderProps: { kind: 'execution' }, width: '100px' },
{ key: 'decided_at', header: 'Decided', render: 'date', width: '170px', accessor: (a) => a.decided_at ?? '—' },
{
key: 'status',
header: 'Status',
render: 'status-badge',
renderProps: { kind: 'execution' },
width: '100px'
},
{
key: 'decided_at',
header: 'Decided',
render: 'date',
width: '170px',
accessor: (a) => a.decided_at ?? '—'
}
]
const activityColumns: DataTableColumn<ActivityItem>[] = [
{ key: 'target', header: 'Target', class: 'font-mono text-xs', width: '180px', accessor: (a) => a.target ?? '—', truncate: true },
{
key: 'target',
header: 'Target',
class: 'font-mono text-xs',
width: '180px',
accessor: (a) => a.target ?? '—',
truncate: true
},
{ key: '_action', header: 'Action', render: ActivityAction, truncate: true },
{ key: 'risk_class', header: 'Risk', render: 'status-badge', renderProps: { kind: 'risk' }, width: '120px' },
{ key: 'status', header: 'Status', render: 'status-badge', renderProps: { kind: 'execution' }, width: '110px' },
{ key: 'duration_ms', header: 'Duration', render: DurationRenderer, width: '90px', align: 'right' },
{
key: 'risk_class',
header: 'Risk',
render: 'status-badge',
renderProps: { kind: 'risk' },
width: '120px'
},
{
key: 'status',
header: 'Status',
render: 'status-badge',
renderProps: { kind: 'execution' },
width: '110px'
},
{
key: 'duration_ms',
header: 'Duration',
render: DurationRenderer,
width: '90px',
align: 'right'
},
{ key: 'created_at', header: 'When', render: 'relative-time', width: '100px' },
{ key: '_cancel', header: '', render: ActivityCancel, renderProps: { onCancel: cancel }, align: 'right', headerClass: 'text-right', width: '100px' },
{
key: '_cancel',
header: '',
render: ActivityCancel,
renderProps: { onCancel: cancel },
align: 'right',
headerClass: 'text-right',
width: '100px'
}
]
</script>
@@ -108,13 +192,19 @@
<Tabs.Root value="approvals" class="flex flex-1 flex-col overflow-hidden">
<Tabs.List>
<Tabs.Trigger value="approvals">
Approvals {#if pendingApprovals.length}<Badge variant="destructive" class="ml-1">{pendingApprovals.length}</Badge>{/if}
Approvals {#if pendingApprovals.length}<Badge variant="destructive" class="ml-1"
>{pendingApprovals.length}</Badge
>{/if}
</Tabs.Trigger>
<Tabs.Trigger value="executions">Activity</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="approvals" class="flex-1 overflow-auto">
<DataTable columns={pendingColumns} data={pendingApprovals} emptyMessage="No pending approvals." />
<DataTable
columns={pendingColumns}
data={pendingApprovals}
emptyMessage="No pending approvals."
/>
{#if decidedApprovals.length}
<p class="mt-4 text-xs text-muted-foreground">Recently decided</p>

View File

@@ -15,7 +15,13 @@
let filter = $state<'all' | Bucket>('all')
const counts = $derived.by(() => {
const c: Record<string, number> = { all: $sessions.length, running: 0, input: 0, done: 0, failed: 0 }
const c: Record<string, number> = {
all: $sessions.length,
running: 0,
input: 0,
done: 0,
failed: 0
}
for (const s of $sessions) c[bucket(s)]++
return c
})
@@ -53,10 +59,31 @@
})
const columns: DataTableColumn<Session>[] = [
{ key: '_status', header: 'Status', render: StatusDotRenderer, sortable: true, accessor: (s) => bucket(s), width: '140px' },
{
key: '_status',
header: 'Status',
render: StatusDotRenderer,
sortable: true,
accessor: (s) => bucket(s),
width: '140px'
},
{ key: '_heading', header: 'Task', sortable: true, accessor: heading, truncate: true },
{ key: 'summary', header: 'Summary', accessor: (s) => s.summary || '—', truncate: true, headerClass: 'hidden md:table-cell', class: 'hidden md:table-cell' },
{ key: 'last_active_at', header: 'Last active', render: 'relative-time', sortable: true, width: '112px', align: 'right' },
{
key: 'summary',
header: 'Summary',
accessor: (s) => s.summary || '—',
truncate: true,
headerClass: 'hidden md:table-cell',
class: 'hidden md:table-cell'
},
{
key: 'last_active_at',
header: 'Last active',
render: 'relative-time',
sortable: true,
width: '112px',
align: 'right'
}
]
const emptyMessage = $derived(
@@ -67,7 +94,6 @@
</script>
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-2">
<div class="relative z-10 flex flex-wrap items-center gap-1.5">
{#each FILTERS as f}
<button
@@ -88,13 +114,9 @@
</Button>
</div>
<div class="relative z-10 min-h-0 flex-1 overflow-hidden rounded-xl border bg-card/70 backdrop-blur">
<DataTable
{columns}
data={visible}
{emptyMessage}
bordered={false}
onRowClick={openTask}
/>
<div
class="relative z-10 min-h-0 flex-1 overflow-hidden rounded-xl border bg-card/70 backdrop-blur"
>
<DataTable {columns} data={visible} {emptyMessage} bordered={false} onRowClick={openTask} />
</div>
</div>

View File

@@ -11,7 +11,14 @@
import { Separator } from '$lib/components/ui/separator'
import { Checkbox } from '$lib/components/ui/checkbox'
import { Slider } from '$lib/components/ui/slider'
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig, type OikosConfig } from '$lib/config'
import {
fetchWithAuth,
setConfig,
initConfig,
getConfig,
clearConfig,
type OikosConfig
} from '$lib/config'
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
import { getTheme, setTheme, THEME_LABELS, type Theme } from '$lib/stores/theme.svelte'
import {
@@ -138,234 +145,302 @@
</script>
<div class="flex h-full min-h-0 flex-col">
<div class="flex min-h-0 flex-1">
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r bg-muted/20 p-2">
{#each SECTIONS as s (s.id)}
<button
type="button"
class="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors {section === s.id
? 'bg-muted font-medium text-foreground'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}"
onclick={() => (section = s.id)}
>
<s.icon class="size-4 shrink-0" />
{s.label}
</button>
{/each}
</nav>
<div class="flex min-h-0 flex-1">
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r bg-muted/20 p-2">
{#each SECTIONS as s (s.id)}
<button
type="button"
class="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors {section ===
s.id
? 'bg-muted font-medium text-foreground'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}"
onclick={() => (section = s.id)}
>
<s.icon class="size-4 shrink-0" />
{s.label}
</button>
{/each}
</nav>
<div class="min-h-0 flex-1 overflow-y-auto p-6">
{#if section === 'connection'}
<div class="mx-auto flex max-w-md flex-col gap-5">
<div>
<h2 class="text-sm font-semibold">Connection</h2>
<p class="mt-0.5 text-xs text-muted-foreground">Where the control room talks to your Oikos server.</p>
</div>
{#if oidcUser}
<p class="text-xs text-muted-foreground">
Signed in via Authentik as <span class="font-medium text-foreground">{oidcUser}</span>
</p>
{/if}
<div class="flex flex-col gap-1.5">
<Label for="settings-server-url" class="text-xs font-medium">Server URL</Label>
<div class="relative">
<ServerIcon class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
<Input id="settings-server-url" type="url" placeholder="https://oikos.hubris.network" class="pl-9" bind:value={apiUrl} />
<div class="min-h-0 flex-1 overflow-y-auto p-6">
{#if section === 'connection'}
<div class="mx-auto flex max-w-md flex-col gap-5">
<div>
<h2 class="text-sm font-semibold">Connection</h2>
<p class="mt-0.5 text-xs text-muted-foreground">
Where the control room talks to your Oikos server.
</p>
</div>
</div>
<Button type="button" variant="secondary" disabled={oidcLoggingIn} onclick={loginWithAuthentik} class="w-full gap-2">
<LogInIcon class="size-4" />
{oidcLoggingIn ? 'Redirecting…' : oidcConfigured ? 'Switch account with Authentik' : 'Login with Authentik'}
</Button>
{#if oidcUser}
<p class="text-xs text-muted-foreground">
Signed in via Authentik as <span class="font-medium text-foreground">{oidcUser}</span>
</p>
{/if}
<div class="flex items-center gap-3">
<Separator decorative class="flex-1" />
<span class="text-[10px] font-medium uppercase tracking-widest text-muted-foreground/40">or use</span>
<Separator decorative class="flex-1" />
</div>
<form class="flex flex-col gap-2.5" onsubmit={(e) => { e.preventDefault(); save() }}>
<div class="relative">
<LockIcon class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
<Input type="password" placeholder="Bearer token" class="pl-9" bind:value={token} />
<div class="flex flex-col gap-1.5">
<Label for="settings-server-url" class="text-xs font-medium">Server URL</Label>
<div class="relative">
<ServerIcon
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50"
/>
<Input
id="settings-server-url"
type="url"
placeholder="https://oikos.hubris.network"
class="pl-9"
bind:value={apiUrl}
/>
</div>
</div>
<Button type="submit" disabled={saving} class="w-full">
{saving ? 'Saving…' : 'Save'}
<Button
type="button"
variant="secondary"
disabled={oidcLoggingIn}
onclick={loginWithAuthentik}
class="w-full gap-2"
>
<LogInIcon class="size-4" />
{oidcLoggingIn
? 'Redirecting…'
: oidcConfigured
? 'Switch account with Authentik'
: 'Login with Authentik'}
</Button>
</form>
{#if error}
<p class="rounded-lg bg-destructive/10 px-3 py-2 text-center text-sm text-destructive">{error}</p>
{/if}
{#if existing.token || oidcConfigured}
<div class="flex justify-center pt-1">
<Button type="button" variant="ghost" size="sm" onclick={forgetConnection}>Forget saved connection</Button>
<div class="flex items-center gap-3">
<Separator decorative class="flex-1" />
<span class="text-[10px] font-medium uppercase tracking-widest text-muted-foreground/40"
>or use</span
>
<Separator decorative class="flex-1" />
</div>
{/if}
</div>
{:else if section === 'appearance'}
<div class="mx-auto flex max-w-md flex-col gap-5">
<div>
<h2 class="text-sm font-semibold">Appearance</h2>
<p class="mt-0.5 text-xs text-muted-foreground">Pick the theme for the whole desktop.</p>
<form
class="flex flex-col gap-2.5"
onsubmit={(e) => {
e.preventDefault()
save()
}}
>
<div class="relative">
<LockIcon
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50"
/>
<Input type="password" placeholder="Bearer token" class="pl-9" bind:value={token} />
</div>
<Button type="submit" disabled={saving} class="w-full">
{saving ? 'Saving…' : 'Save'}
</Button>
</form>
{#if error}
<p class="rounded-lg bg-destructive/10 px-3 py-2 text-center text-sm text-destructive">
{error}
</p>
{/if}
{#if existing.token || oidcConfigured}
<div class="flex justify-center pt-1">
<Button type="button" variant="ghost" size="sm" onclick={forgetConnection}
>Forget saved connection</Button
>
</div>
{/if}
</div>
{:else if section === 'appearance'}
<div class="mx-auto flex max-w-md flex-col gap-5">
<div>
<h2 class="text-sm font-semibold">Appearance</h2>
<p class="mt-0.5 text-xs text-muted-foreground">
Pick the theme for the whole desktop.
</p>
</div>
<div class="grid grid-cols-2 gap-2">
{#each Object.entries(THEME_LABELS) as [id, label] (id)}
<button
type="button"
class="flex items-center justify-between gap-1.5 rounded-lg border px-2.5 py-1.5 text-left text-sm transition-colors {getTheme() === (id as Theme)
? 'border-primary/50 bg-primary/5 text-foreground'
: 'text-muted-foreground hover:bg-muted/50'}"
onclick={() => pickTheme(id as Theme)}
>
{label}
{#if getTheme() === (id as Theme)}<CheckIcon class="size-3.5 shrink-0 text-primary" />{/if}
</button>
{/each}
</div>
<div class="grid grid-cols-2 gap-2">
{#each Object.entries(THEME_LABELS) as [id, label] (id)}
<button
type="button"
class="flex items-center justify-between gap-1.5 rounded-lg border px-2.5 py-1.5 text-left text-sm transition-colors {getTheme() ===
(id as Theme)
? 'border-primary/50 bg-primary/5 text-foreground'
: 'text-muted-foreground hover:bg-muted/50'}"
onclick={() => pickTheme(id as Theme)}
>
{label}
{#if getTheme() === (id as Theme)}<CheckIcon
class="size-3.5 shrink-0 text-primary"
/>{/if}
</button>
{/each}
</div>
<Separator decorative />
<Separator decorative />
<div>
<h3 class="text-sm font-semibold">Desktop background</h3>
<p class="mt-0.5 text-xs text-muted-foreground">
A subtle CSS pattern behind your icons, in the style of
<a href="https://www.magicpattern.design/tools/css-backgrounds" target="_blank" rel="noreferrer">magicpattern.design</a>.
</p>
</div>
<div>
<h3 class="text-sm font-semibold">Desktop background</h3>
<p class="mt-0.5 text-xs text-muted-foreground">
A subtle CSS pattern behind your icons, in the style of
<a
href="https://www.magicpattern.design/tools/css-backgrounds"
target="_blank"
rel="noreferrer">magicpattern.design</a
>.
</p>
</div>
<div class="grid grid-cols-4 gap-2">
{#each PATTERNS as p (p.id)}
{@const active = getBackground().pattern === p.id}
<button
type="button"
class="flex flex-col items-center gap-1.5 rounded-lg border p-2 transition-colors {active
? 'border-primary/60 bg-primary/5'
: 'border-border hover:bg-muted/50'}"
onclick={() => setBackgroundPattern(p.id)}
title={p.label}
>
<span
class="h-10 w-full rounded-md border border-border/60 bg-muted"
style={swatchStyle(p.id, getBackground().color)}
></span>
<span class="flex items-center gap-1 text-[11px] {active ? 'font-medium text-foreground' : 'text-muted-foreground'}">
{p.label}
{#if active}<CheckIcon class="size-3 text-primary" />{/if}
</span>
</button>
{/each}
</div>
<div class="grid grid-cols-4 gap-2">
{#each PATTERNS as p (p.id)}
{@const active = getBackground().pattern === p.id}
<button
type="button"
class="flex flex-col items-center gap-1.5 rounded-lg border p-2 transition-colors {active
? 'border-primary/60 bg-primary/5'
: 'border-border hover:bg-muted/50'}"
onclick={() => setBackgroundPattern(p.id)}
title={p.label}
>
<span
class="h-10 w-full rounded-md border border-border/60 bg-muted"
style={swatchStyle(p.id, getBackground().color)}
></span>
<span
class="flex items-center gap-1 text-[11px] {active
? 'font-medium text-foreground'
: 'text-muted-foreground'}"
>
{p.label}
{#if active}<CheckIcon class="size-3 text-primary" />{/if}
</span>
</button>
{/each}
</div>
<!-- Colors: pattern (foreground shapes) + an optional fill behind
<!-- Colors: pattern (foreground shapes) + an optional fill behind
them. Fill defaults to "Auto", i.e. transparent, so the app's
own theme background shows through the gaps — same as before
this control existed. -->
<div class="flex items-center justify-between gap-4">
<Label for="bg-color" class="text-xs font-medium">Pattern color</Label>
<div class="flex items-center gap-2">
<input
id="bg-color"
type="color"
class="size-7 cursor-pointer rounded-md border border-border bg-transparent p-0.5"
value={getBackground().color}
oninput={(e) => setPatternColor((e.currentTarget as HTMLInputElement).value)}
<div class="flex items-center justify-between gap-4">
<Label for="bg-color" class="text-xs font-medium">Pattern color</Label>
<div class="flex items-center gap-2">
<input
id="bg-color"
type="color"
class="size-7 cursor-pointer rounded-md border border-border bg-transparent p-0.5"
value={getBackground().color}
oninput={(e) => setPatternColor((e.currentTarget as HTMLInputElement).value)}
/>
<span class="font-mono text-xs text-muted-foreground">{getBackground().color}</span>
</div>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Checkbox
id="bg-fill-toggle"
checked={getBackground().fillColor !== null}
onCheckedChange={(v) => onFillToggle(!!v)}
/>
<Label for="bg-fill-toggle" class="text-xs font-medium">Custom background fill</Label>
</div>
<div class="flex items-center gap-2">
<input
id="bg-fill-color"
type="color"
class="size-7 cursor-pointer rounded-md border border-border bg-transparent p-0.5 disabled:cursor-not-allowed disabled:opacity-40"
value={getBackground().fillColor ?? fillDraft}
disabled={getBackground().fillColor === null}
oninput={onFillColorInput}
/>
<span class="font-mono text-xs text-muted-foreground"
>{getBackground().fillColor ?? 'Auto'}</span
>
</div>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label for="bg-opacity" class="text-xs font-medium">Opacity</Label>
<span class="font-mono text-xs text-muted-foreground"
>{Math.round(getBackground().opacity * 100)}%</span
>
</div>
<Slider
id="bg-opacity"
type="single"
min={0}
max={1}
step={0.01}
value={getBackground().opacity}
onValueChange={setBackgroundOpacity}
/>
<span class="font-mono text-xs text-muted-foreground">{getBackground().color}</span>
</div>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Checkbox
id="bg-fill-toggle"
checked={getBackground().fillColor !== null}
onCheckedChange={(v) => onFillToggle(!!v)}
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label for="bg-fade" class="text-xs font-medium">Fade mask</Label>
<span class="font-mono text-xs text-muted-foreground">
{getBackground().fade === 0 ? 'Off' : `${Math.round(getBackground().fade * 100)}%`}
</span>
</div>
<Slider
id="bg-fade"
type="single"
min={0}
max={1}
step={0.01}
value={getBackground().fade}
onValueChange={setBackgroundFade}
/>
<Label for="bg-fill-toggle" class="text-xs font-medium">Custom background fill</Label>
<p class="text-[11px] text-muted-foreground">
Fades the pattern out toward the edges, like a vignette.
</p>
</div>
<div class="flex items-center gap-2">
<input
id="bg-fill-color"
type="color"
class="size-7 cursor-pointer rounded-md border border-border bg-transparent p-0.5 disabled:cursor-not-allowed disabled:opacity-40"
value={getBackground().fillColor ?? fillDraft}
disabled={getBackground().fillColor === null}
oninput={onFillColorInput}
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label for="bg-scale" class="text-xs font-medium">Size</Label>
<span class="font-mono text-xs text-muted-foreground"
>{Math.round(getBackground().scale * 100)}%</span
>
</div>
<Slider
id="bg-scale"
type="single"
min={0.4}
max={3}
step={0.05}
value={getBackground().scale}
onValueChange={setBackgroundScale}
disabled={noPattern}
/>
<span class="font-mono text-xs text-muted-foreground">{getBackground().fillColor ?? 'Auto'}</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label for="bg-opacity" class="text-xs font-medium">Opacity</Label>
<span class="font-mono text-xs text-muted-foreground">{Math.round(getBackground().opacity * 100)}%</span>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label for="bg-rotation" class="text-xs font-medium">Rotation</Label>
<span class="font-mono text-xs text-muted-foreground"
>{Math.round(getBackground().rotation)}°</span
>
</div>
<Slider
id="bg-rotation"
type="single"
min={0}
max={359}
step={1}
value={getBackground().rotation}
onValueChange={setBackgroundRotation}
disabled={noPattern}
/>
</div>
<Slider
id="bg-opacity"
type="single"
min={0}
max={1}
step={0.01}
value={getBackground().opacity}
onValueChange={setBackgroundOpacity}
/>
</div>
{/if}
</div>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label for="bg-fade" class="text-xs font-medium">Fade mask</Label>
<span class="font-mono text-xs text-muted-foreground">
{getBackground().fade === 0 ? 'Off' : `${Math.round(getBackground().fade * 100)}%`}
</span>
</div>
<Slider id="bg-fade" type="single" min={0} max={1} step={0.01} value={getBackground().fade} onValueChange={setBackgroundFade} />
<p class="text-[11px] text-muted-foreground">Fades the pattern out toward the edges, like a vignette.</p>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label for="bg-scale" class="text-xs font-medium">Size</Label>
<span class="font-mono text-xs text-muted-foreground">{Math.round(getBackground().scale * 100)}%</span>
</div>
<Slider
id="bg-scale"
type="single"
min={0.4}
max={3}
step={0.05}
value={getBackground().scale}
onValueChange={setBackgroundScale}
disabled={noPattern}
/>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label for="bg-rotation" class="text-xs font-medium">Rotation</Label>
<span class="font-mono text-xs text-muted-foreground">{Math.round(getBackground().rotation)}°</span>
</div>
<Slider
id="bg-rotation"
type="single"
min={0}
max={359}
step={1}
value={getBackground().rotation}
onValueChange={setBackgroundRotation}
disabled={noPattern}
/>
</div>
</div>
{/if}
<div class="shrink-0 border-t px-3 py-1.5 text-right text-[11px] text-muted-foreground">
Oikos {VERSION}
</div>
</div>
<div class="shrink-0 border-t px-3 py-1.5 text-right text-[11px] text-muted-foreground">Oikos {VERSION}</div>
</div>

View File

@@ -71,24 +71,51 @@
return severityFilter === 'all' ? list : list.filter((s) => s.severity === severityFilter)
}
const open = $derived(bySeverity(signals.filter((s) => ['raised', 'acknowledged', 'acting'].includes(s.state))))
const open = $derived(
bySeverity(signals.filter((s) => ['raised', 'acknowledged', 'acting'].includes(s.state)))
)
const muted = $derived(bySeverity(signals.filter((s) => s.state === 'muted')))
const resolved = $derived(bySeverity(signals.filter((s) => ['resolved', 'failed'].includes(s.state))))
const resolved = $derived(
bySeverity(signals.filter((s) => ['resolved', 'failed'].includes(s.state)))
)
function makeColumns(showActions: boolean, actingVal: string | null): DataTableColumn<Signal>[] {
const base: DataTableColumn<Signal>[] = [
{ key: 'target', header: 'Target', class: 'font-mono text-xs', width: '180px', accessor: (s) => s.target ?? '—', truncate: true },
{
key: 'target',
header: 'Target',
class: 'font-mono text-xs',
width: '180px',
accessor: (s) => s.target ?? '—',
truncate: true
},
{ key: 'kind', header: 'Kind', width: '120px' },
{ key: 'severity', header: 'Severity', render: 'status-badge', renderProps: { kind: 'severity' }, width: '100px' },
{ key: 'state', header: 'State', render: 'status-badge', renderProps: { kind: 'state' }, width: '110px' },
{
key: 'severity',
header: 'Severity',
render: 'status-badge',
renderProps: { kind: 'severity' },
width: '100px'
},
{
key: 'state',
header: 'State',
render: 'status-badge',
renderProps: { kind: 'state' },
width: '110px'
},
{ key: 'occurrence_count', header: 'Occurrences', width: '100px', align: 'right' },
{ key: 'last_seen_at', header: 'Last seen', render: 'date', width: '170px' },
{ key: 'last_seen_at', header: 'Last seen', render: 'date', width: '170px' }
]
if (showActions) {
base.push({
key: '_actions', header: '', render: SignalActions,
key: '_actions',
header: '',
render: SignalActions,
renderProps: { acting: actingVal, onAck: ack, onMute: mute, onResolve: resolve },
align: 'right', headerClass: 'text-right', width: '220px'
align: 'right',
headerClass: 'text-right',
width: '220px'
})
}
return base