Hub stage 1: apps across servers, read-only
hub/apps/matrix reads apps/list and each app's config on every server (the read token first, the admin token once if a server refuses) and returns per app and server the version, available update and a config hash, plus whether versions and configs match and how many JSON leaf paths differ. Config texts never leave the hub. In hub mode the Apps section has the server selector: All servers shows the matrix with filters for differing versions, differing configs and updates; one server shows its list without write actions. The dev mock takes --variant a|b with differing apps. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0ef2d53a65
commit
3d8df08fa0
14 files changed
+1082
-37
No files matched your search
@@ -187,3 +187,35 @@ export function updateApp(
|
||||
const { path, form } = buildUpload('apps/update', name, file)
|
||||
return apiRequest(path, { token, form })
|
||||
}
|
||||
|
||||
/*
|
||||
The hub's matrix of apps over servers (`hub/apps/matrix`, hub/apps-matrix.ts). Only on a
|
||||
hub. A config is never in it: just a hash per server and the comparison.
|
||||
*/
|
||||
export interface MatrixCell {
|
||||
installed: boolean
|
||||
version?: string
|
||||
updateAvailable?: boolean
|
||||
updateVersion?: string
|
||||
configStatus: 'none' | 'read' | 'unreadable'
|
||||
configHash?: string
|
||||
configError?: string
|
||||
}
|
||||
|
||||
export interface MatrixApp {
|
||||
name: string
|
||||
description: string
|
||||
servers: Record<string, MatrixCell>
|
||||
configs: 'same' | 'different' | 'single' | 'unknown'
|
||||
diffCount?: number
|
||||
versions: 'same' | 'different' | 'single'
|
||||
}
|
||||
|
||||
export interface AppsMatrixData {
|
||||
servers: { id: string; ok: boolean; error?: string }[]
|
||||
apps: MatrixApp[]
|
||||
}
|
||||
|
||||
export function getAppsMatrix(token: string | null): Promise<ApiOutcome<{ response: AppsMatrixData }>> {
|
||||
return apiRequest('hub/apps/matrix', { token })
|
||||
}
|
||||
+1
-1
@@ -649,7 +649,7 @@ export function Shell({
|
||||
) : current?.id === 'about' ? (
|
||||
<About info={info} />
|
||||
) : current?.id === 'apps' ? (
|
||||
<Apps token={session.token} />
|
||||
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} />
|
||||
) : current?.id === 'cache' ? (
|
||||
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} />
|
||||
) : current?.id === 'blocking' ? (
|
||||
|
||||
@@ -21,6 +21,7 @@ export function AppCard({
|
||||
onUpdate,
|
||||
onStoreUpdate,
|
||||
onUninstall,
|
||||
readOnly = false,
|
||||
}: {
|
||||
app: InstalledApp
|
||||
busy: boolean
|
||||
@@ -28,6 +29,8 @@ export function AppCard({
|
||||
onUpdate: () => void
|
||||
onStoreUpdate: () => void
|
||||
onUninstall: () => void
|
||||
/** Behind a hub nothing can be changed yet: no action buttons. */
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const hasUpdate = app.updateAvailable === true && Boolean(app.updateUrl)
|
||||
|
||||
@@ -54,6 +57,9 @@ export function AppCard({
|
||||
<Tag tone={hasUpdate ? 'warn' : 'ok'}>{hasUpdate ? 'Update available' : 'Up to date'}</Tag>
|
||||
</div>
|
||||
|
||||
{readOnly ? (
|
||||
<div />
|
||||
) : (
|
||||
<div className={styles.rowActs}>
|
||||
<Button disabled={busy} onClick={onConfig}>
|
||||
Config
|
||||
@@ -94,6 +100,7 @@ export function AppCard({
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{app.dnsApps.length > 0 && (
|
||||
<Details className={styles.rowDetails} summary="More Details">
|
||||
|
||||
+89
-27
@@ -1,5 +1,5 @@
|
||||
import { KpiStrip } from '../../ui/KpiStrip'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useContext, useEffect, useState } from 'react'
|
||||
import {
|
||||
downloadAndUpdate,
|
||||
getAppConfig,
|
||||
@@ -25,6 +25,9 @@ import styles from './Apps.module.css'
|
||||
import { noticeFromFailure } from '../../lib/notice'
|
||||
import { Notifier } from '../../ui/Notifier'
|
||||
import { Confirm } from '../../ui/Confirm'
|
||||
import { InChrome } from '../../app/ChromeSlot'
|
||||
import { AGGREGATE, ClusterNodeSelect, HubMode, type ClusterNode } from '../../ui/ClusterNodeSelect'
|
||||
import { AppsMatrix } from './AppsMatrix'
|
||||
|
||||
/*
|
||||
A replica of the Apps tab (apps.js + index.html:807-835).
|
||||
@@ -56,7 +59,41 @@ export function error(outcome: { kind: string; message?: string }): AlertState {
|
||||
return noticeFromFailure(outcome)
|
||||
}
|
||||
|
||||
export function Apps({ token }: { token: string | null }) {
|
||||
export function Apps({
|
||||
token,
|
||||
nodes = [],
|
||||
clusterInitialised = false,
|
||||
}: {
|
||||
token: string | null
|
||||
/** Only used behind a hub: its servers. */
|
||||
nodes?: ClusterNode[]
|
||||
clusterInitialised?: boolean
|
||||
}) {
|
||||
/*
|
||||
Behind a hub (`info.hub`) this screen is read-only and has a server selector: "All
|
||||
servers" shows the matrix of apps over servers, one server shows that server's list.
|
||||
Anywhere else none of this exists.
|
||||
*/
|
||||
const hub = useContext(HubMode)
|
||||
const [node, setNode] = useState<string>(() => {
|
||||
try {
|
||||
return localStorage.getItem('hubAppsNode') || AGGREGATE
|
||||
} catch {
|
||||
return AGGREGATE
|
||||
}
|
||||
})
|
||||
const chosen = node === AGGREGATE || nodes.some((n) => n.name === node) ? node : AGGREGATE
|
||||
const matrixMode = hub && chosen === AGGREGATE
|
||||
const requestNode = hub && !matrixMode ? chosen : ''
|
||||
useEffect(() => {
|
||||
if (!hub) return
|
||||
try {
|
||||
localStorage.setItem('hubAppsNode', node)
|
||||
} catch {
|
||||
// Not remembered; the choice still works.
|
||||
}
|
||||
}, [hub, node])
|
||||
|
||||
const [apps, setApps] = useState<InstalledApp[] | null>(null)
|
||||
const [alert, setAlert] = useState<AlertState | null>(null)
|
||||
const [modal, setModal] = useState<Modal | null>(null)
|
||||
@@ -67,7 +104,7 @@ export function Apps({ token }: { token: string | null }) {
|
||||
const [pendingUninstall, setPendingUninstall] = useState<string | null>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const outcome = await listApps(token)
|
||||
const outcome = await listApps(token, requestNode)
|
||||
if (outcome.kind === 'ok') {
|
||||
setApps(outcome.data.response.apps)
|
||||
setFailure(false)
|
||||
@@ -87,11 +124,11 @@ export function Apps({ token }: { token: string | null }) {
|
||||
setApps([])
|
||||
setFailure(true)
|
||||
setAlert(error(outcome))
|
||||
}, [token])
|
||||
}, [token, requestNode])
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
}, [reload])
|
||||
if (!matrixMode) void reload()
|
||||
}, [reload, matrixMode])
|
||||
|
||||
/*
|
||||
apps.js:425-449. The confirmation and the alert are upstream literals.
|
||||
@@ -169,13 +206,30 @@ export function Apps({ token }: { token: string | null }) {
|
||||
*/
|
||||
const { rows: sortedApps, toggle } = useSort({ name: (a: InstalledApp) => a.name }, apps ?? [])
|
||||
|
||||
const selector = hub ? (
|
||||
<InChrome>
|
||||
<ClusterNodeSelect nodes={nodes} initialised={clusterInitialised} aggregate value={chosen} onChange={setNode} />
|
||||
</InChrome>
|
||||
) : null
|
||||
|
||||
if (matrixMode) {
|
||||
return (
|
||||
<>
|
||||
{selector}
|
||||
<SectionHeader title="Apps" subtitle="DNS apps on every server" />
|
||||
<AppsMatrix token={token} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{selector}
|
||||
<Notifier notice={alert} onClose={() => setAlert(null)} />
|
||||
|
||||
<SectionHeader
|
||||
title="Apps"
|
||||
subtitle="DNS apps installed on this server"
|
||||
subtitle={hub ? `DNS apps installed on ${chosen}` : 'DNS apps installed on this server'}
|
||||
labels={
|
||||
withUpdate > 0 ? (
|
||||
<Tag tone="warn">
|
||||
@@ -184,12 +238,14 @@ export function Apps({ token }: { token: string | null }) {
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="primary" onClick={() => setTab('store')}>
|
||||
App Store
|
||||
</Button>
|
||||
<Button onClick={() => setModal({ kind: 'install' })}>Install from file</Button>
|
||||
</>
|
||||
hub ? undefined : (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => setTab('store')}>
|
||||
App Store
|
||||
</Button>
|
||||
<Button onClick={() => setModal({ kind: 'install' })}>Install from file</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -204,17 +260,19 @@ export function Apps({ token }: { token: string | null }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<PageTabs
|
||||
label="Apps"
|
||||
tabs={[
|
||||
{ id: 'installed', label: 'Installed', count: apps?.length },
|
||||
{ id: 'store', label: 'App Store' },
|
||||
]}
|
||||
active={tab}
|
||||
onChoose={setTab}
|
||||
/>
|
||||
{!hub && (
|
||||
<PageTabs
|
||||
label="Apps"
|
||||
tabs={[
|
||||
{ id: 'installed', label: 'Installed', count: apps?.length },
|
||||
{ id: 'store', label: 'App Store' },
|
||||
]}
|
||||
active={tab}
|
||||
onChoose={setTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'store' ? (
|
||||
{tab === 'store' && !hub ? (
|
||||
<StoreApps token={token} onChanged={() => void reload()} />
|
||||
) : apps === null ? (
|
||||
<Loading />
|
||||
@@ -227,13 +285,16 @@ export function Apps({ token }: { token: string | null }) {
|
||||
<Empty
|
||||
title="No apps installed"
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => setTab('store')}>
|
||||
Open App Store
|
||||
</Button>
|
||||
hub ? undefined : (
|
||||
<Button variant="primary" onClick={() => setTab('store')}>
|
||||
Open App Store
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
DNS Apps add behaviour to the server (query logging, advanced blocking, split horizon)
|
||||
without touching the base configuration. Open the store to see what is available.
|
||||
without touching the base configuration.
|
||||
{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">
|
||||
@@ -258,6 +319,7 @@ export function Apps({ token }: { token: string | null }) {
|
||||
key={app.name}
|
||||
app={app}
|
||||
busy={busy === app.name}
|
||||
readOnly={hub}
|
||||
onConfig={() => void openConfig(app.name)}
|
||||
onUpdate={() => setModal({ kind: 'update', name: app.name })}
|
||||
onStoreUpdate={() => void updateFromStore(app)}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, 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 } from '../../api/apps'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
const DATA: AppsMatrixData = {
|
||||
servers: [
|
||||
{ id: 'mdns', ok: true },
|
||||
{ id: 'ndns', ok: true },
|
||||
],
|
||||
apps: [
|
||||
{
|
||||
name: 'Advanced Blocking',
|
||||
description: 'Group based blocking',
|
||||
configs: 'different',
|
||||
diffCount: 2,
|
||||
versions: 'same',
|
||||
servers: {
|
||||
mdns: { installed: true, version: '10.1', configStatus: 'read', configHash: 'aaaa' },
|
||||
ndns: { installed: true, version: '10.1', configStatus: 'read', configHash: 'bbbb' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Query Logs (Sqlite)',
|
||||
description: 'Logs queries',
|
||||
configs: 'same',
|
||||
versions: 'different',
|
||||
servers: {
|
||||
mdns: { installed: true, version: '7.0', configStatus: 'read', configHash: 'cccc' },
|
||||
ndns: { installed: true, version: '7.1', updateAvailable: true, updateVersion: '7.2', configStatus: 'read', configHash: 'cccc' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Split Horizon',
|
||||
description: '',
|
||||
configs: 'single',
|
||||
versions: 'single',
|
||||
servers: {
|
||||
mdns: { installed: true, version: '9.0', configStatus: 'read', configHash: 'dddd' },
|
||||
ndns: { installed: false, configStatus: 'none' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Wild IP',
|
||||
description: '',
|
||||
configs: 'unknown',
|
||||
versions: 'same',
|
||||
servers: {
|
||||
mdns: { installed: true, version: '5.1', configStatus: 'unreadable', configError: 'Access was denied' },
|
||||
ndns: { installed: true, version: '5.1', configStatus: 'read', configHash: 'eeee' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function mockMatrix(data: AppsMatrixData = DATA) {
|
||||
return vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({ kind: 'ok', data: { response: data } } as never)
|
||||
}
|
||||
|
||||
const row = (name: string) => screen.getByText(name).closest('tr') as HTMLElement
|
||||
|
||||
describe('AppsMatrix', () => {
|
||||
it('draws a row per app and a column per server', async () => {
|
||||
mockMatrix()
|
||||
render(<AppsMatrix token="t" />)
|
||||
expect(await screen.findByText('Advanced Blocking')).toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', { name: 'mdns' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', { name: 'ndns' })).toBeInTheDocument()
|
||||
expect(within(row('Split Horizon')).getByText('v9.0')).toBeInTheDocument()
|
||||
expect(within(row('Split Horizon')).getByText('Not installed')).toBeInTheDocument()
|
||||
expect(within(row('Query Logs (Sqlite)')).getByText('v7.1')).toBeInTheDocument()
|
||||
expect(within(row('Query Logs (Sqlite)')).getByText('Update v7.2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the config state of each app', async () => {
|
||||
mockMatrix()
|
||||
render(<AppsMatrix token="t" />)
|
||||
await screen.findByText('Advanced Blocking')
|
||||
expect(within(row('Advanced Blocking')).getByText('Different · 2')).toBeInTheDocument()
|
||||
expect(within(row('Query Logs (Sqlite)')).getByText('Same')).toBeInTheDocument()
|
||||
expect(within(row('Split Horizon')).getByText('Single server')).toBeInTheDocument()
|
||||
expect(within(row('Wild IP')).getByText('Unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains an unknown config in a tooltip', async () => {
|
||||
mockMatrix()
|
||||
render(<AppsMatrix token="t" />)
|
||||
await screen.findByText('Advanced Blocking')
|
||||
await userEvent.hover(within(row('Wild IP')).getByText('Unknown'))
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('mdns: Access was denied')
|
||||
})
|
||||
|
||||
it('filters with counts', async () => {
|
||||
mockMatrix()
|
||||
render(<AppsMatrix token="t" />)
|
||||
await screen.findByText('Advanced Blocking')
|
||||
const filters = screen.getByRole('group', { name: 'Filter apps' })
|
||||
expect(within(filters).getByRole('button', { name: 'All · 4' })).toBeInTheDocument()
|
||||
expect(within(filters).getByRole('button', { name: 'Version differs · 1' })).toBeInTheDocument()
|
||||
expect(within(filters).getByRole('button', { name: 'Config differs · 1' })).toBeInTheDocument()
|
||||
expect(within(filters).getByRole('button', { name: 'Update available · 1' })).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(within(filters).getByRole('button', { name: 'Config differs · 1' }))
|
||||
expect(screen.getByText('Advanced Blocking')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Split Horizon')).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(within(filters).getByRole('button', { name: 'Update available · 1' }))
|
||||
expect(screen.getByText('Query Logs (Sqlite)')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Advanced Blocking')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks a server that did not answer as unknown', async () => {
|
||||
mockMatrix({
|
||||
servers: [{ id: 'mdns', ok: true }, { id: 'ndns', ok: false, error: 'timed out' }],
|
||||
apps: [
|
||||
{
|
||||
name: 'Split Horizon',
|
||||
description: '',
|
||||
configs: 'unknown',
|
||||
versions: 'single',
|
||||
servers: {
|
||||
mdns: { installed: true, version: '9.0', configStatus: 'read' },
|
||||
ndns: { installed: false, configStatus: 'unreadable', configError: 'ndns did not answer' },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
render(<AppsMatrix token="t" />)
|
||||
await screen.findByText('Split Horizon')
|
||||
expect(within(row('Split Horizon')).getByText('v9.0')).toBeInTheDocument()
|
||||
expect(within(row('Split Horizon')).getAllByText('Unknown')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('says so when no filter matches, with a way back', async () => {
|
||||
mockMatrix({ ...DATA, apps: [DATA.apps[2]] })
|
||||
render(<AppsMatrix token="t" />)
|
||||
await screen.findByText('Split Horizon')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Config differs · 0' }))
|
||||
expect(screen.getByText('No apps match')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Show all' }))
|
||||
expect(screen.getByText('Split Horizon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has an empty state with an action', async () => {
|
||||
const spy = mockMatrix({ servers: DATA.servers, apps: [] })
|
||||
render(<AppsMatrix token="t" />)
|
||||
expect(await screen.findByText('No apps installed')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }))
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('shows loading and then an error with Retry', async () => {
|
||||
const spy = vi.spyOn(api, 'getAppsMatrix').mockResolvedValueOnce({ kind: 'error', message: 'No server answered.' } as never)
|
||||
render(<AppsMatrix token="t" />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading')
|
||||
expect(await screen.findByText(/Could not load the apps/)).toHaveTextContent('No server answered.')
|
||||
spy.mockResolvedValue({ kind: 'ok', data: { response: DATA } } as never)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('Advanced Blocking')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Apps behind a hub', () => {
|
||||
const INSTALLED = {
|
||||
name: 'Split Horizon',
|
||||
description: 'Different answers',
|
||||
version: '9.0',
|
||||
dnsApps: [],
|
||||
}
|
||||
const NODES = [{ name: 'mdns', type: 'Primary' }, { name: 'ndns', type: 'Secondary' }]
|
||||
|
||||
function mount(hub: boolean) {
|
||||
return render(
|
||||
<HubMode.Provider value={hub}>
|
||||
<SlotProvider>
|
||||
{(ref) => (
|
||||
<>
|
||||
<div ref={ref} />
|
||||
<Apps token="t" nodes={NODES} clusterInitialised />
|
||||
</>
|
||||
)}
|
||||
</SlotProvider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
it('shows the selector and the matrix for All servers', async () => {
|
||||
const matrix = mockMatrix()
|
||||
const list = vi.spyOn(api, 'listApps')
|
||||
mount(true)
|
||||
expect(await screen.findByText('Advanced Blocking')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('combobox', { name: 'Server' })).toHaveTextContent('All servers')
|
||||
expect(matrix).toHaveBeenCalled()
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: 'App Store' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows one server read-only and remembers the choice', async () => {
|
||||
mockMatrix()
|
||||
const list = vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
|
||||
mount(true)
|
||||
await userEvent.click(await screen.findByRole('combobox', { name: 'Server' }))
|
||||
await userEvent.click(screen.getByRole('option', { name: 'ndns' }))
|
||||
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
|
||||
expect(list).toHaveBeenCalledWith('t', 'ndns')
|
||||
expect(localStorage.getItem('hubAppsNode')).toBe('ndns')
|
||||
for (const name of ['Config', 'Update', 'Store Update', 'App Store', 'Install from file']) {
|
||||
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
|
||||
}
|
||||
expect(screen.queryByRole('button', { name: /Actions: Split Horizon/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('tab', { name: /App Store/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts on the remembered server', async () => {
|
||||
localStorage.setItem('hubAppsNode', 'mdns')
|
||||
const list = vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
|
||||
mount(true)
|
||||
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
|
||||
expect(list).toHaveBeenCalledWith('t', 'mdns')
|
||||
})
|
||||
|
||||
it('is unchanged outside a hub', async () => {
|
||||
const matrix = mockMatrix()
|
||||
const list = vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
|
||||
mount(false)
|
||||
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
|
||||
expect(list).toHaveBeenCalledWith('t', '')
|
||||
expect(matrix).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('combobox', { name: 'Server' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'App Store' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Config' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { getAppsMatrix, type AppsMatrixData, 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 { Tag } from '../../ui/Tag'
|
||||
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'
|
||||
|
||||
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 }: { app: MatrixApp; data: AppsMatrixData }) {
|
||||
switch (app.configs) {
|
||||
case 'same':
|
||||
return <Tag tone="ok">Same</Tag>
|
||||
case 'different':
|
||||
return <Tag tone="warn">{app.diffCount === undefined ? 'Different' : `Different · ${app.diffCount}`}</Tag>
|
||||
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 }: { app: MatrixApp; id: string; data: AppsMatrixData }) {
|
||||
const cell = app.servers[id]
|
||||
if (data.servers.find((s) => s.id === id)?.ok === false) return <span className="text-sm text-muted-foreground">Unknown</span>
|
||||
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>
|
||||
{cell.updateAvailable === true && (
|
||||
<Tag tone="warn">{cell.updateVersion === undefined ? 'Update' : `Update v${cell.updateVersion}`}</Tag>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppsMatrix({ token }: { token: string | null }) {
|
||||
const [data, setData] = useState<AppsMatrixData | 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()
|
||||
}, [load])
|
||||
|
||||
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])
|
||||
|
||||
const retry = (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setFailure(null)
|
||||
setData(null)
|
||||
void load()
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (failure !== null && data === null) {
|
||||
return (
|
||||
<Failure>
|
||||
<span className="flex flex-col items-center gap-3">
|
||||
<span>Could not load the apps of the servers. {failure}</span>
|
||||
{retry}
|
||||
</span>
|
||||
</Failure>
|
||||
)
|
||||
}
|
||||
if (data === null) return <Loading>Loading the apps of every server…</Loading>
|
||||
|
||||
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])
|
||||
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="text-xs text-muted-foreground">{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} />,
|
||||
}),
|
||||
),
|
||||
{ id: 'config', ...head({ children: 'Config' }), cell: (a) => <ConfigState app={a} data={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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user