Hub stage 3: compare and sync app configs across servers
The hub keeps a baseline per app in /data/apps/<app>/baseline.json. Local fields (overrides.json) are left out of the sync and out of the matrix comparison. Each server gets the baseline plus its own values. Applying goes target picker, preview with per-server diffs and warnings, then apply. Apply refuses a server whose config changed since the preview, saves the old config to history, then reads the new one back to check it. Large config bodies are read only for signed-in callers. The App config dialog has Compare, Baseline and Local fields tabs and an empty state that offers an import. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6acd2d656d
commit
0e05039202
22 files changed
+2434
-58
No files matched your search
+98
-1
@@ -142,8 +142,9 @@ export function setAppConfig(
|
||||
token: string | null,
|
||||
name: string,
|
||||
config: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('apps/config/set', { token, method: 'POST', body: { name, config } })
|
||||
return apiRequest('apps/config/set', { token, method: 'POST', body: { name, config }, node })
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -261,3 +262,99 @@ export function uploadHubApp(
|
||||
form.append('fileAppZip', file)
|
||||
return apiRequest('hub/apps/upload', { token, form })
|
||||
}
|
||||
|
||||
/*
|
||||
Config sync behind a hub (hub/apps-config.ts): the comparison of one app's config across
|
||||
servers, the baseline kept in the hub, the fields that stay local to each server, and the
|
||||
preview and apply that bring servers to baseline plus their own local values.
|
||||
*/
|
||||
export interface FieldChange {
|
||||
path: string
|
||||
type: 'added' | 'removed' | 'changed'
|
||||
a?: unknown
|
||||
b?: unknown
|
||||
}
|
||||
|
||||
export interface LineOp {
|
||||
op: 'same' | 'add' | 'del'
|
||||
text: string
|
||||
}
|
||||
|
||||
export type ConfigDiff =
|
||||
| { kind: 'json'; changes: FieldChange[] }
|
||||
| { kind: 'text'; lines: LineOp[]; truncated: boolean; changed: number }
|
||||
|
||||
export interface Baseline {
|
||||
app: string
|
||||
text: string
|
||||
importedFrom?: string
|
||||
updatedAt: string
|
||||
updatedBy: string
|
||||
}
|
||||
|
||||
export interface Overrides {
|
||||
paths: string[]
|
||||
values: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
export interface ServerConfigView {
|
||||
id: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
installed: boolean
|
||||
writable: boolean
|
||||
version?: string
|
||||
text: string | null
|
||||
hash: string
|
||||
effective?: string
|
||||
warnings: string[]
|
||||
/** Null without a baseline. */
|
||||
inSync: boolean | null
|
||||
vsBaseline?: ConfigDiff
|
||||
vsOthers: Record<string, ConfigDiff>
|
||||
}
|
||||
|
||||
export interface ConfigCompare {
|
||||
name: string
|
||||
baseline: Baseline | null
|
||||
overrides: Overrides
|
||||
servers: ServerConfigView[]
|
||||
}
|
||||
|
||||
export interface TargetPreview {
|
||||
server: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
writable: boolean
|
||||
current: string | null
|
||||
currentHash: string
|
||||
effective: string
|
||||
changes: ConfigDiff
|
||||
unchanged: boolean
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export const getConfigCompare = (token: string | null, name: string): Promise<ApiOutcome<{ response: ConfigCompare }>> =>
|
||||
apiRequest('hub/apps/config/compare', { token, body: { name } })
|
||||
|
||||
export const importBaseline = (token: string | null, name: string, server: string): Promise<ApiOutcome<{ response: { baseline: Baseline } }>> =>
|
||||
apiRequest('hub/apps/baseline/import', { token, method: 'POST', body: { name, server } })
|
||||
|
||||
export const saveBaseline = (token: string | null, name: string, text: string): Promise<ApiOutcome<{ response: { baseline: Baseline } }>> =>
|
||||
apiRequest('hub/apps/baseline/save', { token, method: 'POST', body: { name, text } })
|
||||
|
||||
export const saveOverrides = (token: string | null, name: string, overrides: Overrides): Promise<ApiOutcome<{ response: { overrides: Overrides } }>> =>
|
||||
apiRequest('hub/apps/overrides/save', {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: { name, paths: overrides.paths.join(','), values: JSON.stringify(overrides.values) },
|
||||
})
|
||||
|
||||
export const getCurrentLocalValues = (token: string | null, name: string, paths: string[]): Promise<ApiOutcome<{ response: { values: Overrides['values'] } }>> =>
|
||||
apiRequest('hub/apps/overrides/current', { token, method: 'POST', body: { name, paths: paths.join(',') } })
|
||||
|
||||
export const previewConfig = (token: string | null, name: string, targets: string[]): Promise<ApiOutcome<{ response: { targets: TargetPreview[] } }>> =>
|
||||
apiRequest('hub/apps/config/preview', { token, method: 'POST', body: { name, targets: targets.join(',') } })
|
||||
|
||||
export const applyConfig = (token: string | null, name: string, targets: string[], expect: Record<string, string>): Promise<RunOutcome> =>
|
||||
apiRequest('hub/apps/config/apply', { token, method: 'POST', body: { name, targets: targets.join(','), expect: JSON.stringify(expect) } })
|
||||
@@ -25,6 +25,7 @@ export function AppConfig({
|
||||
name,
|
||||
initialConfig,
|
||||
onSaved,
|
||||
node = '',
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
@@ -32,6 +33,8 @@ export function AppConfig({
|
||||
name: string
|
||||
initialConfig: string
|
||||
onSaved: (name: string) => void
|
||||
/** Behind a hub: the one server it goes to. */
|
||||
node?: string
|
||||
}) {
|
||||
const [config, setConfig] = useState(initialConfig)
|
||||
const [alert, setAlert] = useState<AlertState | null>(null)
|
||||
@@ -47,7 +50,7 @@ export function AppConfig({
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
const outcome = await setAppConfig(token, name, config)
|
||||
const outcome = await setAppConfig(token, name, config, node)
|
||||
setBusy(false)
|
||||
|
||||
if (outcome.kind !== 'ok') {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
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 { AppConfigSync } from './AppConfigSync'
|
||||
import { AppsMatrix } from './AppsMatrix'
|
||||
import * as api from '../../api/apps'
|
||||
import type { ConfigCompare, ServerConfigView } from '../../api/apps'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const A = JSON.stringify({ ttl: 30, db: '/a.db' }, null, 2)
|
||||
const B = JSON.stringify({ ttl: 60, db: '/b.db' }, null, 2)
|
||||
|
||||
function view(id: string, over: Partial<ServerConfigView> = {}): ServerConfigView {
|
||||
return { id, ok: true, installed: true, writable: true, version: '1.0', text: A, hash: 'h-' + id, warnings: [], inSync: null, vsOthers: {}, ...over }
|
||||
}
|
||||
|
||||
/* mdns is the baseline; ndns differs in ttl and in the local db path. */
|
||||
const WITH_BASELINE: ConfigCompare = {
|
||||
name: 'Blocker',
|
||||
baseline: { app: 'Blocker', text: A, importedFrom: 'mdns', updatedAt: '2026-10-31T08:00:00Z', updatedBy: 'alice' },
|
||||
overrides: { paths: ['db'], values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } },
|
||||
servers: [
|
||||
view('mdns', { inSync: true, vsBaseline: { kind: 'json', changes: [] } }),
|
||||
view('ndns', {
|
||||
text: B,
|
||||
inSync: false,
|
||||
vsBaseline: { kind: 'json', changes: [{ path: 'ttl', type: 'changed', a: 60, b: 30 }, { path: 'db', type: 'changed', a: '/x.db', b: '/b.db' }] },
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
const NO_BASELINE: ConfigCompare = {
|
||||
name: 'Blocker',
|
||||
baseline: null,
|
||||
overrides: { paths: [], values: {} },
|
||||
servers: [
|
||||
view('mdns', { vsOthers: { ndns: { kind: 'json', changes: [{ path: 'ttl', type: 'changed', a: 60, b: 30 }] } } }),
|
||||
view('ndns', { text: B }),
|
||||
],
|
||||
}
|
||||
|
||||
function mockCompare(data: ConfigCompare) {
|
||||
return vi.spyOn(api, 'getConfigCompare').mockResolvedValue({ kind: 'ok', data: { response: data } } as never)
|
||||
}
|
||||
|
||||
const ok = (results: api.TargetResult[]) => ({ kind: 'ok', data: { response: { results } } }) as never
|
||||
|
||||
function mount(canWrite: boolean) {
|
||||
const onChanged = vi.fn()
|
||||
render(<AppConfigSync token="t" name="Blocker" canWrite={canWrite} onClose={() => {}} onChanged={onChanged} />)
|
||||
return { onChanged }
|
||||
}
|
||||
|
||||
describe('Compare', () => {
|
||||
it('shows a skeleton while loading, then the badges and the fields that differ', async () => {
|
||||
mockCompare(WITH_BASELINE)
|
||||
mount(false)
|
||||
expect(screen.getByRole('status', { name: 'Loading the configs' })).toBeInTheDocument()
|
||||
expect(await screen.findByText('In sync')).toBeInTheDocument()
|
||||
expect(screen.getByText('Differs')).toBeInTheDocument()
|
||||
const dialog = screen.getByRole('dialog', { name: 'App config: Blocker' })
|
||||
expect(within(dialog).getByRole('columnheader', { name: 'ndns' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('ttl')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('60')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('expected 30')).toBeInTheDocument()
|
||||
// The local field is marked.
|
||||
const dbRow = within(dialog).getByText('db').closest('tr') as HTMLElement
|
||||
expect(within(dbRow).getByText('Local')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('compares the servers to each other when there is no baseline', async () => {
|
||||
mockCompare(NO_BASELINE)
|
||||
mount(false)
|
||||
expect(await screen.findByText('No baseline yet')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('No baseline').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('ttl')).toBeInTheDocument()
|
||||
expect(screen.getByText('30')).toBeInTheDocument()
|
||||
expect(screen.getByText('60')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows lines for a text config', async () => {
|
||||
mockCompare({
|
||||
...WITH_BASELINE,
|
||||
servers: [
|
||||
view('mdns', { inSync: true, vsBaseline: { kind: 'text', lines: [], truncated: false, changed: 0 } }),
|
||||
view('ndns', { inSync: false, vsBaseline: { kind: 'text', lines: [{ op: 'same', text: 'a = 1' }, { op: 'del', text: 'b = 2' }, { op: 'add', text: 'b = 3' }], truncated: false, changed: 2 } }),
|
||||
],
|
||||
})
|
||||
mount(false)
|
||||
const lines = await screen.findByLabelText('Line differences')
|
||||
expect(within(lines).getByText(/b = 3/)).toHaveAttribute('data-op', 'add')
|
||||
expect(within(lines).getByText(/b = 2/)).toHaveAttribute('data-op', 'del')
|
||||
})
|
||||
|
||||
it('shows an error with Retry', async () => {
|
||||
const spy = vi.spyOn(api, 'getConfigCompare').mockResolvedValueOnce({ kind: 'error', message: 'No server answered.' } as never)
|
||||
mount(false)
|
||||
expect(await screen.findByText(/Could not read the configs/)).toHaveTextContent('No server answered.')
|
||||
spy.mockResolvedValue({ kind: 'ok', data: { response: WITH_BASELINE } } as never)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('In sync')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('role gating', () => {
|
||||
it('gives a reader no editing controls', async () => {
|
||||
mockCompare(WITH_BASELINE)
|
||||
mount(false)
|
||||
await screen.findByText('In sync')
|
||||
expect(screen.queryByRole('button', { name: 'Apply…' })).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Baseline' }))
|
||||
expect(screen.getByLabelText('Baseline text')).toHaveAttribute('readonly')
|
||||
expect(screen.queryByRole('button', { name: 'Save baseline' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^Import from/ })).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Local fields' }))
|
||||
expect(screen.getByLabelText('db on mdns')).toHaveAttribute('readonly')
|
||||
expect(screen.queryByRole('button', { name: 'Save local fields' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tells a reader who sees no baseline to ask, without import buttons', async () => {
|
||||
mockCompare(NO_BASELINE)
|
||||
mount(false)
|
||||
await screen.findByText('No baseline yet')
|
||||
expect(screen.queryByRole('button', { name: /^Import from/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/apps administrator/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers a writer the import from each server when there is no baseline', async () => {
|
||||
const spy = mockCompare(NO_BASELINE)
|
||||
const imp = vi.spyOn(api, 'importBaseline').mockResolvedValue({ kind: 'ok', data: { response: { baseline: WITH_BASELINE.baseline } } } as never)
|
||||
const { onChanged } = mount(true)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Import from mdns' }))
|
||||
expect(imp).toHaveBeenCalledWith('t', 'Blocker', 'mdns')
|
||||
await waitFor(() => expect(spy.mock.calls.length).toBeGreaterThan(1))
|
||||
expect(onChanged).toHaveBeenCalled()
|
||||
expect(await screen.findByText('Baseline imported from mdns.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Baseline and Local fields', () => {
|
||||
it('shows who updated the baseline and a validation error from the hub inline', async () => {
|
||||
mockCompare(WITH_BASELINE)
|
||||
const save = vi.spyOn(api, 'saveBaseline').mockResolvedValue({ kind: 'error', message: 'The baseline is not valid JSON: Unexpected token at position 4' } as never)
|
||||
mount(true)
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Baseline' }))
|
||||
expect(screen.getByText(/Imported from mdns\. Updated by alice/)).toBeInTheDocument()
|
||||
const box = screen.getByLabelText('Baseline text')
|
||||
await userEvent.clear(box)
|
||||
await userEvent.type(box, 'x')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save baseline' }))
|
||||
expect(save).toHaveBeenCalledWith('t', 'Blocker', 'x')
|
||||
expect(await screen.findByText(/not valid JSON: Unexpected token at position 4/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a local field, fills the values from the servers and saves them', async () => {
|
||||
mockCompare({ ...WITH_BASELINE, overrides: { paths: [], values: {} } })
|
||||
const current = vi.spyOn(api, 'getCurrentLocalValues').mockResolvedValue({ kind: 'ok', data: { response: { values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } } } } as never)
|
||||
const save = vi.spyOn(api, 'saveOverrides').mockResolvedValue({ kind: 'ok', data: { response: { overrides: WITH_BASELINE.overrides } } } as never)
|
||||
mount(true)
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Local fields' }))
|
||||
expect(screen.getByText('No local fields yet.')).toBeInTheDocument()
|
||||
await userEvent.type(screen.getByLabelText('New local field'), 'db')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add field' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Fill from the current server configs' }))
|
||||
expect(current).toHaveBeenCalledWith('t', 'Blocker', ['db'])
|
||||
expect(await screen.findByDisplayValue('"/b.db"')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save local fields' }))
|
||||
expect(save).toHaveBeenCalledWith('t', 'Blocker', { paths: ['db'], values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } })
|
||||
})
|
||||
|
||||
it('refuses a value that is not JSON, without calling the hub', async () => {
|
||||
mockCompare(WITH_BASELINE)
|
||||
const save = vi.spyOn(api, 'saveOverrides')
|
||||
mount(true)
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Local fields' }))
|
||||
const box = screen.getByLabelText('db on mdns')
|
||||
await userEvent.clear(box)
|
||||
await userEvent.type(box, 'no quotes')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save local fields' }))
|
||||
expect(await screen.findByText(/db on mdns is not valid JSON/)).toBeInTheDocument()
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Apply', () => {
|
||||
it('picks servers, previews, applies with the preview hashes, and retries only what failed', async () => {
|
||||
mockCompare({ ...WITH_BASELINE, servers: [WITH_BASELINE.servers[0], { ...WITH_BASELINE.servers[1], writable: true }] })
|
||||
const preview = vi.spyOn(api, 'previewConfig').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: {
|
||||
response: {
|
||||
targets: [
|
||||
{ server: 'ndns', ok: true, writable: true, current: B, currentHash: 'hash-ndns', effective: A, unchanged: false, warnings: ['Version 0.9 here, 1.0 on mdns: versions differ, align versions first.'], changes: WITH_BASELINE.servers[1].vsBaseline },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
const apply = vi
|
||||
.spyOn(api, 'applyConfig')
|
||||
.mockResolvedValueOnce(ok([{ server: 'ndns', ok: false, message: 'The config is invalid.' }]))
|
||||
.mockResolvedValueOnce(ok([{ server: 'ndns', ok: true, message: 'Applied and verified.' }]))
|
||||
const { onChanged } = mount(true)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Apply…' }))
|
||||
|
||||
const picker = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })
|
||||
// Only the server that differs is ticked.
|
||||
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeChecked()
|
||||
expect(within(picker).getByRole('checkbox', { name: 'mdns' })).not.toBeChecked()
|
||||
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
|
||||
expect(preview).toHaveBeenCalledWith('t', 'Blocker', ['ndns'])
|
||||
|
||||
const review = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker: review' })
|
||||
expect(within(review).getByText('Will change')).toBeInTheDocument()
|
||||
expect(within(review).getByText(/align versions first/)).toBeInTheDocument()
|
||||
expect(within(review).getByText('ttl')).toBeInTheDocument()
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
await userEvent.click(within(review).getByRole('button', { name: 'Apply' }))
|
||||
expect(apply).toHaveBeenLastCalledWith('t', 'Blocker', ['ndns'], { ndns: 'hash-ndns' })
|
||||
|
||||
const results = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker: results' })
|
||||
expect(within(results).getByText('The config is invalid.')).toBeInTheDocument()
|
||||
await userEvent.click(within(results).getByRole('button', { name: 'Retry failed' }))
|
||||
expect(apply).toHaveBeenCalledTimes(2)
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog')).getByText('Applied and verified.')).toBeInTheDocument())
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement)
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('cannot apply without a baseline, and lists a server without a token as disabled', async () => {
|
||||
mockCompare(NO_BASELINE)
|
||||
mount(true)
|
||||
await screen.findByText('No baseline yet')
|
||||
expect(screen.getByRole('button', { name: 'Apply…' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables a server the hub cannot write to in the picker', async () => {
|
||||
mockCompare({ ...WITH_BASELINE, servers: [WITH_BASELINE.servers[0], { ...WITH_BASELINE.servers[1], writable: false }] })
|
||||
mount(true)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Apply…' }))
|
||||
const picker = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })
|
||||
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
|
||||
expect(within(picker).getByText('No admin token configured on the hub')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a review failure from the hub', async () => {
|
||||
mockCompare(WITH_BASELINE)
|
||||
vi.spyOn(api, 'previewConfig').mockResolvedValue({ kind: 'error', message: 'This app has no baseline yet.' } as never)
|
||||
mount(true)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Apply…' }))
|
||||
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })).getByRole('button', { name: 'Preview' }))
|
||||
expect(await screen.findByText('This app has no baseline yet.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('from the matrix', () => {
|
||||
const DATA: api.AppsMatrixData = {
|
||||
servers: [{ id: 'mdns', ok: true, writable: true }, { id: 'ndns', ok: true, writable: true }],
|
||||
apps: [
|
||||
{
|
||||
name: 'Blocker', description: '', configs: 'different', diffCount: 2, versions: 'same',
|
||||
servers: { mdns: { installed: true, version: '1.0', configStatus: 'read' }, ndns: { installed: true, version: '1.0', configStatus: 'read' } },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('opens the config view from the row menu for a reader and from the Different badge', async () => {
|
||||
vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({ kind: 'ok', data: { response: DATA } } as never)
|
||||
const compare = mockCompare(WITH_BASELINE)
|
||||
render(<AppsMatrix token="t" />)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Actions: Blocker' }))
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: 'Config…' }))
|
||||
expect(await screen.findByRole('dialog', { name: 'App config: Blocker' })).toBeInTheDocument()
|
||||
expect(compare).toHaveBeenCalledWith('t', 'Blocker')
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Compare the configs of Blocker' }))
|
||||
expect(await screen.findByRole('dialog', { name: 'App config: Blocker' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
applyConfig,
|
||||
getConfigCompare,
|
||||
getCurrentLocalValues,
|
||||
importBaseline,
|
||||
previewConfig,
|
||||
saveBaseline,
|
||||
saveOverrides,
|
||||
type ConfigCompare,
|
||||
type ServerConfigView,
|
||||
} from '../../api/apps'
|
||||
import { Alert } from '../../ui/Alert'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { Dialog } from '../../ui/Dialog'
|
||||
import { Empty, Failure } from '../../ui/Empty'
|
||||
import { Input, LabeledTextarea } from '../../ui/Field'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { DiffView, FieldTable, fieldRows } from './ConfigDiffView'
|
||||
import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
|
||||
|
||||
/*
|
||||
One app's config across the servers, behind a hub: compare them, keep a baseline in the hub,
|
||||
mark the fields that stay local to each server, then preview and apply. Everyone who can read
|
||||
sees the comparison; the editors and Apply are for roles that may change apps.
|
||||
*/
|
||||
|
||||
type Tab = 'compare' | 'baseline' | 'local'
|
||||
|
||||
const when = (iso: string): string => (iso === '' ? '' : new Date(iso).toLocaleString())
|
||||
|
||||
function ServerBadge({ s, hasBaseline }: { s: ServerConfigView; hasBaseline: boolean }) {
|
||||
if (!s.ok) return <Tag tone="dan">Did not answer</Tag>
|
||||
if (!s.installed) return <Tag>Not installed</Tag>
|
||||
if (!hasBaseline || s.inSync === null) return <Tag>No baseline</Tag>
|
||||
return s.inSync ? <Tag tone="ok">In sync</Tag> : <Tag tone="warn">Differs</Tag>
|
||||
}
|
||||
|
||||
export function AppConfigSync({
|
||||
token,
|
||||
name,
|
||||
canWrite,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
token: string | null
|
||||
name: string
|
||||
canWrite: boolean
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const [data, setData] = useState<ConfigCompare | null>(null)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<Tab>('compare')
|
||||
const [request, setRequest] = useState<RunRequest | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
const [saved, setSaved] = useState<string | null>(null)
|
||||
// The editors.
|
||||
const [baselineText, setBaselineText] = useState('')
|
||||
const [paths, setPaths] = useState<string[]>([])
|
||||
const [values, setValues] = useState<Record<string, Record<string, string>>>({})
|
||||
const [newPath, setNewPath] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const outcome = await getConfigCompare(token, name)
|
||||
if (outcome.kind !== 'ok') {
|
||||
setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
return
|
||||
}
|
||||
const d = outcome.data.response
|
||||
setData(d)
|
||||
setFailure(null)
|
||||
setBaselineText(d.baseline?.text ?? '')
|
||||
setPaths(d.overrides.paths)
|
||||
setValues(
|
||||
Object.fromEntries(d.servers.map((s) => [s.id, Object.fromEntries(d.overrides.paths.flatMap((p) => (Object.hasOwn(d.overrides.values[s.id] ?? {}, p) ? [[p, JSON.stringify(d.overrides.values[s.id][p])]] : [])))])),
|
||||
)
|
||||
}, [token, name])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const hasBaseline = data?.baseline != null
|
||||
|
||||
async function run(call: () => Promise<{ kind: string; message?: string }>, done: string) {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
setSaved(null)
|
||||
const outcome = await call()
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') {
|
||||
setProblem(outcome.kind === 'error' ? (outcome.message ?? 'Failed.') : 'The session ended.')
|
||||
return
|
||||
}
|
||||
setSaved(done)
|
||||
await load()
|
||||
onChanged()
|
||||
}
|
||||
|
||||
function saveLocal() {
|
||||
const parsed: Record<string, Record<string, unknown>> = {}
|
||||
for (const [server, mine] of Object.entries(values)) {
|
||||
parsed[server] = {}
|
||||
for (const [path, text] of Object.entries(mine)) {
|
||||
if (!paths.includes(path) || text.trim() === '') continue
|
||||
try {
|
||||
parsed[server][path] = JSON.parse(text)
|
||||
} catch {
|
||||
setProblem(`The value of ${path} on ${server} is not valid JSON (a string needs quotes).`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
void run(() => saveOverrides(token, name, { paths, values: parsed }), 'Local fields saved.')
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
const outcome = await getCurrentLocalValues(token, name, paths)
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') {
|
||||
setProblem(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
return
|
||||
}
|
||||
setValues(Object.fromEntries(Object.entries(outcome.data.response.values).map(([s, mine]) => [s, Object.fromEntries(Object.entries(mine).map(([p, v]) => [p, JSON.stringify(v)]))])))
|
||||
}
|
||||
|
||||
function startApply() {
|
||||
if (data === null) return
|
||||
const servers = data.servers
|
||||
setRequest({
|
||||
title: `Apply the baseline of ${name}`,
|
||||
intro: 'Each server gets the baseline plus its own local values. You see what changes before anything is written.',
|
||||
confirmLabel: 'Preview',
|
||||
servers: pickerServers(
|
||||
servers.map((s) => ({ id: s.id, writable: s.writable, ok: s.ok })),
|
||||
Object.fromEntries(servers.filter((s) => s.ok && !s.installed).map((s) => [s.id, 'Not installed'])),
|
||||
),
|
||||
preselected: servers.filter((s) => s.installed && s.inSync === false).map((s) => s.id),
|
||||
review: async (targets) => {
|
||||
const outcome = await previewConfig(token, name, targets)
|
||||
if (outcome.kind !== 'ok') return { kind: 'error', message: outcome.kind === 'error' ? outcome.message : 'The session ended.' }
|
||||
const previews = outcome.data.response.targets
|
||||
return {
|
||||
kind: 'ok',
|
||||
confirmLabel: 'Apply',
|
||||
context: Object.fromEntries(previews.filter((p) => p.ok).map((p) => [p.server, p.currentHash])),
|
||||
body: (
|
||||
<div className="flex flex-col gap-6">
|
||||
{previews.map((p) => (
|
||||
<section key={p.server} aria-label={`Preview ${p.server}`} className="flex flex-col gap-3">
|
||||
<h3 className="m-0 flex items-center gap-2 text-sm font-semibold">
|
||||
{p.server}
|
||||
{!p.ok ? <Tag tone="dan">Cannot apply</Tag> : p.unchanged ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">Will change</Tag>}
|
||||
</h3>
|
||||
{p.error !== undefined && <Alert type="danger" title="Error">{p.error}</Alert>}
|
||||
{p.warnings.map((w) => (
|
||||
<Alert key={w} type="warning" title="Warning">
|
||||
{w}
|
||||
</Alert>
|
||||
))}
|
||||
{p.ok && !p.unchanged && <DiffView diff={p.changes} from="Now" to="After apply" />}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
},
|
||||
run: (targets, context) => applyConfig(token, name, targets, (context ?? {}) as Record<string, string>),
|
||||
})
|
||||
}
|
||||
|
||||
const importButtons = (data?.servers ?? []).filter((s) => s.installed && s.ok)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose()
|
||||
}}
|
||||
size="wide"
|
||||
title={`App config: ${name}`}
|
||||
actions={
|
||||
canWrite ? (
|
||||
<Button variant="primary" disabled={!hasBaseline || busy} onClick={startApply}>
|
||||
Apply…
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{failure !== null && data === null ? (
|
||||
<Failure>
|
||||
<span className="flex flex-col items-center gap-3">
|
||||
<span>Could not read the configs. {failure}</span>
|
||||
<Button variant="primary" onClick={() => void load()}>
|
||||
Retry
|
||||
</Button>
|
||||
</span>
|
||||
</Failure>
|
||||
) : data === null ? (
|
||||
<div className="flex flex-col gap-3" role="status" aria-label="Loading the configs">
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as Tab)}
|
||||
// The project has no `data-horizontal` variant, so the component's own flex-col never applies.
|
||||
className="flex-col"
|
||||
>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="compare">Compare</TabsTrigger>
|
||||
<TabsTrigger value="baseline">Baseline</TabsTrigger>
|
||||
<TabsTrigger value="local">Local fields</TabsTrigger>
|
||||
</TabsList>
|
||||
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
|
||||
{saved !== null && <Alert type="success" title="Saved." onDismiss={() => setSaved(null)}>{saved}</Alert>}
|
||||
|
||||
<TabsContent value="compare" className="flex flex-col gap-4 pt-3">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{data.servers.map((s) => (
|
||||
<span key={s.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{s.id}</span>
|
||||
{s.version !== undefined && <span className="text-xs text-muted-foreground">v{s.version}</span>}
|
||||
<ServerBadge s={s} hasBaseline={hasBaseline} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{data.servers.filter((s) => s.error !== undefined).map((s) => (
|
||||
<Alert key={s.id} type="warning" title={s.id}>
|
||||
{s.error}
|
||||
</Alert>
|
||||
))}
|
||||
{!hasBaseline && (
|
||||
<Empty
|
||||
title="No baseline yet"
|
||||
actions={
|
||||
canWrite ? (
|
||||
<span className="flex flex-wrap justify-center gap-2">
|
||||
{importButtons.map((s) => (
|
||||
<Button key={s.id} variant="primary" disabled={busy} onClick={() => void run(() => importBaseline(token, name, s.id), `Baseline imported from ${s.id}.`)}>
|
||||
Import from {s.id}
|
||||
</Button>
|
||||
))}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
The baseline is the config every server should have. Import it from a server that is right, then mark the fields that must differ per server as local.
|
||||
{!canWrite && ' An apps administrator can set it up.'}
|
||||
</Empty>
|
||||
)}
|
||||
<CompareBody data={data} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="baseline" className="flex flex-col gap-3 pt-3">
|
||||
<p className="m-0 text-xs text-muted-foreground">
|
||||
{data.baseline === null
|
||||
? 'No baseline saved.'
|
||||
: `${data.baseline.importedFrom !== undefined ? `Imported from ${data.baseline.importedFrom}. ` : ''}Updated by ${data.baseline.updatedBy} at ${when(data.baseline.updatedAt)}.`}
|
||||
</p>
|
||||
<LabeledTextarea
|
||||
label="Baseline text"
|
||||
mono
|
||||
rows={16}
|
||||
spellCheck={false}
|
||||
readOnly={!canWrite}
|
||||
value={baselineText}
|
||||
onChange={(e) => setBaselineText(e.target.value)}
|
||||
/>
|
||||
{canWrite && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" disabled={busy} onClick={() => void run(() => saveBaseline(token, name, baselineText), 'Baseline saved.')}>
|
||||
Save baseline
|
||||
</Button>
|
||||
{importButtons.map((s) => (
|
||||
<Button key={s.id} disabled={busy} onClick={() => void run(() => importBaseline(token, name, s.id), `Baseline imported from ${s.id}.`)}>
|
||||
Import from {s.id}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="local" className="flex flex-col gap-4 pt-3">
|
||||
<p className="m-0 text-xs text-muted-foreground">
|
||||
Local fields are not synchronised: each server keeps its own value. Name a field by its path, for example <code>db.path</code> or <code>networks.0.cidr</code>. A value is JSON, so a string needs quotes.
|
||||
</p>
|
||||
{paths.length === 0 && <p className="m-0 text-sm text-muted-foreground">No local fields yet.</p>}
|
||||
{paths.map((p) => (
|
||||
<fieldset key={p} className="m-0 flex flex-col gap-2 border-0 p-0">
|
||||
<legend className="flex items-center gap-2 p-0 font-mono text-xs">
|
||||
{p}
|
||||
{canWrite && (
|
||||
<Button size="sm" onClick={() => setPaths((old) => old.filter((x) => x !== p))}>
|
||||
Remove {p}
|
||||
</Button>
|
||||
)}
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{data.servers.map((s) => (
|
||||
<label key={s.id} className="flex min-w-48 flex-1 flex-col gap-1 text-xs">
|
||||
<span>
|
||||
{p} on {s.id}
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
readOnly={!canWrite}
|
||||
aria-label={`${p} on ${s.id}`}
|
||||
placeholder="(no local value)"
|
||||
value={values[s.id]?.[p] ?? ''}
|
||||
onChange={(e) => setValues((old) => ({ ...old, [s.id]: { ...(old[s.id] ?? {}), [p]: e.target.value } }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
{canWrite && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span>New local field</span>
|
||||
<Input mono aria-label="New local field" placeholder="db.path" value={newPath} onChange={(e) => setNewPath(e.target.value)} />
|
||||
</label>
|
||||
<Button
|
||||
disabled={newPath.trim() === '' || paths.includes(newPath.trim())}
|
||||
onClick={() => {
|
||||
setPaths((old) => [...old, newPath.trim()])
|
||||
setNewPath('')
|
||||
}}
|
||||
>
|
||||
Add field
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="primary" disabled={busy} onClick={saveLocal}>
|
||||
Save local fields
|
||||
</Button>
|
||||
<Button disabled={busy || paths.length === 0} onClick={() => void fill()}>
|
||||
Fill from the current server configs
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</Dialog>
|
||||
{request !== null && (
|
||||
<RunFlow
|
||||
request={request}
|
||||
onClose={(changed) => {
|
||||
setRequest(null)
|
||||
if (changed) {
|
||||
void load()
|
||||
onChanged()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* The differences, as a field table for JSON and as line diffs for text. */
|
||||
function CompareBody({ data }: { data: ConfigCompare }) {
|
||||
if (fieldRows(data) !== null) return <FieldTable data={data} />
|
||||
const live = data.servers.filter((s) => s.installed && s.ok)
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{live.map((s) => {
|
||||
const diff = data.baseline !== null ? s.vsBaseline : Object.values(s.vsOthers)[0]
|
||||
return diff === undefined ? null : (
|
||||
<section key={s.id} aria-label={`Differences ${s.id}`} className="flex flex-col gap-2">
|
||||
<h3 className="m-0 text-sm font-semibold">{s.id}</h3>
|
||||
<DiffView diff={diff} from={data.baseline !== null ? 'Now' : 'Other'} to={data.baseline !== null ? 'Expected' : s.id} />
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{live.length === 0 && <p className="m-0 text-sm text-muted-foreground">Nothing to compare: the app is not installed on any server that answered.</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export function Apps({
|
||||
// cluster's primary node. `config` can come null; the editor is left empty.
|
||||
async function openConfig(name: string) {
|
||||
setBusy(name)
|
||||
const outcome = await getAppConfig(token, name)
|
||||
const outcome = await getAppConfig(token, name, requestNode)
|
||||
setBusy(null)
|
||||
|
||||
if (outcome.kind !== 'ok') {
|
||||
@@ -363,7 +363,6 @@ export function Apps({
|
||||
app={app}
|
||||
busy={busy === app.name}
|
||||
readOnly={!mayChange}
|
||||
hideConfig={hub}
|
||||
onConfig={() => void openConfig(app.name)}
|
||||
onUpdate={() => setModal({ kind: 'update', name: app.name })}
|
||||
onStoreUpdate={() => void updateFromStore(app)}
|
||||
@@ -419,6 +418,7 @@ export function Apps({
|
||||
onOpenChange={(o) => setModal(o && modal?.kind === 'config' ? modal : null)}
|
||||
token={token}
|
||||
name={modal?.kind === 'config' ? modal.name : ''}
|
||||
node={requestNode}
|
||||
initialConfig={modal?.kind === 'config' ? modal.config : ''}
|
||||
onSaved={(name) => {
|
||||
setModal(null)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Segmented } from '../../ui/Segmented'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
|
||||
import { AppConfigSync } from './AppConfigSync'
|
||||
import { Tooltip } from '../../ui/Tooltip'
|
||||
import { head } from '../../ui/Table'
|
||||
|
||||
@@ -49,12 +50,16 @@ function unknownReason(app: MatrixApp, data: AppsMatrixData): string {
|
||||
return [...reasons].join('; ') || 'The configs could not be compared.'
|
||||
}
|
||||
|
||||
function ConfigState({ app, data }: { app: MatrixApp; data: AppsMatrixData }) {
|
||||
function ConfigState({ app, data, onOpen }: { app: MatrixApp; data: AppsMatrixData; onOpen: () => void }) {
|
||||
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>
|
||||
return (
|
||||
<button type="button" className="cursor-pointer border-0 bg-transparent p-0" aria-label={`Compare the configs of ${app.name}`} onClick={onOpen}>
|
||||
<Tag tone="warn">{app.diffCount === undefined ? 'Different' : `Different · ${app.diffCount}`}</Tag>
|
||||
</button>
|
||||
)
|
||||
case 'single':
|
||||
return <Badge variant="outline">Single server</Badge>
|
||||
default:
|
||||
@@ -85,6 +90,7 @@ function ServerCell({ app, id, data }: { app: MatrixApp; id: string; data: AppsM
|
||||
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)
|
||||
const [configApp, setConfigApp] = useState<string | 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)
|
||||
@@ -194,6 +200,11 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
|
||||
<Menu label={`Actions: ${app.name}`}>
|
||||
{(close) => (
|
||||
<>
|
||||
<Button size="sm" onClick={() => { close(); setConfigApp(app.name) }}>
|
||||
Config…
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<>
|
||||
<Button size="sm" disabled={outdated.length === 0} onClick={() => { close(); open('update-store', `Update ${app.name}`, 'Update', outdated, installed, 'Not installed') }}>
|
||||
Update on all
|
||||
</Button>
|
||||
@@ -214,6 +225,8 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
|
||||
>
|
||||
Uninstall from…
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
@@ -237,8 +250,8 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
|
||||
cell: (a) => <ServerCell app={a} id={s.id} data={data} />,
|
||||
}),
|
||||
),
|
||||
{ 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) }] : []),
|
||||
{ id: 'config', ...head({ children: 'Config' }), cell: (a) => <ConfigState app={a} data={data} onOpen={() => setConfigApp(a.name)} /> },
|
||||
{ id: 'actions', ...head({ children: <span className="sr-only">Actions</span> }), cell: (a) => actions(a, data) },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -270,6 +283,9 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
|
||||
<DataTable data={rows} columns={columns} getRowId={(a) => a.name} />
|
||||
)}
|
||||
</Panel>
|
||||
{configApp !== null && (
|
||||
<AppConfigSync token={token} name={configApp} canWrite={canWrite} onClose={() => setConfigApp(null)} onChanged={() => void load()} />
|
||||
)}
|
||||
{request !== null && (
|
||||
<RunFlow
|
||||
request={request}
|
||||
|
||||
@@ -56,11 +56,15 @@ async function openMenu(app: string) {
|
||||
}
|
||||
|
||||
describe('matrix actions', () => {
|
||||
it('has no menu for a reader and one per row for a writer', async () => {
|
||||
it('gives a reader a menu with Config only and a writer all the actions', async () => {
|
||||
mockReads()
|
||||
const { unmount } = render(<AppsMatrix token="t" />)
|
||||
await screen.findByText('Blocker')
|
||||
expect(screen.queryByRole('button', { name: /^Actions:/ })).not.toBeInTheDocument()
|
||||
await openMenu('Blocker')
|
||||
expect(screen.getByRole('menuitem', { name: 'Config…' })).toBeInTheDocument()
|
||||
for (const name of ['Update on all', 'Install on missing servers', 'Align versions', 'Uninstall from…']) {
|
||||
expect(screen.queryByRole('menuitem', { name })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
|
||||
}
|
||||
unmount()
|
||||
|
||||
render(<AppsMatrix token="t" canWrite />)
|
||||
@@ -251,7 +255,7 @@ describe('Apps behind a hub, for writers', () => {
|
||||
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 () => {
|
||||
it('brings the single-server actions back for a writer, Config included, 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)
|
||||
@@ -262,7 +266,7 @@ describe('Apps behind a hub, for writers', () => {
|
||||
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()
|
||||
expect(screen.getByRole('button', { name: 'Config' })).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Store Update' }))
|
||||
expect(store).toHaveBeenCalledWith('t', 'Split Horizon', 'https://x/s.zip', 'mdns')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { ConfigDiff, ConfigCompare, FieldChange } from '../../api/apps'
|
||||
import { DataTable, type Col } from '../../ui/DataTable'
|
||||
import { head } from '../../ui/Table'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
|
||||
/* A value as one short line: JSON for anything that is not a string. */
|
||||
export function show(value: unknown): string {
|
||||
if (value === undefined) return '(absent)'
|
||||
const text = typeof value === 'string' ? value : JSON.stringify(value)
|
||||
return text.length > 90 ? `${text.slice(0, 87)}...` : text
|
||||
}
|
||||
|
||||
/* Is this field (or a parent of it) one of the local ones? */
|
||||
export const isLocal = (path: string, localPaths: string[]): boolean => localPaths.some((p) => path === p || path.startsWith(`${p}.`))
|
||||
|
||||
const LINE_CLASS = {
|
||||
same: 'text-muted-foreground',
|
||||
add: 'bg-success/10 text-foreground',
|
||||
del: 'bg-destructive/10 text-foreground',
|
||||
} as const
|
||||
const MARK = { same: ' ', add: '+', del: '-' } as const
|
||||
|
||||
/* The changes of one config to another: fields for JSON, lines for text. */
|
||||
export function DiffView({ diff, localPaths = [], from = 'Now', to = 'Expected' }: { diff: ConfigDiff; localPaths?: string[]; from?: string; to?: string }) {
|
||||
if (diff.kind === 'text') {
|
||||
if (diff.truncated) return <p className="m-0 text-sm text-muted-foreground">Too long to show line by line: about {diff.changed} lines differ.</p>
|
||||
if (diff.changed === 0) return <p className="m-0 text-sm text-muted-foreground">No differences.</p>
|
||||
return (
|
||||
<pre className="m-0 max-h-80 overflow-auto rounded-md border border-border font-mono text-xs leading-5" aria-label="Line differences">
|
||||
{diff.lines.map((l, i) => (
|
||||
<div key={i} className={`px-3 whitespace-pre-wrap ${LINE_CLASS[l.op]}`} data-op={l.op}>
|
||||
{MARK[l.op]} {l.text}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
if (diff.changes.length === 0) return <p className="m-0 text-sm text-muted-foreground">No differences.</p>
|
||||
const columns: Col<FieldChange>[] = [
|
||||
{
|
||||
id: 'path',
|
||||
...head({ children: 'Field' }),
|
||||
cell: (c) => (
|
||||
<span className="flex flex-wrap items-center gap-2 font-mono text-xs">
|
||||
{c.path}
|
||||
{isLocal(c.path, localPaths) && <Tag tone="info">Local</Tag>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ id: 'a', ...head({ children: from }), cell: (c) => <span className="font-mono text-xs">{show(c.a)}</span> },
|
||||
{ id: 'b', ...head({ children: to }), cell: (c) => <span className="font-mono text-xs">{show(c.b)}</span> },
|
||||
]
|
||||
return <DataTable data={diff.changes} columns={columns} getRowId={(c) => c.path} />
|
||||
}
|
||||
|
||||
interface Row {
|
||||
path: string
|
||||
cells: Record<string, { now?: unknown; expected?: unknown; differs: boolean }>
|
||||
}
|
||||
|
||||
/*
|
||||
The fields that differ, one row each and one column per server. With a baseline each server is
|
||||
compared to its own effective config (its local values in place); without one, every server is
|
||||
compared to the first.
|
||||
*/
|
||||
export function fieldRows(data: ConfigCompare): Row[] | null {
|
||||
const live = data.servers.filter((s) => s.installed && s.ok)
|
||||
const rows = new Map<string, Row>()
|
||||
const cell = (path: string, server: string) => {
|
||||
const row = rows.get(path) ?? { path, cells: {} }
|
||||
rows.set(path, row)
|
||||
return (row.cells[server] ??= { differs: false })
|
||||
}
|
||||
if (data.baseline !== null) {
|
||||
if (live.some((s) => s.vsBaseline?.kind === 'text')) return null
|
||||
for (const s of live) {
|
||||
if (s.vsBaseline?.kind !== 'json') continue
|
||||
for (const c of s.vsBaseline.changes) Object.assign(cell(c.path, s.id), { now: c.a, expected: c.b, differs: true })
|
||||
}
|
||||
return [...rows.values()]
|
||||
}
|
||||
const reference = live[0]
|
||||
if (reference === undefined) return []
|
||||
for (const o of live.slice(1)) {
|
||||
const diff = reference.vsOthers[o.id]
|
||||
if (diff === undefined) continue
|
||||
if (diff.kind === 'text') return null
|
||||
for (const c of diff.changes) {
|
||||
Object.assign(cell(c.path, reference.id), { now: c.b, differs: true })
|
||||
Object.assign(cell(c.path, o.id), { now: c.a, differs: true })
|
||||
}
|
||||
}
|
||||
return [...rows.values()]
|
||||
}
|
||||
|
||||
export function FieldTable({ data }: { data: ConfigCompare }) {
|
||||
const rows = fieldRows(data)
|
||||
if (rows === null) return null
|
||||
if (rows.length === 0) return <p className="m-0 text-sm text-muted-foreground">No differences between the servers.</p>
|
||||
const servers = data.servers.filter((s) => s.installed && s.ok)
|
||||
const withBaseline = data.baseline !== null
|
||||
const columns: Col<Row>[] = [
|
||||
{
|
||||
id: 'path',
|
||||
...head({ children: 'Field' }),
|
||||
cell: (r) => (
|
||||
<span className="flex flex-wrap items-center gap-2 font-mono text-xs">
|
||||
{r.path}
|
||||
{isLocal(r.path, data.overrides.paths) && <Tag tone="info">Local</Tag>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...servers.map(
|
||||
(s): Col<Row> => ({
|
||||
id: `s-${s.id}`,
|
||||
...head({ children: s.id }),
|
||||
cell: (r) => {
|
||||
const c = r.cells[s.id]
|
||||
if (c === undefined || !c.differs) return <span className="text-xs text-muted-foreground">{withBaseline ? 'Matches' : 'Same'}</span>
|
||||
return (
|
||||
<span className="flex flex-col gap-0.5 font-mono text-xs">
|
||||
<span>{show(c.now)}</span>
|
||||
{withBaseline && <span className="text-muted-foreground">expected {show(c.expected)}</span>}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
]
|
||||
return <DataTable data={rows} columns={columns} getRowId={(r) => r.path} />
|
||||
}
|
||||
@@ -26,7 +26,12 @@ export interface RunRequest {
|
||||
servers: PickerServer[]
|
||||
/** The servers ticked at the start. Only those without a reason count. */
|
||||
preselected: string[]
|
||||
run: (targets: string[]) => Promise<RunOutcome>
|
||||
/*
|
||||
A review step between the picker and the change (the config sync uses it): it reads what
|
||||
would happen on the picked servers and draws it. `context` is handed back to `run`.
|
||||
*/
|
||||
review?: (targets: string[]) => Promise<{ kind: 'ok'; body: ReactNode; context: unknown; confirmLabel?: string } | { kind: 'error'; message: string }>
|
||||
run: (targets: string[], context?: unknown) => Promise<RunOutcome>
|
||||
}
|
||||
|
||||
/* What stops a server from being a target: it did not answer, or the hub has no admin token for it. */
|
||||
@@ -40,7 +45,7 @@ export function pickerServers(
|
||||
})
|
||||
}
|
||||
|
||||
type Stage = 'pick' | 'running' | 'results'
|
||||
type Stage = 'pick' | 'reviewing' | 'review' | 'running' | 'results'
|
||||
|
||||
export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (changed: boolean) => void }) {
|
||||
const [stage, setStage] = useState<Stage>('pick')
|
||||
@@ -50,12 +55,28 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
|
||||
const [results, setResults] = useState<TargetResult[]>([])
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const [lastTargets, setLastTargets] = useState<string[]>([])
|
||||
const [reviewed, setReviewed] = useState<{ body: ReactNode; context: unknown; confirmLabel?: string } | null>(null)
|
||||
const [reviewFailure, setReviewFailure] = useState<string | null>(null)
|
||||
|
||||
async function go(targets: string[]) {
|
||||
async function next(targets: string[]) {
|
||||
if (request.review === undefined) return go(targets)
|
||||
setStage('reviewing')
|
||||
setReviewFailure(null)
|
||||
const outcome = await request.review(targets)
|
||||
if (outcome.kind === 'ok') setReviewed(outcome)
|
||||
else {
|
||||
setReviewed(null)
|
||||
setReviewFailure(outcome.message)
|
||||
}
|
||||
setLastTargets(targets)
|
||||
setStage('review')
|
||||
}
|
||||
|
||||
async function go(targets: string[], context: unknown = reviewed?.context) {
|
||||
setStage('running')
|
||||
setFailure(null)
|
||||
setLastTargets(targets)
|
||||
const outcome = await request.run(targets)
|
||||
const outcome = await request.run(targets, context)
|
||||
if (outcome.kind === 'ok') {
|
||||
const fresh = outcome.data.response.results
|
||||
// A retry replaces only the rows it re-ran.
|
||||
@@ -77,7 +98,7 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
|
||||
title={request.title}
|
||||
close="Cancel"
|
||||
actions={
|
||||
<Button variant="primary" disabled={picked.length === 0} onClick={() => void go(picked)}>
|
||||
<Button variant="primary" disabled={picked.length === 0} onClick={() => void next(picked)}>
|
||||
{request.confirmLabel}
|
||||
</Button>
|
||||
}
|
||||
@@ -99,6 +120,38 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'reviewing') {
|
||||
return (
|
||||
<Dialog open onOpenChange={() => {}} title={request.title} close="Close">
|
||||
<Loading>Reading {lastTargets.join(', ')}…</Loading>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'review') {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={() => onClose(false)}
|
||||
title={`${request.title}: review`}
|
||||
size="wide"
|
||||
close="Cancel"
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => setStage('pick')}>Back</Button>
|
||||
{reviewed !== null && (
|
||||
<Button variant="primary" onClick={() => void go(lastTargets)}>
|
||||
{reviewed.confirmLabel ?? request.confirmLabel}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{reviewFailure !== null ? <Failure>{reviewFailure}</Failure> : reviewed?.body}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'running') {
|
||||
return (
|
||||
<Dialog open onOpenChange={() => {}} title={request.title} close="Close">
|
||||
|
||||
Reference in new issue
Block a user