feat: redesign toward shadcn-svelte dashboard-01 (inset sidebar, gradient stat cards)
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:
@@ -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'}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user