Hub stage 8: settings drift, webhook notices, profiles
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:
1 parent
392b644faa
commit
9742f0fe69
32 files changed
+3123
-106
No files matched your search
+18
-3
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {} })
|
||||
@@ -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
@@ -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's baseline plus the server'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()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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' ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'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'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'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)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user