Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability. Store URLs are resolved from each server's own listing. The config is saved to history before an uninstall. Zip uploads are fanned out to the servers. Every target's result is audited. The console gets a row menu on the matrix, a target picker, a results dialog with Retry failed, the store and install from file in All servers mode, and single-server actions for writers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { getAppsMatrix, listStoreApps, runHubApps, type AppsMatrixData, type HubRunAction, 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 { Menu } from '../../ui/Menu'
|
|
import { Tag } from '../../ui/Tag'
|
|
import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
|
|
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'
|
|
|
|
/* 9.10 is newer than 9.9: compared part by part, numerically. */
|
|
export function compareVersions(a: string, b: string): number {
|
|
const [x, y] = [a, b].map((v) => v.split('.').map((p) => Number.parseInt(p, 10) || 0))
|
|
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
const d = (x[i] ?? 0) - (y[i] ?? 0)
|
|
if (d !== 0) return d
|
|
}
|
|
return 0
|
|
}
|
|
|
|
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, canWrite = false, refresh = 0 }: { token: string | null; canWrite?: boolean; refresh?: number }) {
|
|
const [data, setData] = useState<AppsMatrixData | null>(null)
|
|
const [request, setRequest] = useState<RunRequest | null>(null)
|
|
/** What the store offers, by app: needed to know whether "Align versions" can be done. */
|
|
const [store, setStore] = useState<Record<string, string> | 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, refresh])
|
|
|
|
useEffect(() => {
|
|
if (!canWrite) return
|
|
void listStoreApps(token).then((outcome) => {
|
|
if (outcome.kind === 'ok') setStore(Object.fromEntries(outcome.data.response.storeApps.map((s) => [s.name, s.version])))
|
|
})
|
|
}, [canWrite, token])
|
|
|
|
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])
|
|
|
|
/* The change menu of one app. Each item opens the server picker with the servers it makes sense on. */
|
|
function actions(app: MatrixApp, d: AppsMatrixData) {
|
|
const has = (id: string) => app.servers[id]?.installed === true
|
|
const installed = d.servers.filter((s) => has(s.id)).map((s) => s.id)
|
|
const outdated = installed.filter((id) => app.servers[id].updateAvailable === true)
|
|
const missing = d.servers.filter((s) => s.ok && !has(s.id)).map((s) => s.id)
|
|
const versions = installed.map((id) => app.servers[id].version ?? '0')
|
|
const newest = versions.reduce((best, v) => (compareVersions(v, best) > 0 ? v : best), versions[0] ?? '0')
|
|
const behind = installed.filter((id) => compareVersions(app.servers[id].version ?? '0', newest) < 0)
|
|
const canAlign = behind.length > 0 && store?.[app.name] === newest
|
|
|
|
const open = (
|
|
action: HubRunAction,
|
|
title: string,
|
|
confirmLabel: string,
|
|
preselected: string[],
|
|
allowed: string[],
|
|
why: string,
|
|
intro?: string,
|
|
) =>
|
|
setRequest({
|
|
title,
|
|
intro,
|
|
confirmLabel,
|
|
servers: pickerServers(d.servers, Object.fromEntries(d.servers.filter((s) => !allowed.includes(s.id)).map((s) => [s.id, why]))),
|
|
preselected,
|
|
run: (targets) => runHubApps(token, action, app.name, targets),
|
|
})
|
|
|
|
return (
|
|
<Menu label={`Actions: ${app.name}`}>
|
|
{(close) => (
|
|
<>
|
|
<Button size="sm" disabled={outdated.length === 0} onClick={() => { close(); open('update-store', `Update ${app.name}`, 'Update', outdated, installed, 'Not installed') }}>
|
|
Update on all
|
|
</Button>
|
|
<Button size="sm" disabled={missing.length === 0} onClick={() => { close(); open('install-store', `Install ${app.name}`, 'Install', missing, missing, 'Already installed') }}>
|
|
Install on missing servers
|
|
</Button>
|
|
<Button size="sm" disabled={!canAlign} onClick={() => { close(); open('update-store', `Align ${app.name} to v${newest}`, 'Update', behind, behind, 'Already at the newest version') }}>
|
|
Align versions
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="danger"
|
|
disabled={installed.length === 0}
|
|
onClick={() => {
|
|
close()
|
|
open('uninstall', `Uninstall ${app.name}`, 'Uninstall', [], installed, 'Not installed', "Each server's config of this app is saved to the hub's history before it is uninstalled.")
|
|
}}
|
|
>
|
|
Uninstall from…
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Menu>
|
|
)
|
|
}
|
|
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} /> },
|
|
...(canWrite ? [{ id: 'actions', ...head({ children: <span className="sr-only">Actions</span> }), cell: (a: MatrixApp) => actions(a, 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>
|
|
{request !== null && (
|
|
<RunFlow
|
|
request={request}
|
|
onClose={(changed) => {
|
|
setRequest(null)
|
|
if (changed) void load()
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|