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:
1 parent
3d8df08fa0
commit
6acd2d656d
27 files changed
+1991
-71
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user