Hub stage 8: settings drift, webhook notices, profiles
CI / check (push) Successful in 4m27s
Hub / check (push) Successful in 1m17s
CI / image (push) Successful in 1m54s
Hub / image (push) Successful in 1m56s

The drift check now also covers the settings baseline and the Blocked
and Allowed lists baseline; the drift dialog offers Open compare,
Accept as baseline and Revert to baseline for them. Old drift.json
files still load.

HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC
signature) receives drift.found, drift.cleared and write.failed
notices, only on change, without values, never blocking a request and
never logging the address beyond its host. Admins see the webhook
status and can send a test from About and from the drift dialog.

Profiles bundle the hub's app, settings and lists baselines. Applying
one to a server runs install, app configs, settings and lists in order
after a preview; a failed step stops that server's later steps and
Retry failed resumes from it. Profiles export and import as a file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-01 02:11:19 +07:00
1 parent 392b644faa
commit 9742f0fe69
32 files changed
+3123 -106

No files matched your search

+18 -3
View File
@@ -236,10 +236,18 @@ server's own store; the client never sends one.
*/
export type HubRunAction = 'install-store' | 'update-store' | 'uninstall'
export interface StepResult {
step: 'apps' | 'configs' | 'settings' | 'lists'
status: 'ok' | 'failed' | 'skipped' | 'not-run'
message: string
}
export interface TargetResult {
server: string
ok: boolean
message?: string
/* Only a profile apply: what each step did on this server. */
steps?: StepResult[]
}
export type RunOutcome = ApiOutcome<{ response: { results: TargetResult[] } }>
@@ -384,13 +392,20 @@ export interface HistoryDiff {
unchanged: boolean
}
export type DriftKind = 'differs' | 'unreadable' | 'not-installed'
export type DriftStatus = 'differs' | 'unreadable' | 'not-installed'
/* What drifted: an app's config, the settings, or the Blocked and Allowed lists. */
export type DriftKind = 'app' | 'settings' | 'lists'
export interface DriftEntry {
app: string
kind: DriftKind
/* The app's name; absent for settings and lists. */
name?: string
app?: string
server: string
since: string
kind: DriftKind
status: DriftStatus
/* Setting names, or blocked and allowed. Never values. */
fields?: string[]
}
export interface DriftState {
+1 -1
View File
@@ -29,7 +29,7 @@ export async function refreshHubDrift(token: string | null): Promise<void> {
const outcome = await getDrift(token)
if (outcome.kind !== 'ok') return
const entries = Array.isArray(outcome.data.response?.apps) ? outcome.data.response.apps : []
set({ count: entries.filter((e) => e.kind === 'differs').length, entries })
set({ count: entries.filter((e) => e.status === 'differs').length, entries })
}
export function resetHubDrift(): void {
+126
View File
@@ -0,0 +1,126 @@
import { urlApi } from '../app/base'
import { apiRequest, envelopeOutcome, type ApiOutcome } from './client'
import type { ConfigDiff, RunOutcome } from './apps'
/*
Profiles and the webhook, behind a hub (hub/profiles.ts, hub/webhook.ts). Every endpoint needs the
hub's admin role. A profile holds settings secrets, so no answer shows one: they appear as
`<secret>`, and an app's config text is never sent, only whether there is one.
*/
export interface ProfileSummary {
name: string
apps: number
configs: number
settings: number | null
lists: { blocked: number; allowed: number } | null
updatedAt: string
updatedBy: string
}
export interface ProfileView {
name: string
createdAt: string
updatedAt: string
updatedBy: string
apps: { name: string; version: string; hasConfig: boolean; bytes: number }[]
settings: Record<string, unknown> | null
lists: { blocked: number; allowed: number } | null
}
export const listProfiles = (token: string | null): Promise<ApiOutcome<{ response: { profiles: ProfileSummary[] } }>> => apiRequest('hub/profiles/list', { token })
export const getProfile = (token: string | null, name: string): Promise<ApiOutcome<{ response: ProfileView }>> => apiRequest('hub/profiles/get', { token, body: { name } })
export interface SnapshotParts {
/* Undefined takes every app that has a baseline. */
apps?: string[]
settings: boolean
lists: boolean
}
export const snapshotProfile = (token: string | null, name: string, parts: SnapshotParts): Promise<ApiOutcome<{ response: { profile: ProfileView; replaced: boolean } }>> =>
apiRequest('hub/profiles/snapshot', {
token,
method: 'POST',
body: { name, settings: String(parts.settings), lists: String(parts.lists), ...(parts.apps === undefined ? {} : { apps: parts.apps.join(',') }) },
})
export const deleteProfile = (token: string | null, name: string): Promise<ApiOutcome<{ response: { deleted: boolean } }>> =>
apiRequest('hub/profiles/delete', { token, method: 'POST', body: { name } })
export interface ProfileImportPlan {
profiles: { name: string; status: 'new' | 'changed' | 'same'; apps: number; settings: boolean; lists: boolean }[]
applied: boolean
}
export const importProfiles = (token: string | null, data: string, apply: boolean): Promise<ApiOutcome<{ response: ProfileImportPlan }>> =>
apiRequest('hub/profiles/import', { token, method: 'POST', body: { data, apply: apply ? 'true' : 'false' } })
/* The file of profiles (it holds settings secrets). A download is not the usual envelope. */
export async function exportProfiles(token: string | null, name?: string): Promise<ApiOutcome<{ filename: string; text: string }>> {
const headers: Record<string, string> = {}
if (token) headers.Authorization = `Bearer ${token}`
let res: Response
let text: string
try {
res = await fetch(urlApi(`api/hub/profiles/export${name === undefined ? '' : `?name=${encodeURIComponent(name)}`}`), { headers })
text = await res.text()
} catch {
return { kind: 'error', message: 'Unable to connect to the server. Please try again.' }
}
try {
const env = JSON.parse(text) as { status?: string }
if (env.status !== undefined && env.status !== 'ok') return envelopeOutcome<{ filename: string; text: string }>(env, token)
} catch {
return { kind: 'error', message: 'The hub did not send a file.' }
}
const filename = /filename="([^"]+)"/.exec(res.headers.get('content-disposition') ?? '')?.[1] ?? 'hub-profiles.json'
return { kind: 'ok', data: { filename, text } }
}
export interface ProfileAppState {
name: string
state: 'install' | 'present' | 'version-differs' | 'not-in-store'
profileVersion: string
storeVersion?: string
installedVersion?: string
}
export interface ProfileConfigState {
name: string
state: 'same' | 'differs' | 'after-install' | 'unreadable'
error?: string
changes?: ConfigDiff
}
export interface ProfileTargetPreview {
server: string
writable: boolean
ok: boolean
error?: string
apps: ProfileAppState[]
configs: ProfileConfigState[]
settings: { fields: string[]; secretFields: string[]; unchanged: boolean; changes: ConfigDiff; warnings: string[]; error?: string } | null
lists: { blocked: { missing: number; extra: number }; allowed: { missing: number; extra: number }; status: string; tooMany?: string; error?: string } | null
warnings: string[]
}
export const previewProfile = (token: string | null, name: string, targets: string[], removeExtras: boolean): Promise<ApiOutcome<{ response: { profile: string; targets: ProfileTargetPreview[] } }>> =>
apiRequest('hub/profiles/preview', { token, method: 'POST', body: { name, targets: targets.join(','), removeExtras: String(removeExtras) } })
/* `from` says, per server, the step to start at: the retry of a failed run. */
export const applyProfile = (token: string | null, name: string, targets: string[], removeExtras: boolean, from: Record<string, string>): Promise<RunOutcome> =>
apiRequest('hub/profiles/apply', { token, method: 'POST', body: { name, targets: targets.join(','), removeExtras: String(removeExtras), from: JSON.stringify(from) } })
export interface WebhookStatus {
configured: boolean
/* Host and port only, never the address. */
host: string | null
signed: boolean
last: { event: string; at: string; ok: boolean; error?: string } | null
}
export const getWebhookStatus = (token: string | null): Promise<ApiOutcome<{ response: WebhookStatus }>> => apiRequest('hub/webhook/status', { token })
export const testWebhook = (token: string | null): Promise<ApiOutcome<{ response: WebhookStatus }>> => apiRequest('hub/webhook/test', { token, method: 'POST', body: {} })
+167
View File
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DriftStatus } from './DriftStatus'
import { refreshHubDrift, resetHubDrift } from '../api/hub-drift'
import * as apps from '../api/apps'
import * as settings from '../api/hub-settings'
import * as profiles from '../api/hub-profiles'
import type { DriftEntry } from '../api/apps'
/*
The drift list with the kinds added in stage 8: settings and the Blocked and Allowed lists. Their
actions are for an admin and reuse the compare views and the apply flows of Settings and Blocking;
an entry names fields, never values. The webhook line is the admin's.
*/
beforeEach(() => resetHubDrift())
afterEach(() => vi.restoreAllMocks())
const ENTRIES: DriftEntry[] = [
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs' },
{ kind: 'settings', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs', fields: ['blockingType', 'defaultRecordTtl'] },
{ kind: 'lists', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs', fields: ['blocked'] },
{ kind: 'settings', server: 'mdns', since: '2026-10-31T09:00:00Z', status: 'unreadable' },
]
async function load(entries: DriftEntry[] = ENTRIES) {
vi.spyOn(apps, 'getDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'x', apps: entries } } } as never)
await act(async () => refreshHubDrift('t'))
}
const NOT_SET = { configured: false, host: null, signed: false, last: null }
describe('DriftStatus kinds', () => {
it('counts every kind that differs and lists settings and lists with their field names', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
expect(await screen.findByRole('button', { name: 'Config drift' })).toHaveTextContent('3 config drifts')
await userEvent.click(screen.getByRole('button', { name: 'Config drift' }))
const settingsRow = await screen.findByRole('listitem', { name: 'Settings on ndns' })
expect(within(settingsRow).getByText('blockingType, defaultRecordTtl')).toBeInTheDocument()
expect(within(screen.getByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).getByText('blocked')).toBeInTheDocument()
expect(within(screen.getByRole('listitem', { name: 'Settings on mdns' })).getByText('Cannot read')).toBeInTheDocument()
})
it('gives a non-admin no action on settings or lists, but keeps the app actions of a writer', async () => {
render(<DriftStatus token="t" canWrite writable={['ndns']} />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
const settingsRow = await screen.findByRole('listitem', { name: 'Settings on ndns' })
expect(within(settingsRow).queryByRole('button')).not.toBeInTheDocument()
expect(within(screen.getByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).queryByRole('button')).not.toBeInTheDocument()
expect(within(screen.getByRole('listitem', { name: 'Blocker on ndns' })).getByRole('button', { name: 'Accept as baseline' })).toBeInTheDocument()
expect(screen.queryByText('Webhook')).not.toBeInTheDocument()
})
it('opens the settings compare, and the blocking compare for lists', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
const compare = vi.spyOn(settings, 'getSettingsCompare').mockResolvedValue({ kind: 'error', message: 'stop here' } as never)
const lists = vi.spyOn(settings, 'getListsCompare').mockResolvedValue({ kind: 'error', message: 'stop here' } as never)
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: ENTRIES } } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Settings on ndns' })).getByRole('button', { name: 'Open compare' }))
await waitFor(() => expect(compare).toHaveBeenCalledTimes(1))
expect(lists).not.toHaveBeenCalled()
expect(await screen.findByRole('dialog', { name: 'Compare settings across servers' })).toBeInTheDocument()
})
it('accepts the server settings as the baseline after a confirmation, then checks again', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
const accept = vi.spyOn(settings, 'importSettingsBaseline').mockResolvedValue({ kind: 'ok', data: { response: { baseline: { settings: {}, updatedAt: '', updatedBy: '' } } } } as never)
const check = vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Settings on ndns' })).getByRole('button', { name: 'Accept as baseline' }))
await userEvent.click(await screen.findByRole('button', { name: 'Accept' }))
expect(accept).toHaveBeenCalledWith('t', 'ndns')
await waitFor(() => expect(check).toHaveBeenCalled())
})
it('accepts the lists of a server as the baseline', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
const accept = vi.spyOn(settings, 'importListsBaseline').mockResolvedValue({ kind: 'ok', data: { response: { imported: true } } } as never)
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).getByRole('button', { name: 'Accept as baseline' }))
await userEvent.click(await screen.findByRole('button', { name: 'Accept' }))
expect(accept).toHaveBeenCalledWith('t', 'ndns')
})
it('reverts settings to the baseline through the preview', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
const preview = vi.spyOn(settings, 'previewSettings').mockResolvedValue({
kind: 'ok',
data: { response: { targets: [{ server: 'ndns', ok: true, writable: true, currentHash: 'h1', changes: { kind: 'json', changes: [{ path: 'blockingType', type: 'changed', a: 'AnyAddress', b: 'NxDomain' }] }, secretFields: [], unchanged: false, warnings: [] }] } },
} as never)
const apply = vi.spyOn(settings, 'applySettings').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Applied and verified (1 field).' }] } } } as never)
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Settings on ndns' })).getByRole('button', { name: 'Revert to baseline' }))
const picker = await screen.findByRole('dialog', { name: 'Apply the settings baseline' })
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
expect(preview).toHaveBeenCalledWith('t', ['ndns'], undefined)
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the settings baseline: review' })).getByRole('button', { name: 'Apply' }))
expect(apply).toHaveBeenCalledWith('t', ['ndns'], { ndns: 'h1' }, undefined)
expect(await screen.findByText('Applied and verified (1 field).')).toBeInTheDocument()
})
it('reverts the lists through the lists preview', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
const none = { count: 0, missing: 0, extra: 0, missingSample: [], extraSample: [] }
vi.spyOn(settings, 'previewLists').mockResolvedValue({ kind: 'ok', data: { response: { targets: [{ id: 'ndns', ok: true, writable: true, status: 'differs', blocked: { ...none, extra: 1 }, allowed: none }] } } } as never)
const apply = vi.spyOn(settings, 'applyLists').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Added 0, removed 0.' }] } } } as never)
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).getByRole('button', { name: 'Revert to baseline' }))
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the Blocked and Allowed lists' })).getByRole('button', { name: 'Preview' }))
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the Blocked and Allowed lists: review' })).getByRole('button', { name: 'Apply' }))
expect(apply).toHaveBeenCalledWith('t', ['ndns'], false)
})
})
describe('webhook line in the drift dialog', () => {
it('shows the host and the last delivery, and sends a test', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({
kind: 'ok',
data: { response: { configured: true, host: 'hooks.example:8443', signed: true, last: { event: 'drift.found', at: '2026-11-01T00:00:00Z', ok: false, error: 'HTTP 500' } } },
} as never)
const test = vi.spyOn(profiles, 'testWebhook').mockResolvedValue({
kind: 'ok',
data: { response: { configured: true, host: 'hooks.example:8443', signed: true, last: { event: 'webhook.test', at: '2026-11-01T00:01:00Z', ok: true } } },
} as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
expect(await screen.findByText('hooks.example:8443')).toBeInTheDocument()
expect(screen.getByText('Last delivery failed (HTTP 500)')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Send test' }))
expect(test).toHaveBeenCalledWith('t')
expect(await screen.findByText('Last delivery ok')).toBeInTheDocument()
})
it('says it is not configured, with no test button, and shows an error with Retry', async () => {
const spy = vi
.spyOn(profiles, 'getWebhookStatus')
.mockResolvedValueOnce({ kind: 'error', message: 'boom' } as never)
.mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
expect(await screen.findByText(/boom/)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Not configured')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Send test' })).not.toBeInTheDocument()
expect(spy).toHaveBeenCalledTimes(2)
})
})
+80 -21
View File
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { acceptDrift, checkDrift, type DriftEntry } from '../api/apps'
import { refreshHubDrift, useHubDrift } from '../api/hub-drift'
import { importListsBaseline, importSettingsBaseline } from '../api/hub-settings'
import { AppConfigSync } from '../screens/apps/AppConfigSync'
import { applyRequest } from '../screens/apps/applyRequest'
import { RunFlow, pickerServers, type RunRequest } from '../screens/apps/RunFlow'
import { SettingsCompare, listsApplyRequest, settingsApplyRequest, type Scope } from '../screens/settings/SettingsCompare'
import { Alert } from '../ui/Alert'
import { Button } from '../ui/Button'
import { Confirm } from '../ui/Confirm'
@@ -11,20 +13,27 @@ import { Dialog } from '../ui/Dialog'
import { Empty } from '../ui/Empty'
import { Tag } from '../ui/Tag'
import styles from './Shell.module.css'
import { WebhookStatus } from './WebhookStatus'
/*
Behind a hub: a mark in the header when a server no longer has the config the hub says it should
(someone changed it in that server's own console), and the list of them. Each can be looked at in
the app's config view; a writer can accept the server's config as the new baseline, or revert the
server to the baseline after a preview.
Behind a hub: a mark in the header when a server no longer has what the hub says it should (someone
changed it in that server's own console), and the list of them. Three kinds: an app's config, the
settings, and the Blocked and Allowed lists. Each can be looked at (the app's config view, or the
compare view of Settings or Blocking); a writer can accept the server's state as the new baseline,
or revert the server to the baseline after a preview. Settings and lists are for an admin, as their
compare views are. An entry names fields, never values.
*/
const KIND = { differs: 'Differs', unreadable: 'Cannot read', 'not-installed': 'Not installed' } as const
const STATUS = { differs: 'Differs', unreadable: 'Cannot read', 'not-installed': 'Not installed' } as const
export function DriftStatus({ token, canWrite, writable }: { token: string | null; canWrite: boolean; writable: string[] }) {
const label = (e: DriftEntry): string => (e.kind === 'app' ? (e.name ?? e.app ?? '') : e.kind === 'settings' ? 'Settings' : 'Blocked and Allowed lists')
const keyOf = (e: DriftEntry): string => `${e.kind}/${e.name ?? e.app ?? ''}/${e.server}`
export function DriftStatus({ token, canWrite, writable, isAdmin = false }: { token: string | null; canWrite: boolean; writable: string[]; isAdmin?: boolean }) {
const { count, entries } = useHubDrift()
const [open, setOpen] = useState(false)
const [configApp, setConfigApp] = useState<string | null>(null)
const [compare, setCompare] = useState<Scope | null>(null)
const [request, setRequest] = useState<RunRequest | null>(null)
const [accepting, setAccepting] = useState<DriftEntry | null>(null)
const [problem, setProblem] = useState<string | null>(null)
@@ -32,13 +41,25 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
if (count === 0 && !open) return null
async function recheck() {
await checkDrift(token)
await refreshHubDrift(token)
}
async function accept(entry: DriftEntry) {
setBusy(true)
setProblem(null)
const outcome = await acceptDrift(token, entry.app, entry.server)
setBusy(false)
const outcome =
entry.kind === 'app'
? await acceptDrift(token, entry.name ?? entry.app ?? '', entry.server)
: entry.kind === 'settings'
? await importSettingsBaseline(token, entry.server)
: await importListsBaseline(token, entry.server)
if (outcome.kind !== 'ok') setProblem(outcome.kind === 'error' ? outcome.message : 'The session ended.')
await refreshHubDrift(token)
// The hub checks again after an app is accepted; for settings and lists it is asked to.
if (entry.kind === 'app') await refreshHubDrift(token)
else await recheck()
setBusy(false)
}
async function checkNow() {
@@ -51,11 +72,19 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
}
function revert(entry: DriftEntry) {
const servers = pickerServers([{ id: entry.server, writable: writable.includes(entry.server) }])
setRequest(
applyRequest(token, entry.app, pickerServers([{ id: entry.server, writable: writable.includes(entry.server) }]), [entry.server]),
entry.kind === 'app'
? applyRequest(token, entry.name ?? entry.app ?? '', servers, [entry.server])
: entry.kind === 'settings'
? settingsApplyRequest(token, servers, [entry.server], 'settings')
: listsApplyRequest(token, servers, [entry.server]),
)
}
/* Settings and lists are admin-only; an app's config follows the Apps right. */
const mayAct = (e: DriftEntry): boolean => (e.kind === 'app' ? canWrite : isAdmin)
return (
<>
<button type="button" className={styles.status} data-attention="true" aria-label="Config drift" onClick={() => setOpen(true)}>
@@ -77,24 +106,36 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
}
>
<p className="m-0 text-sm text-muted-foreground">
These servers no longer have the config the hub expects: the baseline plus the server's own local fields.
These servers no longer have what the hub expects: an app&apos;s baseline plus the server&apos;s own local fields, or the shared settings and the Blocked and Allowed lists of their baselines.
</p>
{isAdmin && <WebhookStatus token={token} />}
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
{entries.length === 0 ? (
<Empty title="No drift">Every server has the config the hub expects.</Empty>
<Empty title="No drift">Every server has what the hub expects.</Empty>
) : (
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Drifts">
{entries.map((e) => (
<li key={`${e.app}/${e.server}`} aria-label={`${e.app} on ${e.server}`} className="flex flex-wrap items-center gap-3 border-b border-border pb-3 text-sm last:border-b-0">
<span className="font-medium">{e.app}</span>
<li key={keyOf(e)} aria-label={`${label(e)} on ${e.server}`} className="flex flex-wrap items-center gap-3 border-b border-border pb-3 text-sm last:border-b-0">
<span className="font-medium">{label(e)}</span>
<span>{e.server}</span>
<Tag tone={e.kind === 'differs' ? 'warn' : 'neutral'}>{KIND[e.kind]}</Tag>
<Tag tone={e.status === 'differs' ? 'warn' : 'neutral'}>{STATUS[e.status]}</Tag>
<span className="text-xs text-muted-foreground">since {new Date(e.since).toLocaleString()}</span>
{e.fields !== undefined && e.fields.length > 0 && (
<span className="basis-full font-mono text-xs text-muted-foreground">{e.fields.slice(0, 8).join(', ')}{e.fields.length > 8 ? ` and ${e.fields.length - 8} more` : ''}</span>
)}
<span className="ml-auto flex flex-wrap gap-2">
<Button size="sm" onClick={() => setConfigApp(e.app)}>
Open config
</Button>
{canWrite && e.kind === 'differs' && (
{e.kind === 'app' ? (
<Button size="sm" onClick={() => setConfigApp(e.name ?? e.app ?? '')}>
Open config
</Button>
) : (
isAdmin && (
<Button size="sm" onClick={() => setCompare(e.kind === 'settings' ? 'settings' : 'blocking')}>
Open compare
</Button>
)
)}
{mayAct(e) && e.status === 'differs' && (
<>
<Button size="sm" disabled={busy} onClick={() => setAccepting(e)}>
Accept as baseline
@@ -114,7 +155,15 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
<Confirm
open={accepting !== null}
title="Accept as baseline"
text={`Make the config of ${accepting?.app ?? ''} on ${accepting?.server ?? ''} the baseline? The other servers will then differ from it. The old baseline is kept in the history.`}
text={
accepting === null
? ''
: accepting.kind === 'app'
? `Make the config of ${label(accepting)} on ${accepting.server} the baseline? The other servers will then differ from it. The old baseline is kept in the history.`
: accepting.kind === 'settings'
? `Make the settings of ${accepting.server} the baseline? The other servers will then differ from it. The old baseline is kept in the settings history.`
: `Make the Blocked and Allowed lists of ${accepting.server} the baseline? The other servers will then differ from it.`
}
label="Accept"
onClose={() => setAccepting(null)}
onConfirm={() => accepting && accept(accepting)}
@@ -123,12 +172,22 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
{configApp !== null && (
<AppConfigSync token={token} name={configApp} canWrite={canWrite} onClose={() => setConfigApp(null)} onChanged={() => void refreshHubDrift(token)} />
)}
{compare !== null && (
<SettingsCompare
token={token}
scope={compare}
onClose={() => {
setCompare(null)
void recheck()
}}
/>
)}
{request !== null && (
<RunFlow
request={request}
onClose={(changed) => {
setRequest(null)
if (changed) void checkDrift(token).then(() => refreshHubDrift(token))
if (changed) void recheck()
}}
/>
)}
+1 -1
View File
@@ -107,7 +107,7 @@ describe('Shell on a hub', () => {
vi.restoreAllMocks()
vi.spyOn(client, 'apiRequest').mockImplementation((async (path: string) =>
path === 'hub/drift/get'
? { kind: 'ok', data: { status: 'ok', response: { checkedAt: 'x', apps: [{ app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', kind: 'differs' }] } } }
? { kind: 'ok', data: { status: 'ok', response: { checkedAt: 'x', apps: [{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs' }] } } }
: { kind: 'ok', data: { status: 'ok', response: {} } }) as never)
const hubSession = session(true)
hubSession.info = { ...hubSession.info!, hubDrift: 1 }
+4 -3
View File
@@ -41,6 +41,7 @@ import { useBlockingMark } from '../screens/blocking/blocking-state'
import { useHubWarnings } from '../api/hub-warnings'
import { refreshHubDrift, resetHubDrift, setHubDriftCount } from '../api/hub-drift'
import { DriftStatus } from './DriftStatus'
import { WebhookStatus } from './WebhookStatus'
import { Confirm } from '../ui/Confirm'
import { Notifier } from '../ui/Notifier'
import type { Notice } from '../lib/notice'
@@ -613,7 +614,7 @@ export function Shell({
<span key={i} data-last={i === crumbs.length - 1}>{c}</span>
))}
</div>
{hub && <DriftStatus token={session.token} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} />}
{hub && <DriftStatus token={session.token} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} isAdmin={permissions?.Settings?.canModify === true} />}
<Popover>
<PopoverTrigger className={styles.status} data-attention={blockingNow != null || hubWarnings.length > 0} aria-label="Server status">
<i className={styles.pdot} data-tone={hubWarnings.length > 0 ? 'warn' : (blockingNow?.tone ?? 'ok')} />
@@ -679,9 +680,9 @@ export function Shell({
request={pending != null && 'dnsclient' in pending ? pending.dnsclient : undefined}
/>
) : current?.id === 'about' ? (
<About info={info} />
<About info={info} hubPanel={hub && permissions?.Settings?.canModify === true ? <WebhookStatus token={session.token} /> : undefined} />
) : current?.id === 'apps' ? (
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} />
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} isAdmin={permissions?.Settings?.canModify === true} />
) : current?.id === 'cache' ? (
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canDelete={hub ? permissions?.Cache?.canDelete !== false : undefined} />
) : current?.id === 'blocking' ? (
+78
View File
@@ -0,0 +1,78 @@
import { useCallback, useEffect, useState } from 'react'
import { getWebhookStatus, testWebhook, type WebhookStatus as Status } from '../api/hub-profiles'
import { Button } from '../ui/Button'
import { Tag } from '../ui/Tag'
import { Skeleton } from '@/components/ui/skeleton'
/*
For the hub's admin: whether the hub sends notices to a webhook (HUB_WEBHOOK_URL), the host it
sends them to (never the address, which may carry a token), how the last delivery went, and a
button that sends a test notice. Read-only: the address and the secret are set on the hub.
*/
export function WebhookStatus({ token }: { token: string | null }) {
const [status, setStatus] = useState<Status | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
const outcome = await getWebhookStatus(token)
if (outcome.kind === 'ok') {
setStatus(outcome.data.response)
setFailure(null)
} else setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
}, [token])
useEffect(() => {
void load()
}, [load])
async function test() {
setBusy(true)
const outcome = await testWebhook(token)
setBusy(false)
if (outcome.kind === 'ok') {
setStatus(outcome.data.response)
setFailure(null)
} else setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
}
if (failure !== null && status === null) {
return (
<div className="flex flex-wrap items-center gap-2 text-sm" role="alert">
<span>Could not read the webhook status. {failure}</span>
<Button size="sm" onClick={() => void load()}>
Retry
</Button>
</div>
)
}
if (status === null) return <Skeleton className="h-6 w-2/3" role="status" aria-label="Loading the webhook status" />
return (
<div className="flex flex-col gap-2" aria-label="Webhook">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">Webhook</span>
{status.configured ? <Tag tone="ok">Configured</Tag> : <Tag tone="neutral">Not configured</Tag>}
{status.configured && <span className="text-muted-foreground">{status.host}</span>}
{status.configured && status.last === null && <span className="text-xs text-muted-foreground">Nothing sent yet.</span>}
{status.last !== null && (
<>
<Tag tone={status.last.ok ? 'ok' : 'dan'}>{status.last.ok ? 'Last delivery ok' : `Last delivery failed${status.last.error === undefined ? '' : ` (${status.last.error})`}`}</Tag>
<span className="text-xs text-muted-foreground">{new Date(status.last.at).toLocaleString()}</span>
</>
)}
{status.configured && (
<Button size="sm" className="ml-auto" disabled={busy} onClick={() => void test()}>
Send test
</Button>
)}
</div>
{!status.configured && <p className="m-0 text-xs text-muted-foreground">Set HUB_WEBHOOK_URL on the hub to get a notice when a server drifts or a write fails.</p>}
{failure !== null && (
<p className="m-0 text-xs text-muted-foreground" role="alert">
{failure}
</p>
)}
</div>
)
}
+8 -1
View File
@@ -61,7 +61,8 @@ four literals the product does not say.
What stays is the `Server` panel: version and uptime are facts upstream's own
About shows, right under the title.
*/
export function About({ info }: { info?: Info }) {
/* `hubPanel`: behind a hub, the hub's own status for its admin (the webhook), under Server. */
export function About({ info, hubPanel }: { info?: Info; hubPanel?: ReactNode }) {
return (
<>
<SectionHeader title="About" subtitle="Technitium DNS Server and this console" />
@@ -166,6 +167,12 @@ export function About({ info }: { info?: Info }) {
</Body>
</Panel>
{hubPanel != null && (
<Panel title="Hub notifications" description="Where the hub sends drift and failure notices">
<Body>{hubPanel}</Body>
</Panel>
)}
{/*
This console's own panels (deviation 9 of CONVENTIONS.md, approved on
2026-10-16), under Server. Technitium's card on the left stays literal.
+7
View File
@@ -32,6 +32,7 @@ import { AppsMatrix } from './AppsMatrix'
import { HubStore } from './HubStore'
import { HubInstallFile } from './HubInstallFile'
import { ImportBaselines } from './ImportBaselines'
import { Profiles } from './Profiles'
import { RunFlow, type RunRequest } from './RunFlow'
/*
@@ -70,6 +71,7 @@ export function Apps({
clusterInitialised = false,
canWrite = false,
writable = [],
isAdmin = false,
}: {
token: string | null
/** Only used behind a hub: its servers. */
@@ -79,6 +81,8 @@ export function Apps({
canWrite?: boolean
/** Behind a hub: the servers the hub holds an admin token for. */
writable?: string[]
/** Behind a hub: the role is the hub's admin (profiles are for admins). */
isAdmin?: boolean
}) {
/*
Behind a hub (`info.hub`) this screen is read-only and has a server selector: "All
@@ -102,6 +106,7 @@ export function Apps({
const [hubView, setHubView] = useState<'matrix' | 'store'>('matrix')
const [fileDialog, setFileDialog] = useState(false)
const [importDialog, setImportDialog] = useState(false)
const [profilesDialog, setProfilesDialog] = useState(false)
const [fileRequest, setFileRequest] = useState<RunRequest | null>(null)
const [refresh, setRefresh] = useState(0)
useEffect(() => {
@@ -263,6 +268,7 @@ export function Apps({
<Button onClick={() => setFileDialog(true)}>Install from file</Button>
<Button onClick={() => void exportFile()}>Export baselines</Button>
<Button onClick={() => setImportDialog(true)}>Import baselines</Button>
{isAdmin && <Button onClick={() => setProfilesDialog(true)}>Profiles</Button>}
</>
) : undefined
}
@@ -272,6 +278,7 @@ export function Apps({
) : (
<AppsMatrix token={token} canWrite={canWrite} refresh={refresh} />
)}
{isAdmin && <Profiles open={profilesDialog} onOpenChange={setProfilesDialog} token={token} servers={hubServers} />}
<ImportBaselines open={importDialog} onOpenChange={setImportDialog} token={token} onImported={() => setRefresh((n) => n + 1)} />
<HubInstallFile open={fileDialog} onOpenChange={setFileDialog} token={token} servers={hubServers} onRequest={setFileRequest} />
{fileRequest !== null && (
+3 -3
View File
@@ -125,8 +125,8 @@ describe('History tab', () => {
describe('DriftStatus in the header', () => {
const DRIFTS: DriftEntry[] = [
{ app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', kind: 'differs' },
{ app: 'Blocker', server: 'mdns', since: '2026-10-31T09:00:00Z', kind: 'unreadable' },
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs' },
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'mdns', since: '2026-10-31T09:00:00Z', status: 'unreadable' },
]
async function load(entries: DriftEntry[]) {
@@ -200,7 +200,7 @@ describe('Drift badge in the matrix', () => {
},
},
} as never)
vi.spyOn(api, 'getDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'x', apps: [{ app: 'Blocker', server: 'ndns', since: 'z', kind: 'differs' }, { app: 'Blocker', server: 'mdns', since: 'z', kind: 'unreadable' }] } } } as never)
vi.spyOn(api, 'getDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'x', apps: [{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: 'z', status: 'differs' }, { kind: 'app', name: 'Blocker', app: 'Blocker', server: 'mdns', since: 'z', status: 'unreadable' }] } } } as never)
render(<AppsMatrix token="t" />)
await screen.findByText('Blocker')
await waitFor(() => expect(screen.getAllByText('Drift')).toHaveLength(1))
+1 -1
View File
@@ -114,7 +114,7 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
void getDrift(token)
.then((o) => {
if (o.kind === 'ok' && Array.isArray(o.data.response?.apps)) {
setDrifted(new Set(o.data.response.apps.filter((e) => e.kind === 'differs').map((e) => `${e.app}\n${e.server}`)))
setDrifted(new Set(o.data.response.apps.filter((e) => e.kind === 'app' && e.status === 'differs').map((e) => `${e.name ?? e.app}\n${e.server}`)))
}
})
.catch(() => {})
+223
View File
@@ -0,0 +1,223 @@
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 { Profiles } from './Profiles'
import * as api from '../../api/hub-profiles'
import type { ProfileSummary, ProfileTargetPreview } from '../../api/hub-profiles'
afterEach(() => vi.restoreAllMocks())
const SERVERS = [
{ id: 'mdns', writable: true },
{ id: 'ndns', writable: true },
]
const FRESH: ProfileSummary = { name: 'Fresh', apps: 2, configs: 1, settings: 12, lists: { blocked: 3, allowed: 1 }, updatedAt: '2026-11-01T00:00:00Z', updatedBy: 'admin' }
const listOf = (profiles: ProfileSummary[]) => vi.spyOn(api, 'listProfiles').mockResolvedValue({ kind: 'ok', data: { response: { profiles } } } as never)
const PREVIEW: ProfileTargetPreview = {
server: 'ndns',
writable: true,
ok: true,
apps: [
{ name: 'Blocker', state: 'install', profileVersion: '2.0', storeVersion: '2.0' },
{ name: 'Logs', state: 'version-differs', profileVersion: '2.0', installedVersion: '1.0' },
],
configs: [{ name: 'Blocker', state: 'after-install' }],
settings: { fields: ['blockingType'], secretFields: ['tsigKeys'], unchanged: false, warnings: [], changes: { kind: 'json', changes: [{ path: 'blockingType', type: 'changed', a: 'AnyAddress', b: 'NxDomain' }] } },
lists: { blocked: { missing: 2, extra: 1 }, allowed: { missing: 0, extra: 0 }, status: 'differs' },
warnings: [],
}
const result = (steps: { step: 'apps' | 'configs' | 'settings' | 'lists'; status: 'ok' | 'failed' | 'skipped' | 'not-run'; message: string }[], ok: boolean) =>
({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok, message: ok ? 'Profile applied.' : 'Stopped at settings: refused', steps }] } } }) as never
describe('Profiles list', () => {
it('shows a skeleton while it loads, then each profile with its parts', async () => {
listOf([FRESH])
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
expect(screen.getByRole('status', { name: 'Loading the profiles' })).toBeInTheDocument()
const row = await screen.findByRole('listitem', { name: 'Fresh' })
expect(within(row).getByText('2 apps')).toBeInTheDocument()
expect(within(row).getByText('1 config')).toBeInTheDocument()
expect(within(row).getByText('12 settings')).toBeInTheDocument()
expect(within(row).getByText('4 list domains')).toBeInTheDocument()
for (const name of ['View', 'Apply to servers…', 'Export', 'Delete']) expect(within(row).getByRole('button', { name })).toBeInTheDocument()
})
it('has an empty state whose action creates a profile from the current baselines', async () => {
listOf([])
const snapshot = vi.spyOn(api, 'snapshotProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: {} as never, replaced: false } } } as never)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
expect(await screen.findByText('No profiles yet')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Create from current baselines' }))
const dialog = await screen.findByRole('dialog', { name: 'Create a profile' })
await userEvent.type(within(dialog).getByLabelText('Profile name'), 'Fresh')
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
expect(snapshot).toHaveBeenCalledWith('t', 'Fresh', { apps: undefined, settings: true, lists: true })
})
it('lets the admin leave a part out', async () => {
listOf([])
const snapshot = vi.spyOn(api, 'snapshotProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: {} as never, replaced: false } } } as never)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
await userEvent.click(await screen.findByRole('button', { name: 'Create from current baselines' }))
const dialog = await screen.findByRole('dialog', { name: 'Create a profile' })
await userEvent.type(within(dialog).getByLabelText('Profile name'), 'Light')
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'Apps' }))
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'Blocked and Allowed lists' }))
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
expect(snapshot).toHaveBeenCalledWith('t', 'Light', { apps: [], settings: true, lists: false })
})
it('shows an error with Retry', async () => {
const spy = vi.spyOn(api, 'listProfiles').mockResolvedValueOnce({ kind: 'error', message: 'boom' } as never).mockResolvedValue({ kind: 'ok', data: { response: { profiles: [FRESH] } } } as never)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
expect(await screen.findByText(/boom/)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('listitem', { name: 'Fresh' })).toBeInTheDocument()
expect(spy).toHaveBeenCalledTimes(2)
})
it('views a profile with the secrets masked and no config text', async () => {
listOf([FRESH])
vi.spyOn(api, 'getProfile').mockResolvedValue({
kind: 'ok',
data: {
response: {
name: 'Fresh', createdAt: '', updatedAt: '2026-11-01T00:00:00Z', updatedBy: 'admin',
apps: [{ name: 'Blocker', version: '2.0', hasConfig: true, bytes: 12 }],
settings: { tsigKeys: [{ keyName: 'k', sharedSecret: '<secret>' }] },
lists: { blocked: 3, allowed: 1 },
},
},
} as never)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'View' }))
const dialog = await screen.findByRole('dialog', { name: 'Profile: Fresh' })
expect(await within(dialog).findByText('With config')).toBeInTheDocument()
expect((within(dialog).getByLabelText('Shared settings (JSON)') as HTMLTextAreaElement).value).toContain('<secret>')
expect(within(dialog).getByText('3 blocked and 1 allowed domains.')).toBeInTheDocument()
})
it('deletes only after a confirmation', async () => {
const list = listOf([FRESH])
const removed = vi.spyOn(api, 'deleteProfile').mockResolvedValue({ kind: 'ok', data: { response: { deleted: true } } } as never)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Delete' }))
expect(removed).not.toHaveBeenCalled()
list.mockResolvedValue({ kind: 'ok', data: { response: { profiles: [] } } } as never)
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }))
expect(removed).toHaveBeenCalledWith('t', 'Fresh')
expect(await screen.findByText('No profiles yet')).toBeInTheDocument()
})
})
describe('applying a profile', () => {
it('previews the steps per server, applies, and shows the steps of each result', async () => {
listOf([FRESH])
const preview = vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: 'Fresh', targets: [PREVIEW] } } } as never)
const apply = vi.spyOn(api, 'applyProfile').mockResolvedValue(
result(
[
{ step: 'apps', status: 'ok', message: 'Installed Blocker.' },
{ step: 'configs', status: 'ok', message: '1 config(s) applied, 0 already in sync.' },
{ step: 'settings', status: 'ok', message: 'Applied and verified (1 field).' },
{ step: 'lists', status: 'ok', message: 'Added 2, removed 0.' },
],
true,
),
)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
const picker = await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })
await userEvent.click(within(picker).getByRole('checkbox', { name: 'ndns' }))
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
expect(preview).toHaveBeenCalledWith('t', 'Fresh', ['ndns'], false)
const review = await screen.findByRole('dialog', { name: 'Apply the profile Fresh: review' })
const steps = within(review).getByRole('list', { name: 'Steps on ndns' })
expect(within(steps).getByText('1. Apps')).toBeInTheDocument()
expect(within(steps).getByText('Will be installed')).toBeInTheDocument()
expect(within(steps).getByText('Other version installed')).toBeInTheDocument()
expect(within(steps).getByText('2. App configs')).toBeInTheDocument()
expect(within(steps).getByText(/tsigKeys: the secret values are sent but never shown/)).toBeInTheDocument()
expect(within(steps).getByText(/Blocked: 2 to add, 1 extra/)).toBeInTheDocument()
await userEvent.click(within(review).getByRole('button', { name: 'Apply' }))
expect(apply).toHaveBeenCalledWith('t', 'Fresh', ['ndns'], false, {})
const list = await screen.findByRole('list', { name: 'Steps on ndns' })
expect(within(list).getByText('Installed Blocker.')).toBeInTheDocument()
expect(within(list).getAllByText('Done')).toHaveLength(4)
})
it('removes list extras only when ticked in the review', async () => {
listOf([FRESH])
vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: 'Fresh', targets: [PREVIEW] } } } as never)
const apply = vi.spyOn(api, 'applyProfile').mockResolvedValue(result([{ step: 'lists', status: 'ok', message: 'done' }], true))
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })).getByRole('checkbox', { name: 'ndns' }))
await userEvent.click(screen.getByRole('button', { name: 'Preview' }))
await userEvent.click(await screen.findByRole('checkbox', { name: 'Also remove extras' }))
await userEvent.click(screen.getByRole('button', { name: 'Apply' }))
await waitFor(() => expect(apply).toHaveBeenCalledWith('t', 'Fresh', ['ndns'], true, {}))
})
it('says which step failed and retries from that step', async () => {
listOf([FRESH])
vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: 'Fresh', targets: [PREVIEW] } } } as never)
const apply = vi
.spyOn(api, 'applyProfile')
.mockResolvedValueOnce(
result(
[
{ step: 'apps', status: 'ok', message: 'Installed Blocker.' },
{ step: 'configs', status: 'ok', message: '1 config(s) applied, 0 already in sync.' },
{ step: 'settings', status: 'failed', message: 'refused' },
{ step: 'lists', status: 'not-run', message: 'An earlier step failed.' },
],
false,
),
)
.mockResolvedValue(
result(
[
{ step: 'apps', status: 'skipped', message: 'Done by an earlier run.' },
{ step: 'configs', status: 'skipped', message: 'Done by an earlier run.' },
{ step: 'settings', status: 'ok', message: 'Applied and verified (1 field).' },
{ step: 'lists', status: 'ok', message: 'Added 2, removed 0.' },
],
true,
),
)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })).getByRole('checkbox', { name: 'ndns' }))
await userEvent.click(screen.getByRole('button', { name: 'Preview' }))
await userEvent.click(await screen.findByRole('button', { name: 'Apply' }))
const list = await screen.findByRole('list', { name: 'Steps on ndns' })
expect(within(list).getByText('Failed')).toBeInTheDocument()
expect(within(list).getByText('Not run')).toBeInTheDocument()
expect(screen.getByText(/Stopped at settings/)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry failed' }))
await waitFor(() => expect(apply).toHaveBeenCalledTimes(2))
expect(apply).toHaveBeenLastCalledWith('t', 'Fresh', ['ndns'], false, { ndns: 'settings' })
await waitFor(() => expect(within(screen.getByRole('list', { name: 'Steps on ndns' })).getAllByText('Done earlier')).toHaveLength(2))
})
it('lists a server with no admin token disabled, and shows a preview error', async () => {
listOf([FRESH])
vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'error', message: 'unreachable' } as never)
render(<Profiles open onOpenChange={() => {}} token="t" servers={[{ id: 'mdns', writable: false }, { id: 'ndns', writable: true }]} />)
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
const picker = await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })
expect(within(picker).getByRole('checkbox', { name: 'mdns' })).toBeDisabled()
await userEvent.click(within(picker).getByRole('checkbox', { name: 'ndns' }))
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
expect(await screen.findByText('unreachable')).toBeInTheDocument()
})
})
+546
View File
@@ -0,0 +1,546 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
applyProfile,
deleteProfile,
exportProfiles,
getProfile,
importProfiles,
listProfiles,
previewProfile,
snapshotProfile,
type ProfileImportPlan,
type ProfileSummary,
type ProfileTargetPreview,
type ProfileView,
} from '../../api/hub-profiles'
import type { RunOutcome } from '../../api/apps'
import { Alert } from '../../ui/Alert'
import { Button } from '../../ui/Button'
import { Check } from '../../ui/Check'
import { Confirm } from '../../ui/Confirm'
import { Dialog } from '../../ui/Dialog'
import { Empty, Failure } from '../../ui/Empty'
import { LabeledInput, LabeledTextarea } from '../../ui/Field'
import { Tag } from '../../ui/Tag'
import { Skeleton } from '@/components/ui/skeleton'
import { DiffView } from './ConfigDiffView'
import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
/*
Behind a hub, for an admin: profiles, named bundles of store apps (with the hub's baseline config of
each), the shared settings and the Blocked and Allowed lists. One is made from the hub's current
baselines, can be exported to another hub, and is applied to one or several servers through the
same picker, review and results as every other change here. A server's steps run in order (apps,
app configs, settings, lists) and a failed one stops the rest for that server; "Retry failed" starts
again at the failed step. Nothing here shows a secret or an app's config text.
*/
const sessionEnded = (kind: string, message?: string): string => (kind === 'error' ? (message ?? 'Failed.') : 'The session ended.')
const when = (iso: string): string => (iso === '' ? '' : new Date(iso).toLocaleString())
const APP_STATE = {
install: ['warn', 'Will be installed'],
present: ['ok', 'Already installed'],
'version-differs': ['info', 'Other version installed'],
'not-in-store': ['dan', 'Not in this store'],
} as const
const CONFIG_STATE = {
same: ['ok', 'Same'],
differs: ['warn', 'Will change'],
'after-install': ['info', 'Set after install'],
unreadable: ['dan', 'Cannot read'],
} as const
/* ---- Apply: the review of what each server would get ---- */
function PreviewBody({ targets, opts }: { targets: ProfileTargetPreview[]; opts: { remove: boolean } }) {
const [remove, setRemove] = useState(opts.remove)
const extras = targets.reduce((n, t) => n + (t.lists?.blocked.extra ?? 0) + (t.lists?.allowed.extra ?? 0), 0)
return (
<div className="flex flex-col gap-6">
{targets.map((t) => (
<section key={t.server} aria-label={`Preview ${t.server}`} className="flex flex-col gap-3">
<h3 className="m-0 flex items-center gap-2 text-sm font-semibold">
{t.server}
{!t.ok && <Tag tone="dan">Cannot apply</Tag>}
</h3>
{t.error !== undefined && <Alert type="danger" title="Error">{t.error}</Alert>}
{t.warnings.map((w) => (
<Alert key={w} type="warning" title="Warning">
{w}
</Alert>
))}
{t.ok && (
<ol className="m-0 flex list-none flex-col gap-3 p-0" aria-label={`Steps on ${t.server}`}>
{t.apps.length > 0 && (
<li className="flex flex-col gap-1 text-sm">
<span className="font-medium">1. Apps</span>
<ul className="m-0 flex list-none flex-col gap-1 p-0 pl-4">
{t.apps.map((a) => (
<li key={a.name} className="flex flex-wrap items-center gap-2 text-xs">
<span className="font-medium">{a.name}</span>
<Tag tone={APP_STATE[a.state][0]}>{APP_STATE[a.state][1]}</Tag>
<span className="text-muted-foreground">
{a.state === 'version-differs' ? `${a.installedVersion} here, ${a.profileVersion} in the profile; it is not updated` : a.state === 'install' ? `Store offers ${a.storeVersion}` : `Version ${a.profileVersion}`}
</span>
</li>
))}
</ul>
</li>
)}
{t.configs.length > 0 && (
<li className="flex flex-col gap-1 text-sm">
<span className="font-medium">2. App configs</span>
<ul className="m-0 flex list-none flex-col gap-2 p-0 pl-4">
{t.configs.map((c) => (
<li key={c.name} className="flex flex-col gap-1 text-xs">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{c.name}</span>
<Tag tone={CONFIG_STATE[c.state][0]}>{CONFIG_STATE[c.state][1]}</Tag>
{c.error !== undefined && <span className="text-muted-foreground">{c.error}</span>}
</span>
{c.changes !== undefined && <DiffView diff={c.changes} from="Now" to="After apply" />}
</li>
))}
</ul>
</li>
)}
{t.settings !== null && (
<li className="flex flex-col gap-1 text-sm">
<span className="flex items-center gap-2 font-medium">
3. Settings
{t.settings.error !== undefined ? <Tag tone="dan">Cannot read</Tag> : t.settings.unchanged ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">{`${t.settings.fields.length} field(s) will change`}</Tag>}
</span>
{t.settings.error !== undefined && <span className="pl-4 text-xs text-muted-foreground">{t.settings.error}</span>}
{t.settings.secretFields.length > 0 && <span className="pl-4 text-xs text-muted-foreground">{t.settings.secretFields.join(', ')}: the secret values are sent but never shown.</span>}
{t.settings.error === undefined && !t.settings.unchanged && (
<div className="pl-4">
<DiffView diff={t.settings.changes} from="Now" to="After apply" />
</div>
)}
</li>
)}
{t.lists !== null && (
<li className="flex flex-col gap-1 text-sm">
<span className="flex items-center gap-2 font-medium">
4. Blocked and Allowed lists
{t.lists.error !== undefined ? <Tag tone="dan">Cannot read</Tag> : t.lists.status === 'same' ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">Will change</Tag>}
</span>
{t.lists.error !== undefined ? (
<span className="pl-4 text-xs text-muted-foreground">{t.lists.error}</span>
) : (
<span className="pl-4 text-xs text-muted-foreground">
Blocked: {t.lists.blocked.missing} to add, {t.lists.blocked.extra} extra. Allowed: {t.lists.allowed.missing} to add, {t.lists.allowed.extra} extra.
</span>
)}
{t.lists.tooMany !== undefined && <span className="pl-4 text-xs text-muted-foreground">{t.lists.tooMany}</span>}
</li>
)}
</ol>
)}
</section>
))}
{/* Only when a server has list domains the profile does not: otherwise there is nothing to remove. */}
{extras > 0 && (
<Check
label="Also remove extras"
help={`Deletes the ${extras} list domain(s) a server has that the profile does not. The server's lists are saved to the hub's history first.`}
checked={remove}
onChange={(on) => {
opts.remove = on
setRemove(on)
}}
/>
)}
</div>
)
}
/* The picker, the review and the results of applying one profile. */
function applyRequestFor(token: string | null, name: string, servers: { id: string; writable: boolean }[]): RunRequest {
const opts = { remove: false }
// Where each server should start next time: the step that failed on it.
const from: Record<string, string> = {}
return {
title: `Apply the profile ${name}`,
intro: 'Each server goes through the steps in order: install missing apps from its own store, apply the app configs, the settings, then the lists. A step that fails stops the later ones for that server.',
confirmLabel: 'Preview',
servers: pickerServers(servers),
preselected: [],
review: async (targets) => {
opts.remove = false
const outcome = await previewProfile(token, name, targets, false)
if (outcome.kind !== 'ok') return { kind: 'error', message: sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined) }
return { kind: 'ok', confirmLabel: 'Apply', context: undefined, body: <PreviewBody targets={outcome.data.response.targets} opts={opts} /> }
},
run: async (targets): Promise<RunOutcome> => {
const outcome = await applyProfile(token, name, targets, opts.remove, { ...from })
if (outcome.kind === 'ok') {
for (const r of outcome.data.response.results) {
const failed = r.steps?.find((s) => s.status === 'failed')
if (r.ok || failed === undefined) delete from[r.server]
else from[r.server] = failed.step
}
}
return outcome
},
}
}
/* ---- Make one ---- */
function CreateProfile({ token, onClose, onDone }: { token: string | null; onClose: () => void; onDone: () => void }) {
const [name, setName] = useState('')
const [apps, setApps] = useState(true)
const [settings, setSettings] = useState(true)
const [lists, setLists] = useState(true)
const [busy, setBusy] = useState(false)
const [problem, setProblem] = useState<string | null>(null)
async function create() {
setBusy(true)
setProblem(null)
const outcome = await snapshotProfile(token, name.trim(), { apps: apps ? undefined : [], settings, lists })
setBusy(false)
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
onDone()
}
return (
<Dialog
open
onOpenChange={(o) => {
if (!o) onClose()
}}
title="Create a profile"
close="Cancel"
actions={
<Button variant="primary" disabled={busy || name.trim() === '' || (!apps && !settings && !lists)} onClick={() => void create()}>
Create
</Button>
}
>
<p className="m-0 text-sm text-muted-foreground">
A profile is a copy of the hub&apos;s baselines as they are now. An existing profile with the same name is replaced.
</p>
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
<LabeledInput label="Profile name" value={name} maxLength={60} onChange={(e) => setName(e.target.value)} />
<div className="flex flex-col gap-3" role="group" aria-label="Parts">
<Check label="Apps" help="Every app that has a baseline, with its store version and its baseline config. Store apps only." checked={apps} onChange={setApps} />
<Check label="Settings" help="The shared fields of the settings baseline. Host fields stay out." checked={settings} onChange={setSettings} />
<Check label="Blocked and Allowed lists" help="The lists baseline." checked={lists} onChange={setLists} />
</div>
</Dialog>
)
}
/* ---- View one ---- */
function ViewProfile({ token, name, onClose }: { token: string | null; name: string; onClose: () => void }) {
const [view, setView] = useState<ProfileView | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const load = useCallback(async () => {
const outcome = await getProfile(token, name)
if (outcome.kind === 'ok') {
setView(outcome.data.response)
setFailure(null)
} else setFailure(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
}, [token, name])
useEffect(() => {
void load()
}, [load])
return (
<Dialog
open
onOpenChange={(o) => {
if (!o) onClose()
}}
size="wide"
title={`Profile: ${name}`}
>
{failure !== null ? (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not read the profile. {failure}</span>
<Button variant="primary" onClick={() => void load()}>
Retry
</Button>
</span>
</Failure>
) : view === null ? (
<Skeleton className="h-24 w-full" role="status" aria-label="Loading the profile" />
) : (
<div className="flex flex-col gap-4">
<p className="m-0 text-xs text-muted-foreground">
Made by {view.updatedBy} at {when(view.updatedAt)}. Secrets show as {'<secret>'}; an app&apos;s config text is not shown.
</p>
<section aria-label="Apps" className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold">Apps</h3>
{view.apps.length === 0 ? (
<Empty compact>No apps.</Empty>
) : (
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{view.apps.map((a) => (
<li key={a.name} className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{a.name}</span>
<span className="text-muted-foreground">{a.version}</span>
<Tag tone={a.hasConfig ? 'info' : 'neutral'}>{a.hasConfig ? 'With config' : 'No config'}</Tag>
</li>
))}
</ul>
)}
</section>
<section aria-label="Settings" className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold">Settings</h3>
{view.settings === null ? <Empty compact>Not part of this profile.</Empty> : <LabeledTextarea label="Shared settings (JSON)" mono rows={10} readOnly spellCheck={false} value={JSON.stringify(view.settings, null, 2)} onChange={() => {}} />}
</section>
<section aria-label="Lists" className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold">Blocked and Allowed lists</h3>
{view.lists === null ? <Empty compact>Not part of this profile.</Empty> : <p className="m-0 text-sm">{view.lists.blocked} blocked and {view.lists.allowed} allowed domains.</p>}
</section>
</div>
)}
</Dialog>
)
}
/* ---- Import ---- */
function ImportProfiles({ token, onClose, onDone }: { token: string | null; onClose: () => void; onDone: () => void }) {
const [text, setText] = useState<string | null>(null)
const [plan, setPlan] = useState<ProfileImportPlan | null>(null)
const [problem, setProblem] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
async function check() {
const file = fileRef.current?.files?.[0]
if (!file) return setProblem('Please select a profiles file.')
setBusy(true)
setProblem(null)
const content = await file.text()
const outcome = await importProfiles(token, content, false)
setBusy(false)
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
setText(content)
setPlan(outcome.data.response)
}
async function commit() {
if (text === null) return
setBusy(true)
const outcome = await importProfiles(token, text, true)
setBusy(false)
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
setPlan(outcome.data.response)
setDone(true)
onDone()
}
const changes = plan?.profiles.filter((p) => p.status !== 'same').length ?? 0
const TONE = { new: 'info', changed: 'warn', same: 'ok' } as const
const LABEL = { new: 'New', changed: 'Changed', same: 'Same' } as const
return (
<Dialog
open
onOpenChange={(o) => {
if (!o) onClose()
}}
title="Import profiles"
actions={
plan === null ? (
<Button variant="primary" disabled={busy} onClick={() => void check()}>
Check file
</Button>
) : !done ? (
<Button variant="primary" disabled={busy || changes === 0} onClick={() => void commit()}>
Import
</Button>
) : undefined
}
>
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
{plan === null ? (
<>
<p className="m-0 text-sm text-muted-foreground">Choose a file made by Export. It is checked first; nothing is changed until you press Import, and no server is touched.</p>
<LabeledInput label="Profiles file" type="file" accept="application/json,.json" ref={fileRef} />
</>
) : (
<>
{done && <Alert type="success" title="Imported.">The profiles were saved.</Alert>}
<ul className="m-0 flex list-none flex-col gap-2 p-0" aria-label="Profiles in the file">
{plan.profiles.map((p) => (
<li key={p.name} className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{p.name}</span>
<Tag tone={TONE[p.status]}>{LABEL[p.status]}</Tag>
<span className="text-xs text-muted-foreground">{p.apps} app(s){p.settings ? ', settings' : ''}{p.lists ? ', lists' : ''}</span>
</li>
))}
</ul>
</>
)}
</Dialog>
)
}
/* ---- The list ---- */
export function Profiles({
open,
onOpenChange,
token,
servers,
}: {
open: boolean
onOpenChange: (o: boolean) => void
token: string | null
/** The servers of the hub, and whether it holds an admin token for each. */
servers: { id: string; writable: boolean }[]
}) {
const [profiles, setProfiles] = useState<ProfileSummary[] | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [importing, setImporting] = useState(false)
const [viewing, setViewing] = useState<string | null>(null)
const [deleting, setDeleting] = useState<string | null>(null)
const [request, setRequest] = useState<RunRequest | null>(null)
const [problem, setProblem] = useState<string | null>(null)
const load = useCallback(async () => {
const outcome = await listProfiles(token)
if (outcome.kind === 'ok') {
setProfiles(outcome.data.response.profiles)
setFailure(null)
} else setFailure(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
}, [token])
useEffect(() => {
if (open) void load()
}, [open, load])
async function exportFile(name: string) {
setProblem(null)
const outcome = await exportProfiles(token, name)
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
const url = URL.createObjectURL(new Blob([outcome.data.text], { type: 'application/json' }))
const link = document.createElement('a')
link.href = url
link.download = outcome.data.filename
link.click()
URL.revokeObjectURL(url)
}
async function remove(name: string) {
const outcome = await deleteProfile(token, name)
if (outcome.kind !== 'ok') setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
await load()
}
const create = (
<Button variant="primary" onClick={() => setCreating(true)}>
Create from current baselines
</Button>
)
return (
<>
<Dialog
open={open}
onOpenChange={onOpenChange}
size="wide"
title="Profiles"
actions={
<>
<Button onClick={() => setImporting(true)}>Import profiles</Button>
{profiles !== null && profiles.length > 0 && create}
</>
}
>
<p className="m-0 text-sm text-muted-foreground">
A profile brings a new or reset server in line: it installs the apps from that server&apos;s own store and applies their configs, the shared settings and the Blocked and Allowed lists.
</p>
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
{failure !== null && profiles === null ? (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not read the profiles. {failure}</span>
<Button variant="primary" onClick={() => void load()}>
Retry
</Button>
</span>
</Failure>
) : profiles === null ? (
<div className="flex flex-col gap-3" role="status" aria-label="Loading the profiles">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : profiles.length === 0 ? (
<Empty title="No profiles yet" actions={create}>
Make one from the baselines the hub holds now, or import a file from another hub.
</Empty>
) : (
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Profiles">
{profiles.map((p) => (
<li key={p.name} aria-label={p.name} className="flex flex-wrap items-center gap-3 border-b border-border pb-3 text-sm last:border-b-0">
<span className="font-medium">{p.name}</span>
<Tag tone="neutral">{`${p.apps} app${p.apps === 1 ? '' : 's'}`}</Tag>
{p.configs > 0 && <Tag tone="neutral">{`${p.configs} config${p.configs === 1 ? '' : 's'}`}</Tag>}
{p.settings !== null && <Tag tone="neutral">{`${p.settings} settings`}</Tag>}
{p.lists !== null && <Tag tone="neutral">{`${p.lists.blocked + p.lists.allowed} list domains`}</Tag>}
<span className="text-xs text-muted-foreground">{p.updatedBy} · {when(p.updatedAt)}</span>
<span className="ml-auto flex flex-wrap gap-2">
<Button size="sm" onClick={() => setViewing(p.name)}>
View
</Button>
<Button size="sm" variant="primary" onClick={() => setRequest(applyRequestFor(token, p.name, servers))}>
Apply to servers…
</Button>
<Button size="sm" onClick={() => void exportFile(p.name)}>
Export
</Button>
<Button size="sm" variant="danger" onClick={() => setDeleting(p.name)}>
Delete
</Button>
</span>
</li>
))}
</ul>
)}
</Dialog>
{creating && (
<CreateProfile
token={token}
onClose={() => setCreating(false)}
onDone={() => {
setCreating(false)
void load()
}}
/>
)}
{importing && (
<ImportProfiles
token={token}
onClose={() => setImporting(false)}
onDone={() => void load()}
/>
)}
{viewing !== null && <ViewProfile token={token} name={viewing} onClose={() => setViewing(null)} />}
<Confirm
open={deleting !== null}
title="Delete profile"
text={`Delete the profile ${deleting ?? ''}? Servers that already had it applied keep what they got.`}
label="Delete"
onClose={() => setDeleting(null)}
onConfirm={() => deleting !== null && remove(deleting)}
/>
{request !== null && <RunFlow request={request} onClose={() => setRequest(null)} />}
</>
)
}
+22 -5
View File
@@ -45,7 +45,11 @@ export function pickerServers(
})
}
type Stage = 'pick' | 'reviewing' | 'review' | 'running' | 'results'
/* The steps of a profile apply, drawn under the server they ran on. */
const STEP_LABEL = { apps: 'Apps', configs: 'App configs', settings: 'Settings', lists: 'Lists' } as const
const STEP_STATUS = { ok: 'Done', failed: 'Failed', skipped: 'Done earlier', 'not-run': 'Not run' } as const
type Stage ='pick' | 'reviewing' | 'review' | 'running' | 'results'
export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (changed: boolean) => void }) {
const [stage, setStage] = useState<Stage>('pick')
@@ -185,10 +189,23 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
) : (
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Results">
{results.map((r) => (
<li key={r.server} className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{r.server}</span>
<Tag tone={r.ok ? 'ok' : 'dan'}>{r.ok ? 'Done' : 'Failed'}</Tag>
{r.message !== undefined && <span className="text-muted-foreground">{r.message}</span>}
<li key={r.server} className="flex flex-col gap-2 text-sm">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{r.server}</span>
<Tag tone={r.ok ? 'ok' : 'dan'}>{r.ok ? 'Done' : 'Failed'}</Tag>
{r.message !== undefined && <span className="text-muted-foreground">{r.message}</span>}
</span>
{r.steps !== undefined && r.steps.length > 0 && (
<ul className="m-0 flex list-none flex-col gap-1 p-0 pl-4" aria-label={`Steps on ${r.server}`}>
{r.steps.map((s) => (
<li key={s.step} className="flex flex-wrap items-center gap-2 text-xs">
<span className="font-medium">{STEP_LABEL[s.step]}</span>
<Tag tone={s.status === 'ok' ? 'ok' : s.status === 'failed' ? 'dan' : 'neutral'}>{STEP_STATUS[s.status]}</Tag>
<span className="text-muted-foreground">{s.message}</span>
</li>
))}
</ul>
)}
</li>
))}
</ul>
+2 -2
View File
@@ -54,7 +54,7 @@ function StatusTag({ status, differing }: { status: string; differing: number })
/* ---- Requests for the picker, the review and the results ---- */
function settingsApplyRequest(token: string | null, servers: PickerServer[], preselected: string[], scope: Scope): RunRequest {
export function settingsApplyRequest(token: string | null, servers: PickerServer[], preselected: string[], scope: Scope): RunRequest {
const group = scope === 'blocking' ? 'blocking' : undefined
return {
title: scope === 'blocking' ? 'Apply the blocking settings' : 'Apply the settings baseline',
@@ -145,7 +145,7 @@ function ListsReview({ targets, opts }: { targets: ListsServer[]; opts: { remove
)
}
function listsApplyRequest(token: string | null, servers: PickerServer[], preselected: string[]): RunRequest {
export function listsApplyRequest(token: string | null, servers: PickerServer[], preselected: string[]): RunRequest {
const opts = { remove: false }
return {
title: 'Apply the Blocked and Allowed lists',