Files
technitium-console/src/screens/apps/AppsMatrix.tsx
T
DenozordecandClaude Opus 5.5 ba3cdd08d2
CI / check (push) Successful in 2m39s
Hub / check (push) Successful in 50s
CI / image (push) Successful in 1m30s
Hub / image (push) Successful in 1m37s
UI polish across sections: one server selector, skeletons, inline errors
- 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>
2026-11-02 21:42:28 +07:00

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>
)
}