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

+50 -8
View File
@@ -61,6 +61,8 @@ export interface StoreApp {
installedVersion?: string
/** Only when `installed` is true. */
updateAvailable?: boolean
/** Only behind a hub, in the All servers listing: the servers that have it installed. */
installedOn?: string[]
}
/*
@@ -88,28 +90,32 @@ export function listApps(
export function listStoreApps(
token: string | null,
node = '',
): Promise<ApiOutcome<{ response: { storeApps: StoreApp[] } }>> {
return apiRequest('apps/listStoreApps', { token })
return apiRequest('apps/listStoreApps', { token, node })
}
/* Behind a hub, `node` is the one server the call is for; elsewhere it stays empty and is not sent. */
export function downloadAndInstall(
token: string | null,
name: string,
url: string,
node = '',
): Promise<ApiOutcome<{ response: { installedApp: InstalledApp } }>> {
return apiRequest('apps/downloadAndInstall', { token, body: { name, url } })
return apiRequest('apps/downloadAndInstall', { token, body: { name, url }, node })
}
export function downloadAndUpdate(
token: string | null,
name: string,
url: string,
node = '',
): Promise<ApiOutcome<{ response: { updatedApp: InstalledApp } }>> {
return apiRequest('apps/downloadAndUpdate', { token, body: { name, url } })
return apiRequest('apps/downloadAndUpdate', { token, body: { name, url }, node })
}
export function uninstallApp(token: string | null, name: string): Promise<ApiOutcome> {
return apiRequest('apps/uninstall', { token, body: { name } })
export function uninstallApp(token: string | null, name: string, node = ''): Promise<ApiOutcome> {
return apiRequest('apps/uninstall', { token, body: { name }, node })
}
/*
@@ -174,18 +180,20 @@ export function installApp(
token: string | null,
name: string,
file: File,
node = '',
): Promise<ApiOutcome<{ response: { installedApp: InstalledApp } }>> {
const { path, form } = buildUpload('apps/install', name, file)
return apiRequest(path, { token, form })
return apiRequest(path, { token, form, node })
}
export function updateApp(
token: string | null,
name: string,
file: File,
node = '',
): Promise<ApiOutcome<{ response: { updatedApp: InstalledApp } }>> {
const { path, form } = buildUpload('apps/update', name, file)
return apiRequest(path, { token, form })
return apiRequest(path, { token, form, node })
}
/*
@@ -212,10 +220,44 @@ export interface MatrixApp {
}
export interface AppsMatrixData {
servers: { id: string; ok: boolean; error?: string }[]
servers: { id: string; ok: boolean; writable: boolean; error?: string }[]
apps: MatrixApp[]
}
export function getAppsMatrix(token: string | null): Promise<ApiOutcome<{ response: AppsMatrixData }>> {
return apiRequest('hub/apps/matrix', { token })
}
/*
App writes across servers (hub/apps-write.ts). The hub resolves a store URL from each
server's own store; the client never sends one.
*/
export type HubRunAction = 'install-store' | 'update-store' | 'uninstall'
export interface TargetResult {
server: string
ok: boolean
message?: string
}
export type RunOutcome = ApiOutcome<{ response: { results: TargetResult[] } }>
export function runHubApps(token: string | null, action: HubRunAction, name: string, targets: string[]): Promise<RunOutcome> {
return apiRequest('hub/apps/run', { token, method: 'POST', body: { action, name, targets: targets.join(',') } })
}
/* The same zip to several servers: multipart, as the hub's `hub/apps/upload` expects. */
export function uploadHubApp(
token: string | null,
action: 'install' | 'update',
name: string,
file: File,
targets: string[],
): Promise<RunOutcome> {
const form = new FormData()
form.append('action', action)
form.append('name', name)
form.append('targets', targets.join(','))
form.append('fileAppZip', file)
return apiRequest('hub/apps/upload', { token, form })
}
+3 -1
View File
@@ -105,6 +105,8 @@ export interface ShellSession {
clusterInitialized?: boolean
/** Only on the hub (`hub/`): a read-only console over several servers. */
hub?: boolean
/** Only on the hub: the servers it holds an admin token for. */
hubWritable?: string[]
/** Only on an initialised cluster (WebServiceAuthApi.cs:104-106). */
clusterDomain?: string
clusterNodes?: { name: string; type: string }[]
@@ -649,7 +651,7 @@ export function Shell({
) : current?.id === 'about' ? (
<About info={info} />
) : current?.id === 'apps' ? (
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} />
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={session.info?.hubWritable ?? []} />
) : current?.id === 'cache' ? (
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} />
) : current?.id === 'blocking' ? (
+8 -3
View File
@@ -22,6 +22,7 @@ export function AppCard({
onStoreUpdate,
onUninstall,
readOnly = false,
hideConfig = false,
}: {
app: InstalledApp
busy: boolean
@@ -31,6 +32,8 @@ export function AppCard({
onUninstall: () => void
/** Behind a hub nothing can be changed yet: no action buttons. */
readOnly?: boolean
/** Behind a hub the config is not edited from here yet. */
hideConfig?: boolean
}) {
const hasUpdate = app.updateAvailable === true && Boolean(app.updateUrl)
@@ -61,9 +64,11 @@ export function AppCard({
<div />
) : (
<div className={styles.rowActs}>
<Button disabled={busy} onClick={onConfig}>
Config
</Button>
{!hideConfig && (
<Button disabled={busy} onClick={onConfig}>
Config
</Button>
)}
<Button disabled={busy} onClick={onUpdate}>
Update
</Button>
+57 -11
View File
@@ -28,6 +28,9 @@ import { Confirm } from '../../ui/Confirm'
import { InChrome } from '../../app/ChromeSlot'
import { AGGREGATE, ClusterNodeSelect, HubMode, type ClusterNode } from '../../ui/ClusterNodeSelect'
import { AppsMatrix } from './AppsMatrix'
import { HubStore } from './HubStore'
import { HubInstallFile } from './HubInstallFile'
import { RunFlow, type RunRequest } from './RunFlow'
/*
A replica of the Apps tab (apps.js + index.html:807-835).
@@ -63,11 +66,17 @@ export function Apps({
token,
nodes = [],
clusterInitialised = false,
canWrite = false,
writable = [],
}: {
token: string | null
/** Only used behind a hub: its servers. */
nodes?: ClusterNode[]
clusterInitialised?: boolean
/** Behind a hub: the role may change apps (`Apps.canModify`). */
canWrite?: boolean
/** Behind a hub: the servers the hub holds an admin token for. */
writable?: string[]
}) {
/*
Behind a hub (`info.hub`) this screen is read-only and has a server selector: "All
@@ -85,6 +94,13 @@ export function Apps({
const chosen = node === AGGREGATE || nodes.some((n) => n.name === node) ? node : AGGREGATE
const matrixMode = hub && chosen === AGGREGATE
const requestNode = hub && !matrixMode ? chosen : ''
/* Behind a hub, changes are for writers and only on a server the hub can write to. Elsewhere always. */
const mayChange = !hub || (!matrixMode && canWrite && writable.includes(chosen))
const hubServers = nodes.map((n) => ({ id: n.name, writable: writable.includes(n.name) }))
const [hubView, setHubView] = useState<'matrix' | 'store'>('matrix')
const [fileDialog, setFileDialog] = useState(false)
const [fileRequest, setFileRequest] = useState<RunRequest | null>(null)
const [refresh, setRefresh] = useState(0)
useEffect(() => {
if (!hub) return
try {
@@ -140,7 +156,7 @@ export function Apps({
*/
async function uninstall(name: string) {
setBusy(name)
const outcome = await uninstallApp(token, name)
const outcome = await uninstallApp(token, name, requestNode)
setBusy(null)
if (outcome.kind !== 'ok') {
@@ -160,7 +176,7 @@ export function Apps({
if (!app.updateUrl) return
setBusy(app.name)
const outcome = await downloadAndUpdate(token, app.name, app.updateUrl)
const outcome = await downloadAndUpdate(token, app.name, app.updateUrl, requestNode)
setBusy(null)
if (outcome.kind !== 'ok') {
@@ -216,8 +232,35 @@ export function Apps({
return (
<>
{selector}
<SectionHeader title="Apps" subtitle="DNS apps on every server" />
<AppsMatrix token={token} />
<SectionHeader
title="Apps"
subtitle="DNS apps on every server"
actions={
canWrite ? (
<>
<Button variant="primary" onClick={() => setHubView((v) => (v === 'store' ? 'matrix' : 'store'))}>
{hubView === 'store' ? 'Back to apps' : 'App Store'}
</Button>
<Button onClick={() => setFileDialog(true)}>Install from file</Button>
</>
) : undefined
}
/>
{hubView === 'store' && canWrite ? (
<HubStore token={token} servers={hubServers} canWrite={canWrite} onChanged={() => setRefresh((n) => n + 1)} />
) : (
<AppsMatrix token={token} canWrite={canWrite} refresh={refresh} />
)}
<HubInstallFile open={fileDialog} onOpenChange={setFileDialog} token={token} servers={hubServers} onRequest={setFileRequest} />
{fileRequest !== null && (
<RunFlow
request={fileRequest}
onClose={(changed) => {
setFileRequest(null)
if (changed) setRefresh((n) => n + 1)
}}
/>
)}
</>
)
}
@@ -238,7 +281,7 @@ export function Apps({
) : undefined
}
actions={
hub ? undefined : (
!mayChange ? undefined : (
<>
<Button variant="primary" onClick={() => setTab('store')}>
App Store
@@ -260,7 +303,7 @@ export function Apps({
/>
)}
{!hub && (
{mayChange && (
<PageTabs
label="Apps"
tabs={[
@@ -272,8 +315,8 @@ export function Apps({
/>
)}
{tab === 'store' && !hub ? (
<StoreApps token={token} onChanged={() => void reload()} />
{tab === 'store' && mayChange ? (
<StoreApps token={token} node={requestNode} onChanged={() => void reload()} />
) : apps === null ? (
<Loading />
) : failure ? (
@@ -285,7 +328,7 @@ export function Apps({
<Empty
title="No apps installed"
actions={
hub ? undefined : (
!mayChange ? undefined : (
<Button variant="primary" onClick={() => setTab('store')}>
Open App Store
</Button>
@@ -294,7 +337,7 @@ export function Apps({
>
DNS Apps add behaviour to the server (query logging, advanced blocking, split horizon)
without touching the base configuration.
{hub ? ' Install apps in the console of this server.' : ' Open the store to see what is available.'}
{!mayChange && hub ? ' Install apps in the console of this server.' : ' Open the store to see what is available.'}
</Empty>
) : (
<Panel title="Installed" description="Version, DNS apps and status">
@@ -319,7 +362,8 @@ export function Apps({
key={app.name}
app={app}
busy={busy === app.name}
readOnly={hub}
readOnly={!mayChange}
hideConfig={hub}
onConfig={() => void openConfig(app.name)}
onUpdate={() => setModal({ kind: 'update', name: app.name })}
onStoreUpdate={() => void updateFromStore(app)}
@@ -343,6 +387,7 @@ export function Apps({
open={modal?.kind === 'install'}
onOpenChange={(o) => setModal(o ? { kind: 'install' } : null)}
token={token}
node={requestNode}
onInstalled={(name) => {
setModal(null)
setAlert({
@@ -358,6 +403,7 @@ export function Apps({
onOpenChange={(o) => setModal(o && modal?.kind === 'update' ? modal : null)}
token={token}
name={modal?.kind === 'update' ? modal.name : ''}
node={requestNode}
onUpdated={(name) => {
setModal(null)
setAlert({
+3 -3
View File
@@ -15,8 +15,8 @@ afterEach(() => {
const DATA: AppsMatrixData = {
servers: [
{ id: 'mdns', ok: true },
{ id: 'ndns', ok: true },
{ id: 'mdns', ok: true, writable: false },
{ id: 'ndns', ok: true, writable: false },
],
apps: [
{
@@ -121,7 +121,7 @@ describe('AppsMatrix', () => {
it('marks a server that did not answer as unknown', async () => {
mockMatrix({
servers: [{ id: 'mdns', ok: true }, { id: 'ndns', ok: false, error: 'timed out' }],
servers: [{ id: 'mdns', ok: true, writable: false }, { id: 'ndns', ok: false, writable: false, error: 'timed out' }],
apps: [
{
name: 'Split Horizon',
+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>
)
}
+288
View File
@@ -0,0 +1,288 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AppsMatrix } from './AppsMatrix'
import { Apps } from './Apps'
import { SlotProvider } from '../../app/ChromeSlot'
import { HubMode } from '../../ui/ClusterNodeSelect'
import * as api from '../../api/apps'
import type { AppsMatrixData, InstalledApp } from '../../api/apps'
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
})
/* mdns has Blocker v1.0 with an update; ndns has v2.0; Wild IP is only on mdns. ndns has no admin token. */
const DATA: AppsMatrixData = {
servers: [
{ id: 'mdns', ok: true, writable: true },
{ id: 'ndns', ok: true, writable: false },
],
apps: [
{
name: 'Blocker',
description: '',
configs: 'same',
versions: 'different',
servers: {
mdns: { installed: true, version: '1.0', updateAvailable: true, updateVersion: '2.0', configStatus: 'read' },
ndns: { installed: true, version: '2.0', configStatus: 'read' },
},
},
{
name: 'Wild IP',
description: '',
configs: 'single',
versions: 'single',
servers: {
mdns: { installed: true, version: '5.1', configStatus: 'read' },
ndns: { installed: false, configStatus: 'none' },
},
},
],
}
const ok = (results: api.TargetResult[]) => ({ kind: 'ok', data: { response: { results } } }) as never
function mockReads(data: AppsMatrixData = DATA, store = [{ name: 'Blocker', version: '2.0' }]) {
const matrix = vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({ kind: 'ok', data: { response: data } } as never)
vi.spyOn(api, 'listStoreApps').mockResolvedValue({ kind: 'ok', data: { response: { storeApps: store } } } as never)
return matrix
}
async function openMenu(app: string) {
await userEvent.click(await screen.findByRole('button', { name: `Actions: ${app}` }))
}
describe('matrix actions', () => {
it('has no menu for a reader and one per row for a writer', async () => {
mockReads()
const { unmount } = render(<AppsMatrix token="t" />)
await screen.findByText('Blocker')
expect(screen.queryByRole('button', { name: /^Actions:/ })).not.toBeInTheDocument()
unmount()
render(<AppsMatrix token="t" canWrite />)
expect(await screen.findByRole('button', { name: 'Actions: Blocker' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Actions: Wild IP' })).toBeInTheDocument()
})
it('enables only the actions that make sense for the row', async () => {
mockReads()
render(<AppsMatrix token="t" canWrite />)
await openMenu('Blocker')
expect(screen.getByRole('menuitem', { name: 'Update on all' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Install on missing servers' })).toBeDisabled()
expect(screen.getByRole('menuitem', { name: 'Align versions' })).toBeEnabled()
expect(screen.getByRole('menuitem', { name: 'Uninstall from…' })).toBeEnabled()
})
it('offers Align versions only when the store has the newest installed version', async () => {
mockReads(DATA, [{ name: 'Blocker', version: '1.5' }])
render(<AppsMatrix token="t" canWrite />)
await openMenu('Blocker')
await waitFor(() => expect(api.listStoreApps).toHaveBeenCalled())
expect(screen.getByRole('button', { name: 'Align versions' })).toBeDisabled()
})
it('offers Install on missing servers for an app that is not everywhere', async () => {
mockReads()
render(<AppsMatrix token="t" canWrite />)
await openMenu('Wild IP')
expect(screen.getByRole('menuitem', { name: 'Install on missing servers' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Update on all' })).toBeDisabled()
})
it('shows the picker with a server the hub cannot write to disabled, with the reason', async () => {
mockReads()
render(<AppsMatrix token="t" canWrite />)
await openMenu('Wild IP')
await userEvent.click(screen.getByRole('menuitem', { name: 'Install on missing servers' }))
const dialog = await screen.findByRole('dialog', { name: 'Install Wild IP' })
// mdns already has it, ndns has no admin token: neither can be chosen.
expect(within(dialog).getByRole('checkbox', { name: 'mdns' })).toBeDisabled()
expect(within(dialog).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
expect(within(dialog).getByText('Already installed')).toBeInTheDocument()
expect(within(dialog).getByText('No admin token configured on the hub')).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Install' })).toBeDisabled()
})
it('runs the change on the ticked servers, shows each result and refreshes', async () => {
const matrix = mockReads()
const run = vi.spyOn(api, 'runHubApps').mockResolvedValue(ok([{ server: 'mdns', ok: true, message: 'Updated from the store.' }]))
render(<AppsMatrix token="t" canWrite />)
await openMenu('Blocker')
await userEvent.click(screen.getByRole('menuitem', { name: 'Update on all' }))
const dialog = await screen.findByRole('dialog', { name: 'Update Blocker' })
expect(within(dialog).getByRole('checkbox', { name: 'mdns' })).toBeChecked()
await userEvent.click(within(dialog).getByRole('button', { name: 'Update' }))
expect(run).toHaveBeenCalledWith('t', 'update-store', 'Blocker', ['mdns'])
const results = await screen.findByRole('dialog', { name: 'Update Blocker: results' })
expect(within(results).getByText('Updated from the store.')).toBeInTheDocument()
expect(within(results).getByText('Done')).toBeInTheDocument()
expect(within(results).queryByRole('button', { name: 'Retry failed' })).not.toBeInTheDocument()
const before = matrix.mock.calls.length
await userEvent.click(within(results).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement)
await waitFor(() => expect(matrix.mock.calls.length).toBeGreaterThan(before))
})
it('retries only the servers that failed', async () => {
const wide: AppsMatrixData = {
servers: [
{ id: 'mdns', ok: true, writable: true },
{ id: 'ndns', ok: true, writable: true },
],
apps: [{ ...DATA.apps[0], servers: { ...DATA.apps[0].servers, ndns: { installed: true, version: '1.0', updateAvailable: true, configStatus: 'read' } } }],
}
mockReads(wide)
const run = vi
.spyOn(api, 'runHubApps')
.mockResolvedValueOnce(ok([{ server: 'mdns', ok: true, message: 'Updated from the store.' }, { server: 'ndns', ok: false, message: 'Disk full.' }]))
.mockResolvedValueOnce(ok([{ server: 'ndns', ok: true, message: 'Updated from the store.' }]))
render(<AppsMatrix token="t" canWrite />)
await openMenu('Blocker')
await userEvent.click(screen.getByRole('menuitem', { name: 'Update on all' }))
const dialog = await screen.findByRole('dialog', { name: 'Update Blocker' })
await userEvent.click(within(dialog).getByRole('button', { name: 'Update' }))
expect(run).toHaveBeenLastCalledWith('t', 'update-store', 'Blocker', ['mdns', 'ndns'])
const results = await screen.findByRole('dialog', { name: 'Update Blocker: results' })
expect(within(results).getByText('Disk full.')).toBeInTheDocument()
await userEvent.click(within(results).getByRole('button', { name: 'Retry failed' }))
expect(run).toHaveBeenLastCalledWith('t', 'update-store', 'Blocker', ['ndns'])
await waitFor(() => expect(within(screen.getByRole('dialog')).queryByText('Disk full.')).not.toBeInTheDocument())
expect(within(screen.getByRole('dialog')).getAllByText('Done')).toHaveLength(2)
})
it('says the config is saved before an uninstall and starts with nothing ticked', async () => {
mockReads()
const run = vi.spyOn(api, 'runHubApps').mockResolvedValue(ok([{ server: 'mdns', ok: true }]))
render(<AppsMatrix token="t" canWrite />)
await openMenu('Wild IP')
await userEvent.click(screen.getByRole('menuitem', { name: 'Uninstall from…' }))
const dialog = await screen.findByRole('dialog', { name: 'Uninstall Wild IP' })
expect(within(dialog).getByText(/saved to the hub's history/)).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Uninstall' })).toBeDisabled()
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'mdns' }))
await userEvent.click(within(dialog).getByRole('button', { name: 'Uninstall' }))
expect(run).toHaveBeenCalledWith('t', 'uninstall', 'Wild IP', ['mdns'])
})
it('shows a failed call as a failure with Retry', async () => {
mockReads()
const run = vi
.spyOn(api, 'runHubApps')
.mockResolvedValueOnce({ kind: 'error', message: 'Your role does not allow this.' } as never)
.mockResolvedValueOnce(ok([{ server: 'mdns', ok: true }]))
render(<AppsMatrix token="t" canWrite />)
await openMenu('Blocker')
await userEvent.click(screen.getByRole('menuitem', { name: 'Update on all' }))
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Update Blocker' })).getByRole('button', { name: 'Update' }))
const results = await screen.findByRole('dialog', { name: 'Update Blocker: results' })
expect(within(results).getByText('Your role does not allow this.')).toBeInTheDocument()
await userEvent.click(within(results).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(run).toHaveBeenCalledTimes(2))
expect(await within(screen.getByRole('dialog')).findByText('Done')).toBeInTheDocument()
})
})
describe('Apps behind a hub, for writers', () => {
const INSTALLED: InstalledApp = { name: 'Split Horizon', description: 'd', version: '9.0', updateAvailable: true, updateVersion: '9.1', updateUrl: 'https://x/s.zip', dnsApps: [] }
const NODES = [{ name: 'mdns', type: 'Primary' }, { name: 'ndns', type: 'Secondary' }]
function mount(props: { canWrite: boolean; writable: string[] }) {
return render(
<HubMode.Provider value>
<SlotProvider>
{(ref) => (
<>
<div ref={ref} />
<Apps token="t" nodes={NODES} clusterInitialised {...props} />
</>
)}
</SlotProvider>
</HubMode.Provider>,
)
}
it('shows App Store and Install from file on All servers to writers only', async () => {
mockReads()
const { unmount } = mount({ canWrite: false, writable: ['mdns'] })
await screen.findByText('Blocker')
expect(screen.queryByRole('button', { name: 'App Store' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Install from file' })).not.toBeInTheDocument()
unmount()
mount({ canWrite: true, writable: ['mdns'] })
expect(await screen.findByRole('button', { name: 'App Store' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Install from file' })).toBeInTheDocument()
})
it('opens the hub store and sends an install through the picker', async () => {
mockReads()
vi.spyOn(api, 'listStoreApps').mockResolvedValue({
kind: 'ok',
data: { response: { storeApps: [{ name: 'Failover', description: 'Backup', version: '8.0', url: 'u', size: '1', installed: false, installedOn: [] }] } },
} as never)
const run = vi.spyOn(api, 'runHubApps').mockResolvedValue(ok([{ server: 'mdns', ok: true }]))
mount({ canWrite: true, writable: ['mdns'] })
await userEvent.click(await screen.findByRole('button', { name: 'App Store' }))
expect(await screen.findByText('Failover')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Install…' }))
const dialog = await screen.findByRole('dialog', { name: 'Install Failover' })
expect(within(dialog).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
await userEvent.click(within(dialog).getByRole('button', { name: 'Install' }))
expect(run).toHaveBeenCalledWith('t', 'install-store', 'Failover', ['mdns'])
})
it('uploads a zip to the chosen servers', async () => {
mockReads()
const upload = vi.spyOn(api, 'uploadHubApp').mockResolvedValue(ok([{ server: 'mdns', ok: true }]))
mount({ canWrite: true, writable: ['mdns'] })
await userEvent.click(await screen.findByRole('button', { name: 'Install from file' }))
const form = await screen.findByRole('dialog', { name: 'Install App' })
await userEvent.type(within(form).getByLabelText('App Name'), 'Mine')
const file = new File([new Uint8Array([1, 2, 3])], 'Mine.zip', { type: 'application/zip' })
await userEvent.upload(within(form).getByLabelText('App Zip File'), file)
await userEvent.click(within(form).getByRole('button', { name: 'Choose servers' }))
const picker = await screen.findByRole('dialog', { name: 'Install Mine from file' })
await userEvent.click(within(picker).getByRole('button', { name: 'Install' }))
expect(upload).toHaveBeenCalledWith('t', 'install', 'Mine', file, ['mdns'])
})
it('brings the single-server actions back for a writer, without Config, and sends them to that node', async () => {
mockReads()
localStorage.setItem('hubAppsNode', 'mdns')
vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
const store = vi.spyOn(api, 'downloadAndUpdate').mockResolvedValue({ kind: 'ok', data: { response: {} } } as never)
mount({ canWrite: true, writable: ['mdns'] })
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'App Store' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Install from file' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Actions: Split Horizon/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Config' })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Store Update' }))
expect(store).toHaveBeenCalledWith('t', 'Split Horizon', 'https://x/s.zip', 'mdns')
})
it('keeps a viewer, and a server without an admin token, read-only', async () => {
mockReads()
vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
localStorage.setItem('hubAppsNode', 'ndns')
const { unmount } = mount({ canWrite: true, writable: ['mdns'] })
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
for (const name of ['Update', 'Store Update', 'App Store', 'Install from file']) {
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
}
unmount()
localStorage.setItem('hubAppsNode', 'mdns')
mount({ canWrite: false, writable: ['mdns'] })
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
for (const name of ['Update', 'Store Update', 'App Store', 'Install from file']) {
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
}
})
})
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useRef, useState } from 'react'
import { uploadHubApp } from '../../api/apps'
import { Button } from '../../ui/Button'
import { Dialog } from '../../ui/Dialog'
import { LabeledInput } from '../../ui/Field'
import { Notifier } from '../../ui/Notifier'
import { pickerServers, type RunRequest } from './RunFlow'
import type { AlertState } from './Apps'
/*
"Install from file" in the All servers view: the same two fields as the single-server dialog
(the validation order and wording are the same), then the server picker. The zip goes to the
hub once and the hub sends it to every chosen server.
*/
export function HubInstallFile({
open,
onOpenChange,
token,
servers,
onRequest,
}: {
open: boolean
onOpenChange: (o: boolean) => void
token: string | null
servers: { id: string; writable: boolean }[]
onRequest: (request: RunRequest) => void
}) {
const [name, setName] = useState('')
const [alert, setAlert] = useState<AlertState | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setName('')
setAlert(null)
}
}, [open])
function next() {
if (name === '') {
setAlert({ type: 'warning', title: 'Missing!', text: 'Please enter an application name.' })
return
}
const file = fileRef.current?.files?.[0]
if (!file) {
setAlert({ type: 'warning', title: 'Missing!', text: 'Please select an application zip file to install.' })
return
}
onRequest({
title: `Install ${name} from file`,
intro: 'The zip is sent to every server you choose.',
confirmLabel: 'Install',
servers: pickerServers(servers),
preselected: servers.filter((s) => s.writable).map((s) => s.id),
run: (targets) => uploadHubApp(token, 'install', name, file, targets),
})
onOpenChange(false)
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title="Install App"
actions={
<Button variant="primary" onClick={next}>
Choose servers
</Button>
}
>
<Notifier notice={alert} onClose={() => setAlert(null)} />
<LabeledInput label="App Name" value={name} onChange={(e) => setName(e.target.value)} />
<LabeledInput label="App Zip File" type="file" ref={fileRef} />
</Dialog>
)
}
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useState } from 'react'
import { listStoreApps, runHubApps, type HubRunAction, type StoreApp } from '../../api/apps'
import { Button } from '../../ui/Button'
import { Empty, Failure, Loading } from '../../ui/Empty'
import { Panel } from '../../ui/Panel'
import { Tag } from '../../ui/Tag'
import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
/*
The App Store behind a hub, in the All servers view. It lists what the first server's store
offers and where each app is installed. Install and Update go to the servers the user picks;
the hub takes each download URL from that server's own store, so none is sent from here.
*/
export function HubStore({
token,
servers,
canWrite,
onChanged,
}: {
token: string | null
servers: { id: string; writable: boolean }[]
canWrite: boolean
onChanged: () => void
}) {
const [apps, setApps] = useState<StoreApp[] | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [request, setRequest] = useState<RunRequest | null>(null)
const load = useCallback(async () => {
const outcome = await listStoreApps(token)
if (outcome.kind === 'ok') {
setApps(outcome.data.response.storeApps)
setFailure(null)
} else setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
}, [token])
useEffect(() => {
void load()
}, [load])
function open(app: StoreApp, action: HubRunAction, verb: string) {
const on = app.installedOn ?? []
const allowed = action === 'install-store' ? servers.map((s) => s.id).filter((id) => !on.includes(id)) : on
setRequest({
title: `${verb} ${app.name}`,
confirmLabel: verb,
servers: pickerServers(
servers,
Object.fromEntries(servers.filter((s) => !allowed.includes(s.id)).map((s) => [s.id, action === 'install-store' ? 'Already installed' : 'Not installed'])),
),
preselected: allowed,
run: (targets) => runHubApps(token, action, app.name, targets),
})
}
if (failure !== null && apps === null) {
return (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not load the App Store. {failure}</span>
<Button variant="primary" onClick={() => void load()}>
Retry
</Button>
</span>
</Failure>
)
}
if (apps === null) return <Loading>Loading the App Store…</Loading>
if (apps.length === 0) {
return (
<Empty
title="No apps in the store"
actions={
<Button variant="primary" onClick={() => void load()}>
Refresh
</Button>
}
>
The store did not list any app.
</Empty>
)
}
return (
<Panel title="DNS App Store" description="What the store offers and where it is installed" groups2>
<ul className="m-0 flex list-none flex-col p-0" aria-label="Store apps">
{apps.map((app) => {
const on = app.installedOn ?? []
return (
<li key={app.name} aria-label={app.name} className="flex flex-wrap items-start justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<span className="flex flex-wrap items-center gap-2 text-sm font-medium">
{app.name}
<Tag>Version {app.version}</Tag>
{on.map((id) => (
<Tag key={id} tone="info">
{id}
</Tag>
))}
</span>
<span className="text-xs text-muted-foreground">{app.description}</span>
</div>
{canWrite && (
<div className="flex items-center gap-2">
<Button size="sm" variant="primary" onClick={() => open(app, 'install-store', 'Install')}>
Install…
</Button>
{on.length > 0 && (
<Button size="sm" onClick={() => open(app, 'update-store', 'Update')}>
Update…
</Button>
)}
</div>
)}
</li>
)
})}
</ul>
{request !== null && (
<RunFlow
request={request}
onClose={(changed) => {
setRequest(null)
if (changed) {
void load()
onChanged()
}
}}
/>
)}
</Panel>
)
}
+4 -1
View File
@@ -17,11 +17,14 @@ export function InstallApp({
onOpenChange,
token,
onInstalled,
node = '',
}: {
open: boolean
onOpenChange: (o: boolean) => void
token: string | null
onInstalled: (name: string) => void
/** Behind a hub: the one server it goes to. */
node?: string
}) {
const [name, setName] = useState('')
const [alert, setAlert] = useState<AlertState | null>(null)
@@ -53,7 +56,7 @@ export function InstallApp({
}
setBusy(true)
const outcome = await installApp(token, name, file)
const outcome = await installApp(token, name, file, node)
setBusy(false)
if (outcome.kind !== 'ok') {
+142
View File
@@ -0,0 +1,142 @@
import { useState, type ReactNode } from 'react'
import type { RunOutcome, TargetResult } from '../../api/apps'
import { Button } from '../../ui/Button'
import { Check } from '../../ui/Check'
import { Dialog } from '../../ui/Dialog'
import { Failure, Loading } from '../../ui/Empty'
import { Tag } from '../../ui/Tag'
/*
Behind a hub, a change to the apps of several servers goes through three steps in one place:
pick the servers, wait, read what happened on each. A server that cannot be chosen is still
listed, disabled, with the reason, so nobody wonders where it went. "Retry failed" runs the
same change again on the servers that failed and nowhere else.
*/
export interface PickerServer {
id: string
/** Why this server cannot be chosen; absent when it can. */
reason?: string
}
export interface RunRequest {
title: string
intro?: ReactNode
confirmLabel: string
servers: PickerServer[]
/** The servers ticked at the start. Only those without a reason count. */
preselected: string[]
run: (targets: string[]) => Promise<RunOutcome>
}
/* What stops a server from being a target: it did not answer, or the hub has no admin token for it. */
export function pickerServers(
servers: { id: string; writable: boolean; ok?: boolean }[],
extra: Record<string, string> = {},
): PickerServer[] {
return servers.map((s) => {
const reason = s.ok === false ? 'Did not answer' : !s.writable ? 'No admin token configured on the hub' : extra[s.id]
return reason === undefined ? { id: s.id } : { id: s.id, reason }
})
}
type Stage = 'pick' | 'running' | 'results'
export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (changed: boolean) => void }) {
const [stage, setStage] = useState<Stage>('pick')
const [picked, setPicked] = useState<string[]>(() =>
request.preselected.filter((id) => request.servers.some((s) => s.id === id && s.reason === undefined)),
)
const [results, setResults] = useState<TargetResult[]>([])
const [failure, setFailure] = useState<string | null>(null)
const [lastTargets, setLastTargets] = useState<string[]>([])
async function go(targets: string[]) {
setStage('running')
setFailure(null)
setLastTargets(targets)
const outcome = await request.run(targets)
if (outcome.kind === 'ok') {
const fresh = outcome.data.response.results
// A retry replaces only the rows it re-ran.
setResults((old) => [...old.filter((r) => !fresh.some((f) => f.server === r.server)), ...fresh])
} else {
setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
}
setStage('results')
}
const failed = results.filter((r) => !r.ok).map((r) => r.server)
const changed = results.some((r) => r.ok)
if (stage === 'pick') {
return (
<Dialog
open
onOpenChange={() => onClose(false)}
title={request.title}
close="Cancel"
actions={
<Button variant="primary" disabled={picked.length === 0} onClick={() => void go(picked)}>
{request.confirmLabel}
</Button>
}
>
{request.intro != null && <p className="m-0 text-sm text-muted-foreground">{request.intro}</p>}
<div className="flex flex-col gap-3" role="group" aria-label="Servers">
{request.servers.map((s) => (
<Check
key={s.id}
label={s.id}
checked={picked.includes(s.id)}
disabled={s.reason !== undefined}
help={s.reason}
onChange={(on) => setPicked((old) => (on ? [...old, s.id] : old.filter((id) => id !== s.id)))}
/>
))}
</div>
</Dialog>
)
}
if (stage === 'running') {
return (
<Dialog open onOpenChange={() => {}} title={request.title} close="Close">
<Loading>Working on {lastTargets.join(', ')}…</Loading>
</Dialog>
)
}
return (
<Dialog
open
onOpenChange={() => onClose(changed)}
title={`${request.title}: results`}
actions={
failure !== null ? (
<Button variant="primary" onClick={() => void go(lastTargets)}>
Retry
</Button>
) : failed.length > 0 ? (
<Button variant="primary" onClick={() => void go(failed)}>
Retry failed
</Button>
) : undefined
}
>
{failure !== null ? (
<Failure>{failure}</Failure>
) : (
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Results">
{results.map((r) => (
<li key={r.server} className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{r.server}</span>
<Tag tone={r.ok ? 'ok' : 'dan'}>{r.ok ? 'Done' : 'Failed'}</Tag>
{r.message !== undefined && <span className="text-muted-foreground">{r.message}</span>}
</li>
))}
</ul>
)}
</Dialog>
)
}
+7 -4
View File
@@ -30,8 +30,11 @@ and the freshly placed row is left without its "Store Update" button.
export function StoreApps({
token,
onChanged,
node = '',
}: {
token: string | null
/** Behind a hub: the one server it acts on. */
node?: string
onChanged: () => void
}) {
const [storeApps, setStoreApps] = useState<StoreApp[] | null>(null)
@@ -50,7 +53,7 @@ export function StoreApps({
*/
const load = useCallback(async () => {
const outcome = await listStoreApps(token)
const outcome = await listStoreApps(token, node)
if (outcome.kind === 'ok') {
setStoreApps(outcome.data.response.storeApps)
return
@@ -85,7 +88,7 @@ export function StoreApps({
title: 'Store App Installed!',
text: `DNS application '${app.name}' was installed successfully from DNS App Store.`,
},
downloadAndInstall(token, app.name, app.url),
downloadAndInstall(token, app.name, app.url, node),
)
}
@@ -97,7 +100,7 @@ export function StoreApps({
title: 'Store App Updated!',
text: `DNS application '${app.name}' was updated successfully from DNS App Store.`,
},
downloadAndUpdate(token, app.name, app.url),
downloadAndUpdate(token, app.name, app.url, node),
)
}
@@ -116,7 +119,7 @@ export function StoreApps({
title: 'Store App Uninstalled!',
text: `DNS application '${app.name}' was uninstalled successfully.`,
},
uninstallApp(token, app.name),
uninstallApp(token, app.name, node),
)
}
+4 -1
View File
@@ -19,12 +19,15 @@ export function UpdateApp({
token,
name,
onUpdated,
node = '',
}: {
open: boolean
onOpenChange: (o: boolean) => void
token: string | null
name: string
onUpdated: (name: string) => void
/** Behind a hub: the one server it goes to. */
node?: string
}) {
const [alert, setAlert] = useState<AlertState | null>(null)
const [busy, setBusy] = useState(false)
@@ -49,7 +52,7 @@ export function UpdateApp({
}
setBusy(true)
const outcome = await updateApp(token, name, file)
const outcome = await updateApp(token, name, file, node)
setBusy(false)
if (outcome.kind !== 'ok') {