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 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() 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 Same case 'different': return {app.diffCount === undefined ? 'Different' : `Different · ${app.diffCount}`} case 'single': return Single server default: return ( Unknown ) } } 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 Unknown if (cell === undefined || !cell.installed) return Not installed return ( {cell.version === undefined ? 'Installed' : `v${cell.version}`} {cell.updateAvailable === true && ( {cell.updateVersion === undefined ? 'Update' : `Update v${cell.updateVersion}`} )} ) } export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: string | null; canWrite?: boolean; refresh?: number }) { const [data, setData] = useState(null) const [request, setRequest] = useState(null) /** What the store offers, by app: needed to know whether "Align versions" can be done. */ const [store, setStore] = useState | null>(null) const [failure, setFailure] = useState(null) const [filter, setFilter] = useState('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, ) }, [data]) const retry = ( ) if (failure !== null && data === null) { return ( Could not load the apps of the servers. {failure} {retry} ) } if (data === null) return Loading the apps of every server… if (data.apps.length === 0) { return ( void load()}> Refresh } > None of the servers that answered has a DNS app installed. Install apps in a server's own console. ) } 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 ( {(close) => ( <> )} ) } const columns: Col[] = [ { id: 'name', ...head({ children: 'App' }), cell: (a) => ( {a.name} {a.description !== '' && {a.description}} ), }, ...data.servers.map( (s): Col => ({ id: `server-${s.id}`, ...head({ children: s.id }), cell: (a) => , }), ), { id: 'config', ...head({ children: 'Config' }), cell: (a) => }, ...(canWrite ? [{ id: 'actions', ...head({ children: Actions }), cell: (a: MatrixApp) => actions(a, data) }] : []), ] return (
{rows.length === 0 ? ( setFilter('all')}> Show all } > No app fits this filter. ) : ( a.name} /> )} {request !== null && ( { setRequest(null) if (changed) void load() }} /> )}
) }