Problem: the frontend had an implicit OS+Apps metaphor (desktop, floating windows, an app registry) but the contract was informal — the mascot was hardcoded into the shell, all apps were statically imported into one 800KB bundle, and there was no install/uninstall path. Change: three phases landed. - Phase 1 (contract + docked kind): AppDef extended with docked/noIcon and optional geometry; the mascot registered as a docked app via a generic DockedLayer that replaces the hardcoded <MascotLayer />; openAppWindow branches on docked → toggleDocked; persisted docked visibility store (absent key = visible, no APPS import to avoid a static cycle). - Phase 2 (lazy loading): AppDef.component is now a dynamic-import loader; LazyApp renders with a loading skeleton; Vite code-splits each app (main bundle 800KB→485KB); the LazyMascot wrapper is gone since the lazy loader breaks the import cycle directly. - Phase 3 (installable apps, local bundles): AppManifest + catalog + installApp/uninstallApp + localStorage persistence; reactive apps store (built-in + installed) and derived appById; App Store page; Notes demo app; icons.ts and WindowLayer's orphan-close react to registration so installs appear without a reload. - Structure: data-table casing unified to PascalCase; the mislabeled DataTable.svelte.ts (pure types, not runes) renamed to types.ts; LazyApp colocated with its desktop-shell consumers; app-store moved under lib/ so the dependency direction is consistent. Risk: the app registry is now a reactive store, not a static array, so every consumer (Desktop, DockedLayer, Taskbar, icons, windows) reads from derived stores. Two static-cycle traps are documented in docs/mbse/components.md §9: docked.ts must not import APPS (it would fire a TDZ at init via the apps.ts→pages→windows.ts→here path), and apps.ts must not statically import the mascot (the lazy loader defers its module graph). Remote bundle loading, the /api/v1/apps endpoint, and permission enforcement are deliberately NOT in this commit — they are security-critical and deferred to Phase 4 with an ADR. Verification: vitest 38/38; svelte-check + tsc clean for changed files; eslint clean; vite build green; runtime smoke confirmed (install Notes → icon appears → open → uninstall → icon + window gone; survives reload). docs/mbse/components.md Component 9 and the plan updated. Plan: plans/2026-07-21-frontend-os-apps-architecture.md
136 lines
4.9 KiB
Svelte
136 lines
4.9 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte'
|
|
import { fetchSignals, ackSignal, resolveSignal, muteSignal, type Signal } from '$lib/api'
|
|
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
|
import * as Tabs from '$lib/components/ui/tabs'
|
|
import { Badge } from '$lib/components/ui/badge'
|
|
import * as Select from '$lib/components/ui/select'
|
|
import { toast } from 'svelte-sonner'
|
|
import DataTable from '$lib/components/data-table/DataTable.svelte'
|
|
import type { DataTableColumn } from '$lib/components/data-table/types'
|
|
import SignalActions from '$lib/components/data-table/renderers/SignalActions.svelte'
|
|
|
|
let signals = $state<Signal[]>([])
|
|
let severityFilter = $state('all')
|
|
let acting = $state<string | null>(null)
|
|
|
|
async function load() {
|
|
signals = await fetchSignals()
|
|
}
|
|
|
|
onMount(() => {
|
|
load()
|
|
const unsubscribe = subscribeEvents()
|
|
return unsubscribe
|
|
})
|
|
|
|
$effect(() => {
|
|
const ev = $liveEvents[0]
|
|
if (!ev || !ev.type.startsWith('signal.')) return
|
|
load()
|
|
})
|
|
|
|
async function ack(id: string) {
|
|
acting = id
|
|
const result = await ackSignal(id)
|
|
acting = null
|
|
if (result) {
|
|
toast.success('Signal acknowledged')
|
|
load()
|
|
} else {
|
|
toast.error('Acknowledge failed')
|
|
}
|
|
}
|
|
|
|
async function resolve(id: string) {
|
|
acting = id
|
|
const result = await resolveSignal(id)
|
|
acting = null
|
|
if (result) {
|
|
toast.success('Signal resolved')
|
|
load()
|
|
} else {
|
|
toast.error('Resolve failed')
|
|
}
|
|
}
|
|
|
|
async function mute(id: string) {
|
|
acting = id
|
|
const muteUntil = new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
|
const result = await muteSignal(id, muteUntil)
|
|
acting = null
|
|
if (result) {
|
|
toast.success('Signal muted for 1h')
|
|
load()
|
|
} else {
|
|
toast.error('Mute failed')
|
|
}
|
|
}
|
|
|
|
function bySeverity(list: Signal[]) {
|
|
return severityFilter === 'all' ? list : list.filter((s) => s.severity === severityFilter)
|
|
}
|
|
|
|
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))))
|
|
|
|
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: '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: 'occurrence_count', header: 'Occurrences', width: '100px', align: 'right' },
|
|
{ key: 'last_seen_at', header: 'Last seen', render: 'date', width: '170px' },
|
|
]
|
|
if (showActions) {
|
|
base.push({
|
|
key: '_actions', header: '', render: SignalActions,
|
|
renderProps: { acting: actingVal, onAck: ack, onMute: mute, onResolve: resolve },
|
|
align: 'right', headerClass: 'text-right', width: '220px'
|
|
})
|
|
}
|
|
return base
|
|
}
|
|
|
|
const columnsWithActions = $derived(makeColumns(true, acting))
|
|
const columnsWithoutActions = makeColumns(false, null)
|
|
</script>
|
|
|
|
<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}>
|
|
<Select.Trigger class="w-40">
|
|
{severityFilter === 'all' ? 'All severities' : severityFilter}
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
<Select.Item value="all">All severities</Select.Item>
|
|
<Select.Item value="critical">Critical</Select.Item>
|
|
<Select.Item value="warning">Warning</Select.Item>
|
|
<Select.Item value="info">Info</Select.Item>
|
|
</Select.Content>
|
|
</Select.Root>
|
|
</div>
|
|
|
|
<Tabs.Root value="open" class="flex flex-1 flex-col overflow-hidden">
|
|
<Tabs.List>
|
|
<Tabs.Trigger value="open">
|
|
Open {#if open.length}<Badge variant="destructive" class="ml-1">{open.length}</Badge>{/if}
|
|
</Tabs.Trigger>
|
|
<Tabs.Trigger value="muted">Muted</Tabs.Trigger>
|
|
<Tabs.Trigger value="resolved">Resolved</Tabs.Trigger>
|
|
</Tabs.List>
|
|
<Tabs.Content value="open" class="flex-1 overflow-auto">
|
|
<DataTable columns={columnsWithActions} data={open} />
|
|
</Tabs.Content>
|
|
<Tabs.Content value="muted" class="flex-1 overflow-auto">
|
|
<DataTable columns={columnsWithActions} data={muted} />
|
|
</Tabs.Content>
|
|
<Tabs.Content value="resolved" class="flex-1 overflow-auto">
|
|
<DataTable columns={columnsWithoutActions} data={resolved} />
|
|
</Tabs.Content>
|
|
</Tabs.Root>
|
|
</div>
|