Hub stage 2: install, update and uninstall apps on several servers

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 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 16:11:40 +07:00
1 parent 3d8df08fa0
commit 6acd2d656d
27 files changed
+1991 -71

No files matched your search

+94 -3
View File
@@ -1,12 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { getAppsMatrix, type AppsMatrixData, type MatrixApp } from '../../api/apps'
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'
@@ -18,6 +20,16 @@ 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> = {
@@ -70,8 +82,11 @@ function ServerCell({ app, id, data }: { app: MatrixApp; id: string; data: AppsM
)
}
export function AppsMatrix({ token }: { token: string | null }) {
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')
@@ -87,7 +102,14 @@ export function AppsMatrix({ token }: { token: string | null }) {
useEffect(() => {
void load()
}, [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 ?? []
@@ -138,6 +160,65 @@ export function AppsMatrix({ token }: { token: string | null }) {
}
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',
@@ -157,6 +238,7 @@ export function AppsMatrix({ token }: { token: string | null }) {
}),
),
{ 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 (
@@ -188,6 +270,15 @@ export function AppsMatrix({ token }: { token: string | null }) {
<DataTable data={rows} columns={columns} getRowId={(a) => a.name} />
)}
</Panel>
{request !== null && (
<RunFlow
request={request}
onClose={(changed) => {
setRequest(null)
if (changed) void load()
}}
/>
)}
</div>
)
}