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:
DenozordecandClaude Opus 5.5 committed 2026-10-31 15:46:51 +07:00
1 parent 0ef2d53a65
commit 3d8df08fa0
14 files changed
+1082 -37

No files matched your search

+32
View File
@@ -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
View File
@@ -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' ? (
+7
View File
@@ -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
View File
@@ -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)}
+243
View File
@@ -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()
})
})
+193
View File
@@ -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>
)
}