.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>
259 lines
8.7 KiB
Svelte
259 lines
8.7 KiB
Svelte
<script lang="ts">
|
|
import type { Entity } from '$lib/api'
|
|
import * as Table from '$lib/components/ui/table'
|
|
import { Badge } from '$lib/components/ui/badge'
|
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
|
import SortHeader from '$lib/components/data-table/SortHeader.svelte'
|
|
import EmptyState from '$lib/components/EmptyState.svelte'
|
|
import HealthDotRenderer from '$lib/components/data-table/renderers/HealthDotRenderer.svelte'
|
|
import StatusBadgeRenderer from '$lib/components/data-table/renderers/StatusBadgeRenderer.svelte'
|
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
|
|
|
let {
|
|
entities,
|
|
loading,
|
|
selectedSlug = null,
|
|
onSelect,
|
|
childToParent = null
|
|
}: {
|
|
entities: Entity[]
|
|
loading: boolean
|
|
selectedSlug?: string | null
|
|
onSelect: (slug: string) => void
|
|
childToParent?: Map<string, string> | null
|
|
} = $props()
|
|
|
|
type SortKey = 'slug' | 'type' | 'name' | 'state' | 'health'
|
|
let sortKey = $state<SortKey>('slug')
|
|
let sortDir = $state<'asc' | 'desc'>('asc')
|
|
let collapsedNodes = $state<Set<string>>(new Set())
|
|
|
|
function toggleNode(slug: string, e: Event) {
|
|
e.stopPropagation()
|
|
const next = new Set(collapsedNodes)
|
|
if (next.has(slug)) next.delete(slug)
|
|
else next.add(slug)
|
|
collapsedNodes = next
|
|
}
|
|
|
|
function sortBy(key: SortKey) {
|
|
if (sortKey === key) {
|
|
sortDir = sortDir === 'asc' ? 'desc' : 'asc'
|
|
} else {
|
|
sortKey = key
|
|
sortDir = 'asc'
|
|
}
|
|
}
|
|
|
|
function getSortState(key: SortKey) {
|
|
if (sortKey !== key) return { sorted: false, direction: 'asc' as const }
|
|
return { sorted: true, direction: sortDir }
|
|
}
|
|
|
|
const healthRank: Record<string, number> = {
|
|
down: 0,
|
|
degraded: 1,
|
|
stale: 2,
|
|
unknown: 3,
|
|
healthy: 4
|
|
}
|
|
|
|
function sortValue(entity: Entity, key: SortKey): string | number {
|
|
if (key === 'health') return entity.health ? (healthRank[entity.health] ?? -1) : -1
|
|
return (entity[key as keyof Entity] ?? '').toString().toLowerCase()
|
|
}
|
|
|
|
const sortedEntities = $derived.by(() => {
|
|
const sorted = [...entities].sort((a, b) => {
|
|
const av = sortValue(a, sortKey)
|
|
const bv = sortValue(b, sortKey)
|
|
if (av < bv) return -1
|
|
if (av > bv) return 1
|
|
return 0
|
|
})
|
|
if (sortDir === 'desc') sorted.reverse()
|
|
return sorted
|
|
})
|
|
|
|
const childrenByParent = $derived.by(() => {
|
|
const map = new Map<string, Entity[]>()
|
|
if (!childToParent) return map
|
|
const visibleSlugs = new Set(entities.map((e) => e.slug))
|
|
for (const e of sortedEntities) {
|
|
const parentSlug = childToParent.get(e.slug)
|
|
if (parentSlug && visibleSlugs.has(parentSlug)) {
|
|
if (!map.has(parentSlug)) map.set(parentSlug, [])
|
|
map.get(parentSlug)!.push(e)
|
|
}
|
|
}
|
|
return map
|
|
})
|
|
|
|
const nestedSlugs = $derived.by(() => {
|
|
const set = new Set<string>()
|
|
for (const children of childrenByParent.values()) for (const c of children) set.add(c.slug)
|
|
return set
|
|
})
|
|
|
|
const topLevelEntities = $derived.by(() =>
|
|
childToParent ? sortedEntities.filter((e) => !nestedSlugs.has(e.slug)) : sortedEntities
|
|
)
|
|
|
|
const skeletonSlugWidths = ['w-24', 'w-20', 'w-28', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']
|
|
const skeletonNameWidths = ['w-32', 'w-40', 'w-24', 'w-36', 'w-28', 'w-40', 'w-24', 'w-32']
|
|
</script>
|
|
|
|
{#if loading}
|
|
<div class="h-full min-h-0 overflow-hidden rounded-md border">
|
|
<Table.Root>
|
|
<Table.Header>
|
|
<Table.Row>
|
|
<Table.Head>Slug</Table.Head>
|
|
<Table.Head>Type</Table.Head>
|
|
<Table.Head>Name</Table.Head>
|
|
<Table.Head>State</Table.Head>
|
|
<Table.Head>Health</Table.Head>
|
|
</Table.Row>
|
|
</Table.Header>
|
|
<Table.Body>
|
|
{#each skeletonSlugWidths as slugWidth, i}
|
|
<Table.Row class="hover:bg-transparent">
|
|
<Table.Cell><Skeleton class="h-4 {slugWidth}" /></Table.Cell>
|
|
<Table.Cell><Skeleton class="h-5 w-16 rounded-full" /></Table.Cell>
|
|
<Table.Cell><Skeleton class="h-4 {skeletonNameWidths[i]}" /></Table.Cell>
|
|
<Table.Cell><Skeleton class="h-5 w-14 rounded-full" /></Table.Cell>
|
|
<Table.Cell>
|
|
<div class="flex items-center gap-1.5">
|
|
<Skeleton class="size-2 shrink-0 rounded-full" />
|
|
<Skeleton class="h-4 w-12" />
|
|
</div>
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
{/each}
|
|
</Table.Body>
|
|
</Table.Root>
|
|
</div>
|
|
{:else}
|
|
{#snippet row(entity: Entity, level: number, ancestors: Set<string>)}
|
|
{@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)}
|
|
{@const children = (childrenByParent.get(entity.slug) ?? []).filter(
|
|
(c) => !ancestorsWithSelf.has(c.slug)
|
|
)}
|
|
<Table.Row
|
|
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
|
|
role="row"
|
|
aria-level={level}
|
|
aria-expanded={children.length > 0 ? !collapsedNodes.has(entity.slug) : undefined}
|
|
tabindex={0}
|
|
onclick={() => onSelect(entity.slug)}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
onSelect(entity.slug)
|
|
}
|
|
}}
|
|
>
|
|
<Table.Cell class="font-mono text-xs">
|
|
<span class="flex items-center gap-1" style="padding-left: {(level - 1) * 1.25}rem">
|
|
<span class="inline-flex size-3.5 shrink-0 items-center justify-center">
|
|
{#if children.length > 0}
|
|
<button
|
|
type="button"
|
|
class="rounded text-muted-foreground hover:text-foreground"
|
|
onclick={(e) => toggleNode(entity.slug, e)}
|
|
aria-label={collapsedNodes.has(entity.slug)
|
|
? `Expand ${entity.slug}`
|
|
: `Collapse ${entity.slug}`}
|
|
>
|
|
{#if collapsedNodes.has(entity.slug)}
|
|
<ChevronRightIcon class="size-3.5" />
|
|
{:else}
|
|
<ChevronDownIcon class="size-3.5" />
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
</span>
|
|
{entity.slug}
|
|
{#if children.length > 0}
|
|
<span class="text-muted-foreground">({children.length})</span>
|
|
{/if}
|
|
</span>
|
|
</Table.Cell>
|
|
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
|
<Table.Cell>{entity.name}</Table.Cell>
|
|
<Table.Cell>
|
|
<StatusBadgeRenderer value={entity.state ?? ''} kind="state" />
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<HealthDotRenderer row={entity} value={null} />
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
{#if children.length > 0 && !collapsedNodes.has(entity.slug)}
|
|
{#each children as child (child.id)}
|
|
{@render row(child, level + 1, ancestorsWithSelf)}
|
|
{/each}
|
|
{/if}
|
|
{/snippet}
|
|
<div class="h-full min-h-0 overflow-auto rounded-md border">
|
|
<Table.Root role={childToParent ? 'treegrid' : undefined}>
|
|
<Table.Header>
|
|
<Table.Row>
|
|
{@const ssSlug = getSortState('slug')}
|
|
<Table.Head>
|
|
<SortHeader
|
|
label="Slug"
|
|
sorted={ssSlug.sorted}
|
|
direction={ssSlug.direction}
|
|
onclick={() => sortBy('slug')}
|
|
/>
|
|
</Table.Head>
|
|
{@const ssType = getSortState('type')}
|
|
<Table.Head>
|
|
<SortHeader
|
|
label="Type"
|
|
sorted={ssType.sorted}
|
|
direction={ssType.direction}
|
|
onclick={() => sortBy('type')}
|
|
/>
|
|
</Table.Head>
|
|
{@const ssName = getSortState('name')}
|
|
<Table.Head>
|
|
<SortHeader
|
|
label="Name"
|
|
sorted={ssName.sorted}
|
|
direction={ssName.direction}
|
|
onclick={() => sortBy('name')}
|
|
/>
|
|
</Table.Head>
|
|
{@const ssState = getSortState('state')}
|
|
<Table.Head>
|
|
<SortHeader
|
|
label="State"
|
|
sorted={ssState.sorted}
|
|
direction={ssState.direction}
|
|
onclick={() => sortBy('state')}
|
|
/>
|
|
</Table.Head>
|
|
{@const ssHealth = getSortState('health')}
|
|
<Table.Head>
|
|
<SortHeader
|
|
label="Health"
|
|
sorted={ssHealth.sorted}
|
|
direction={ssHealth.direction}
|
|
onclick={() => sortBy('health')}
|
|
/>
|
|
</Table.Head>
|
|
</Table.Row>
|
|
</Table.Header>
|
|
<Table.Body>
|
|
{#each topLevelEntities as entity (entity.id)}
|
|
{@render row(entity, 1, new Set())}
|
|
{:else}
|
|
<EmptyState message="No entities in this layer match the filter." colspan={5} />
|
|
{/each}
|
|
</Table.Body>
|
|
</Table.Root>
|
|
</div>
|
|
{/if}
|