- Every section puts the hub's server selector in the chrome slot, the same place and gap as the Dashboard. - Data regions load with a Skeleton and fail inline with Retry; KPIs show the no-data dash instead of 0 when a server did not answer. Settings keeps its header and selector while loading or failing. - Cache, Apps and Logs headers group secondary or hub-refused actions in menus; app descriptions clamp to two lines; Unknown cells say why. - DNS Client: Protocol label, Resolve and Import together. DHCP and Query Logs empty states with actions. View Logs list capped with scroll. - Blocking: Lists KPI tiles like the Overview, app list table fits its panel with one-line URLs. Status re-reads on tab return at most once a minute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { getAppsMatrix, getDrift, 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, FailureRetry, LoadingRegion } 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 { AppConfigSync } from './AppConfigSync'
|
|
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, onOpen }: { app: MatrixApp; data: AppsMatrixData; onOpen: () => void }) {
|
|
switch (app.configs) {
|
|
case 'same':
|
|
return <Tag tone="ok">Same</Tag>
|
|
case 'different':
|
|
return (
|
|
<button type="button" className="cursor-pointer border-0 bg-transparent p-0" aria-label={`Compare the configs of ${app.name}`} onClick={onOpen}>
|
|
<Tag tone="warn">{app.diffCount === undefined ? 'Different' : `Different · ${app.diffCount}`}</Tag>
|
|
</button>
|
|
)
|
|
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, drifted }: { app: MatrixApp; id: string; data: AppsMatrixData; drifted: boolean }) {
|
|
const cell = app.servers[id]
|
|
if (data.servers.find((s) => s.id === id)?.ok === false) {
|
|
return (
|
|
<Tooltip text={`${id} did not answer`} placement="top">
|
|
<span className="text-sm text-muted-foreground" tabIndex={0}>
|
|
Unknown
|
|
</span>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
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>
|
|
{drifted && <Tag tone="warn">Drift</Tag>}
|
|
{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)
|
|
const [configApp, setConfigApp] = useState<string | null>(null)
|
|
/** `app/server` pairs whose config differs from what the hub expects. */
|
|
const [drifted, setDrifted] = useState<Set<string>>(new Set())
|
|
/** 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()
|
|
void getDrift(token)
|
|
.then((o) => {
|
|
if (o.kind === 'ok' && Array.isArray(o.data.response?.apps)) {
|
|
setDrifted(new Set(o.data.response.apps.filter((e) => e.kind === 'app' && e.status === 'differs').map((e) => `${e.name ?? e.app}\n${e.server}`)))
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
}, [load, refresh, token])
|
|
|
|
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])
|
|
|
|
if (failure !== null && data === null) {
|
|
return (
|
|
<FailureRetry
|
|
onRetry={() => {
|
|
setFailure(null)
|
|
setData(null)
|
|
void load()
|
|
}}
|
|
>
|
|
Could not load the apps of the servers. {failure}
|
|
</FailureRetry>
|
|
)
|
|
}
|
|
if (data === null) return <LoadingRegion height="h-64">Loading the apps of every server…</LoadingRegion>
|
|
|
|
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" onClick={() => { close(); setConfigApp(app.name) }}>
|
|
Config…
|
|
</Button>
|
|
{canWrite && (
|
|
<>
|
|
<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="line-clamp-2 text-xs text-muted-foreground" title={a.description}>
|
|
{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} drifted={drifted.has(`${a.name}\n${s.id}`)} />,
|
|
}),
|
|
),
|
|
{ id: 'config', ...head({ children: 'Config' }), cell: (a) => <ConfigState app={a} data={data} onOpen={() => setConfigApp(a.name)} /> },
|
|
{ id: 'actions', ...head({ children: <span className="sr-only">Actions</span> }), cell: (a) => 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>
|
|
{configApp !== null && (
|
|
<AppConfigSync token={token} name={configApp} canWrite={canWrite} onClose={() => setConfigApp(null)} onChanged={() => void load()} />
|
|
)}
|
|
{request !== null && (
|
|
<RunFlow
|
|
request={request}
|
|
onClose={(changed) => {
|
|
setRequest(null)
|
|
if (changed) void load()
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|