feat: redesign toward shadcn-svelte dashboard-01 (inset sidebar, gradient stat cards)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Problem: requested visual alignment with the shadcn-svelte dashboard-01
reference block (shadcn-svelte.com/blocks#dashboard-01) — the app's
sidebar/header shell and Overview stat cards looked plain by comparison.

Change: pulled the actual reference source (app-sidebar.svelte,
nav-main.svelte, site-header.svelte, section-cards.svelte from
huntabyte/shadcn-svelte) rather than approximating from screenshots.

- App.svelte: Sidebar.Root now uses variant="inset" (the floating,
  rounded, shadowed content panel — already fully built into the
  existing Sidebar.Inset component via peer-data selectors, just never
  enabled). Brand mark is now a proper Sidebar.MenuButton matching the
  reference's padding/hover treatment; "New chat" uses the reference's
  primary-colored button styling. Header matches the reference exactly:
  h-(--header-height) (48px, was 44px), vertical separator after the
  sidebar trigger, right-aligned actions group.
- Overview.svelte: stat cards rebuilt to match section-cards.svelte —
  gradient background, Card.Action badge, Card.Footer with a bold line
  + muted context line, tabular-nums, responsive @container grid
  (1/2/4 columns). Deliberately did NOT copy the reference's fake
  trend-percentage badges (Oikos doesn't track historical trends, and
  this project's whole thrust has been eliminating dishonest UI state —
  see 279549c). Badges instead reflect real current-state signals
  (healthy/degraded/down, clear/needs-review) computed from the actual
  dashboard summary.
- EntityDetailContent.svelte + Entities/Signals/Ops/Events/Agent/
  Audit/Knowledge pages: normalized root padding to p-4 md:p-6 (was a
  flat p-6) to match the reference's responsive py-4 md:py-6 convention.

Risk: reversible_low (UI-only, no data or behavior changes).

Verification: npx tsc --noEmit clean (excluding pre-existing unrelated
.svelte type-resolution warnings, same as prior commits). go build/vet
clean (backend untouched, sanity check only). Manually verified in the
browser preview at 1400px: inset sidebar's margin/rounded-corner/shadow
classes confirmed applied via computed styles; Overview cards render
with real live numbers from the now-fixed dashboard summary endpoint;
Signals/Ops pages confirmed visually consistent with the new spacing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 00:54:51 +02:00
parent 851b5dce67
commit cbfd09c5df
10 changed files with 153 additions and 55 deletions

View File

@@ -19,6 +19,7 @@
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 MessageSquareIcon from '@lucide/svelte/icons/message-square'
@@ -76,18 +77,34 @@
<Toaster />
<Sidebar.Provider>
<Sidebar.Root collapsible="icon">
<Sidebar.Provider style="--header-height: calc(var(--spacing) * 12);">
<Sidebar.Root collapsible="icon" variant="inset">
<Sidebar.Header>
<div class="flex items-center px-1">
<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>
</div>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton onclick={() => { newChat(); navigate('chat') }} tooltipContent="New chat">
<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 chat"
>
{#snippet child({ props })}
<button {...props}>
<PlusIcon />
@@ -144,10 +161,11 @@
</Sidebar.Root>
<Sidebar.Inset class="h-svh min-h-0">
<header class="flex h-11 shrink-0 items-center gap-3 border-b px-3">
<Sidebar.Trigger />
<span class="text-sm font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
<div class="flex-1"></div>
<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" />
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
<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>
@@ -169,6 +187,7 @@
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'}

View File

@@ -124,7 +124,7 @@
}
</script>
<div class="@container flex h-full flex-col gap-4 overflow-y-auto p-6">
<div class="@container flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
{#if loading}
<Skeleton class="h-8 w-48" />
<div class="grid grid-cols-1 gap-4 @lg:grid-cols-2">

View File

@@ -49,7 +49,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Agent activity</h1>
<span class="text-xs text-muted-foreground">{activities.length} entries</span>

View File

@@ -49,7 +49,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Audit trail</h1>
<span class="text-xs text-muted-foreground">{entries.length} entries</span>

View File

@@ -73,7 +73,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Entities</h1>
<span class="text-xs text-muted-foreground">{filtered.length} of {entities.length}</span>

View File

@@ -80,7 +80,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Live event feed</h1>
<span class="text-xs text-muted-foreground">

View File

@@ -27,7 +27,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<h1 class="text-lg font-semibold">Knowledge search</h1>
<form

View File

@@ -79,7 +79,7 @@
const decidedApprovals = $derived(approvals.filter((a) => a.status !== 'pending'))
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<h1 class="text-lg font-semibold">Operations ledger</h1>
<Tabs.Root value="approvals" class="flex flex-1 flex-col overflow-hidden">

View File

@@ -6,6 +6,9 @@
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'
import OctagonXIcon from '@lucide/svelte/icons/octagon-x'
let summary = $state<DashboardSummary | null>(null)
let loading = $state(true)
@@ -47,9 +50,44 @@
function formatEventLabel(ev: OikosEvent) {
return ev.type
}
const totalEntities = $derived(
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
)
const topTypes = $derived(
summary
? Object.entries(summary.entities_by_type)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
: []
)
const entityTypeCount = $derived(summary ? Object.keys(summary.entities_by_type).length : 0)
const totalMonitored = $derived(
summary
? summary.health.healthy + summary.health.degraded + summary.health.down + summary.health.unknown
: 0
)
const healthTone = $derived(
!summary ? 'ok' : summary.health.down > 0 ? 'down' : summary.health.degraded > 0 ? 'degraded' : 'ok'
)
const totalSignals = $derived(
summary ? Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0) : 0
)
const worstSeverity = $derived(
summary?.signals_by_severity.critical
? 'critical'
: summary?.signals_by_severity.warning
? 'warning'
: 'none'
)
const executionsRunning = $derived(summary?.executions_by_state.running ?? 0)
const executionsFailed = $derived(summary?.executions_by_state.failed ?? 0)
</script>
<div class="flex h-full flex-col gap-4 overflow-y-auto p-6">
<div class="@container/main flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
<h1 class="text-lg font-semibold">Overview</h1>
{#if loading}
@@ -59,58 +97,99 @@
{/each}
</div>
{:else if summary}
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card.Root>
<div
class="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs @xl/main:grid-cols-2 @5xl/main:grid-cols-4"
>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Entities</Card.Description>
<Card.Title class="text-2xl">
{Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0)}
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{totalEntities}
</Card.Title>
<Card.Action>
<Badge variant="outline">{entityTypeCount} types</Badge>
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs text-muted-foreground">
{#each Object.entries(summary.entities_by_type) as [type, count]}
<Badge variant="outline">{type}: {count}</Badge>
{/each}
</Card.Content>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
{#each topTypes as [type, count]}
<span class="text-muted-foreground">{type}: <span class="text-foreground">{count}</span></span>
{/each}
</div>
<div class="text-muted-foreground">Across the fleet</div>
</Card.Footer>
</Card.Root>
<Card.Root>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Health</Card.Description>
<Card.Title class="text-2xl">{summary.health.healthy} healthy</Card.Title>
<Card.Description>Fleet health</Card.Description>
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{summary.health.healthy} / {totalMonitored}
</Card.Title>
<Card.Action>
{#if healthTone === 'ok'}
<Badge variant="outline"><CircleCheckIcon class="text-success" />healthy</Badge>
{:else if healthTone === 'degraded'}
<Badge variant="outline"><TriangleAlertIcon class="text-warning" />degraded</Badge>
{:else}
<Badge variant="outline"><OctagonXIcon class="text-destructive" />down</Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
<Badge>healthy: {summary.health.healthy}</Badge>
<Badge variant="secondary">degraded: {summary.health.degraded}</Badge>
<Badge variant="destructive">down: {summary.health.down}</Badge>
<Badge variant="outline">unknown: {summary.health.unknown}</Badge>
</Card.Content>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex gap-2 font-medium">
{summary.health.degraded} degraded · {summary.health.down} down · {summary.health.unknown} unmonitored
</div>
<div class="text-muted-foreground">Healthy entities as observed by the scheduler</div>
</Card.Footer>
</Card.Root>
<Card.Root>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Open signals</Card.Description>
<Card.Title class="text-2xl">
{Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0)}
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{totalSignals}
</Card.Title>
<Card.Action>
{#if worstSeverity === 'critical'}
<Badge variant="destructive"><TriangleAlertIcon />critical</Badge>
{:else if worstSeverity === 'warning'}
<Badge variant="secondary"><TriangleAlertIcon />warning</Badge>
{:else}
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
<Badge variant={severityVariant(severity)}>{severity}: {count}</Badge>
{/each}
</Card.Content>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
<span class="text-muted-foreground">{severity}: <span class="text-foreground">{count}</span></span>
{/each}
</div>
<div class="text-muted-foreground">Unresolved right now</div>
</Card.Footer>
</Card.Root>
<Card.Root>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Pending approvals</Card.Description>
<Card.Title class="text-2xl">{summary.approvals_pending}</Card.Title>
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{summary.approvals_pending}
</Card.Title>
<Card.Action>
{#if summary.approvals_pending > 0}
<Badge variant="destructive">needs review</Badge>
{:else}
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
{#each Object.entries(summary.executions_by_state) as [state, count]}
<Badge variant="outline">{state}: {count}</Badge>
{/each}
</Card.Content>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex gap-2 font-medium">
{executionsRunning} running · {executionsFailed} failed
</div>
<div class="text-muted-foreground">Executions in the last 24h</div>
</Card.Footer>
</Card.Root>
</div>

View File

@@ -134,7 +134,7 @@
</div>
{/snippet}
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Signals</h1>
<Select.Root type="single" bind:value={severityFilter}>