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,23 @@
<script lang="ts">
import type { ActivityItem } from '$lib/api'
let { row }: { row: ActivityItem } = $props()
function fmtDuration(ms: number | null): string {
if (ms == null) return '—'
if (ms < 1000) return `${ms}ms`
const s = Math.round(ms / 1000)
if (s < 60) return `${s}s`
return `${Math.floor(s / 60)}m ${s % 60}s`
}
</script>
<div>
<div>{row.verb}</div>
{#if row.summary}
<div class="text-xs text-muted-foreground">{row.summary}</div>
{/if}
{#if row.error}
<div class="text-xs text-destructive">{row.error}</div>
{/if}
</div>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import type { ActivityItem } from '$lib/api'
let {
row,
onCancel
}: {
row: ActivityItem
onCancel?: (id: string) => void
} = $props()
function showCancel(status: string): boolean {
return ['pending_approval', 'approved', 'running'].includes(status)
}
</script>
<div class="flex justify-end">
{#if showCancel(row.status)}
<Button size="sm" variant="outline" onclick={() => onCancel?.(row.id)}>Cancel</Button>
{/if}
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import type { Approval } from '$lib/api'
let {
row,
deciding = null,
onApprove,
onDeny
}: {
row: Approval
deciding?: string | null
onApprove?: (id: string) => void
onDeny?: (id: string) => void
} = $props()
</script>
<div class="flex justify-end gap-2">
<Button size="sm" disabled={deciding === row.id} onclick={() => onApprove?.(row.id)}
>Approve</Button
>
<Button
size="sm"
variant="destructive"
disabled={deciding === row.id}
onclick={() => onDeny?.(row.id)}>Deny</Button
>
</div>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { Badge, type BadgeVariant } from '$lib/components/ui/badge'
let { value, variant = 'outline' as BadgeVariant }: { value: unknown; variant?: BadgeVariant } =
$props()
</script>
<Badge {variant}>{String(value ?? '—')}</Badge>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
let { value }: { value: unknown } = $props()
function format(val: unknown): string {
if (!val) return '—'
try {
return new Date(String(val)).toLocaleString()
} catch {
return String(val)
}
}
</script>
<span class="text-xs text-muted-foreground">{format(value)}</span>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { ActivityItem } from '$lib/api'
let { value }: { value: unknown } = $props()
function fmtDuration(ms: number | null): string {
if (ms == null) return '—'
if (ms < 1000) return `${ms}ms`
const s = Math.round(ms / 1000)
if (s < 60) return `${s}s`
return `${Math.floor(s / 60)}m ${s % 60}s`
}
</script>
<span class="text-xs text-muted-foreground">{fmtDuration(value as number | null)}</span>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { relativeTime } from '$lib/utils'
import type { Entity } from '$lib/api'
let { row, value }: { row: Entity; value: unknown } = $props()
const dot: Record<string, string> = {
healthy: 'bg-success',
degraded: 'bg-warning',
down: 'bg-destructive',
stale: 'bg-warning/50',
unknown: 'bg-muted-foreground/40'
}
const health = $derived(row.health)
const lastCheck = $derived(row.last_check_at)
const title = $derived.by(() => {
if (!row.health) return 'not monitored'
if (row.health === 'stale') return `stale — last checked ${relativeTime(row.last_check_at)}`
return `${row.health} — checked ${relativeTime(row.last_check_at)}`
})
</script>
{#if health}
<span class="flex items-center gap-1.5 text-xs" {title}>
<span class="size-2 shrink-0 rounded-full {dot[row.health ?? ''] ?? ''}"></span>
<span class="text-muted-foreground">{relativeTime(lastCheck)}</span>
</span>
{:else}
<span class="text-xs text-muted-foreground"></span>
{/if}

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { relativeTime } from '$lib/utils'
let { value }: { value: unknown } = $props()
</script>
<span class="text-xs text-muted-foreground">{relativeTime(String(value ?? ''))}</span>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import type { Signal } from '$lib/api'
let {
row,
acting = null,
onAck,
onMute,
onResolve
}: {
row: Signal
acting?: string | null
onAck?: (id: string) => void
onMute?: (id: string) => void
onResolve?: (id: string) => void
} = $props()
</script>
<div class="flex justify-end gap-2">
{#if row.state === 'raised'}
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onAck?.(row.id)}
>Ack</Button
>
{/if}
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onMute?.(row.id)}
>Mute 1h</Button
>
<Button size="sm" disabled={acting === row.id} onclick={() => onResolve?.(row.id)}>Resolve</Button
>
</div>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge'
let {
value,
kind = 'default'
}: { value: unknown; kind?: 'risk' | 'severity' | 'execution' | 'state' | 'type' | 'default' } =
$props()
const v = $derived(String(value ?? ''))
const variantMap: Record<
string,
Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>
> = {
risk: {
destructive: 'destructive',
config_mutation: 'secondary',
default: 'default'
},
severity: {
critical: 'destructive',
warning: 'secondary',
info: 'default',
default: 'default'
},
execution: {
failed: 'destructive',
denied: 'destructive',
revoked: 'destructive',
cancelled: 'destructive',
completed: 'default',
running: 'secondary',
approved: 'secondary',
default: 'outline'
},
state: {
active: 'default',
healthy: 'default',
default: 'outline'
},
type: {
runbook: 'secondary',
investigation: 'default',
default: 'outline'
},
default: { default: 'default' }
}
const variant = $derived.by(() => {
const map = variantMap[kind] ?? variantMap.default
return (map[v] ?? map.default) as 'default' | 'secondary' | 'destructive' | 'outline'
})
</script>
<Badge {variant}>{v}</Badge>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { statusStyle } from '$lib/tasks'
import type { Session } from '$lib/api'
let { row }: { row: Session } = $props()
const st = $derived(statusStyle(row))
</script>
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
{st.label}
</span>