Hub stage 1: apps across servers, read-only
hub/apps/matrix reads apps/list and each app's config on every server (the read token first, the admin token once if a server refuses) and returns per app and server the version, available update and a config hash, plus whether versions and configs match and how many JSON leaf paths differ. Config texts never leave the hub. In hub mode the Apps section has the server selector: All servers shows the matrix with filters for differing versions, differing configs and updates; one server shows its list without write actions. The dev mock takes --variant a|b with differing apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0ef2d53a65
commit
3d8df08fa0
14 files changed
+1082
-37
No files matched your search
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { getAppsMatrix, type AppsMatrixData, type MatrixApp } from '../../api/apps'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { DataTable, type Col } from '../../ui/DataTable'
|
||||
import { Empty, Failure, Loading } from '../../ui/Empty'
|
||||
import { Panel } from '../../ui/Panel'
|
||||
import { Segmented } from '../../ui/Segmented'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { Tooltip } from '../../ui/Tooltip'
|
||||
import { head } from '../../ui/Table'
|
||||
|
||||
/*
|
||||
Behind a hub: one row per app, one column per server, and whether the configs agree.
|
||||
Read-only. The numbers come from `hub/apps/matrix`, which never sends a config, only its
|
||||
hash and the comparison, so nothing here can show a secret.
|
||||
*/
|
||||
|
||||
type Filter = 'all' | 'version' | 'config' | 'update'
|
||||
|
||||
const hasUpdate = (app: MatrixApp): boolean => Object.values(app.servers).some((c) => c.installed && c.updateAvailable === true)
|
||||
|
||||
const FILTERS: Record<Filter, (app: MatrixApp) => boolean> = {
|
||||
all: () => true,
|
||||
version: (app) => app.versions === 'different',
|
||||
config: (app) => app.configs === 'different',
|
||||
update: hasUpdate,
|
||||
}
|
||||
|
||||
/* Why a config's state is not known, from the cells and the servers that did not answer. */
|
||||
function unknownReason(app: MatrixApp, data: AppsMatrixData): string {
|
||||
const reasons = new Set<string>()
|
||||
for (const [id, cell] of Object.entries(app.servers)) {
|
||||
if (cell.installed && cell.configStatus === 'unreadable') reasons.add(`${id}: ${cell.configError ?? 'config not readable'}`)
|
||||
}
|
||||
for (const s of data.servers) if (!s.ok) reasons.add(`${s.id}: ${s.error ?? 'did not answer'}`)
|
||||
return [...reasons].join('; ') || 'The configs could not be compared.'
|
||||
}
|
||||
|
||||
function ConfigState({ app, data }: { app: MatrixApp; data: AppsMatrixData }) {
|
||||
switch (app.configs) {
|
||||
case 'same':
|
||||
return <Tag tone="ok">Same</Tag>
|
||||
case 'different':
|
||||
return <Tag tone="warn">{app.diffCount === undefined ? 'Different' : `Different · ${app.diffCount}`}</Tag>
|
||||
case 'single':
|
||||
return <Badge variant="outline">Single server</Badge>
|
||||
default:
|
||||
return (
|
||||
<Tooltip text={unknownReason(app, data)} placement="top">
|
||||
<Badge variant="outline" tabIndex={0}>
|
||||
Unknown
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function ServerCell({ app, id, data }: { app: MatrixApp; id: string; data: AppsMatrixData }) {
|
||||
const cell = app.servers[id]
|
||||
if (data.servers.find((s) => s.id === id)?.ok === false) return <span className="text-sm text-muted-foreground">Unknown</span>
|
||||
if (cell === undefined || !cell.installed) return <span className="text-sm text-muted-foreground">Not installed</span>
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>{cell.version === undefined ? 'Installed' : `v${cell.version}`}</span>
|
||||
{cell.updateAvailable === true && (
|
||||
<Tag tone="warn">{cell.updateVersion === undefined ? 'Update' : `Update v${cell.updateVersion}`}</Tag>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppsMatrix({ token }: { token: string | null }) {
|
||||
const [data, setData] = useState<AppsMatrixData | null>(null)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const outcome = await getAppsMatrix(token)
|
||||
if (outcome.kind === 'ok') {
|
||||
setData(outcome.data.response)
|
||||
setFailure(null)
|
||||
return
|
||||
}
|
||||
setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const apps = data?.apps ?? []
|
||||
return (Object.keys(FILTERS) as Filter[]).reduce(
|
||||
(acc, f) => ({ ...acc, [f]: apps.filter(FILTERS[f]).length }),
|
||||
{} as Record<Filter, number>,
|
||||
)
|
||||
}, [data])
|
||||
|
||||
const retry = (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setFailure(null)
|
||||
setData(null)
|
||||
void load()
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (failure !== null && data === null) {
|
||||
return (
|
||||
<Failure>
|
||||
<span className="flex flex-col items-center gap-3">
|
||||
<span>Could not load the apps of the servers. {failure}</span>
|
||||
{retry}
|
||||
</span>
|
||||
</Failure>
|
||||
)
|
||||
}
|
||||
if (data === null) return <Loading>Loading the apps of every server…</Loading>
|
||||
|
||||
if (data.apps.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
title="No apps installed"
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
None of the servers that answered has a DNS app installed. Install apps in a server's own console.
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
const rows = data.apps.filter(FILTERS[filter])
|
||||
const columns: Col<MatrixApp>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
...head({ children: 'App' }),
|
||||
cell: (a) => (
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">{a.name}</span>
|
||||
{a.description !== '' && <span className="text-xs text-muted-foreground">{a.description}</span>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...data.servers.map(
|
||||
(s): Col<MatrixApp> => ({
|
||||
id: `server-${s.id}`,
|
||||
...head({ children: s.id }),
|
||||
cell: (a) => <ServerCell app={a} id={s.id} data={data} />,
|
||||
}),
|
||||
),
|
||||
{ id: 'config', ...head({ children: 'Config' }), cell: (a) => <ConfigState app={a} data={data} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Segmented
|
||||
label="Filter apps"
|
||||
active={filter}
|
||||
onChoose={setFilter}
|
||||
options={[
|
||||
{ id: 'all', label: `All · ${counts.all}` },
|
||||
{ id: 'version', label: `Version differs · ${counts.version}` },
|
||||
{ id: 'config', label: `Config differs · ${counts.config}` },
|
||||
{ id: 'update', label: `Update available · ${counts.update}` },
|
||||
]}
|
||||
/>
|
||||
<Panel title="Apps by server" description="Installed version and whether the configs match">
|
||||
{rows.length === 0 ? (
|
||||
<Empty
|
||||
title="No apps match"
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => setFilter('all')}>
|
||||
Show all
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
No app fits this filter.
|
||||
</Empty>
|
||||
) : (
|
||||
<DataTable data={rows} columns={columns} getRowId={(a) => a.name} />
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user