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 <noreply@anthropic.com>
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

+51 -8
View File
@@ -254,9 +254,9 @@ export class SettingsConfig {
return hashOf(shared)
}
async preview(ids: string[], only?: readonly string[]): Promise<{ targets: SettingsTargetPreview[] }> {
async preview(ids: string[], only?: readonly string[], override?: SettingsBaseline): Promise<{ targets: SettingsTargetPreview[] }> {
const targets = this.#targets(ids)
const baseline = this.getBaseline()
const baseline = override ?? this.getBaseline()
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
const extra = this.#extraLocal()
const base = normalize(baseline.settings)
@@ -341,9 +341,10 @@ export class SettingsConfig {
}
}
async apply(ids: string[], expect: Record<string, string>, only?: readonly string[]): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
/* `override` is a baseline that is not the hub's own (a profile's settings). */
async apply(ids: string[], expect: Record<string, string>, only?: readonly string[], override?: SettingsBaseline): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
const targets = this.#targets(ids)
const baseline = this.getBaseline()
const baseline = override ?? this.getBaseline()
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
const extra = this.#extraLocal()
const done = await Promise.all(targets.map((s) => this.#applyOne(s, baseline, extra, expect[s.id], only)))
@@ -352,6 +353,48 @@ export class SettingsConfig {
return { results: done.map((d) => d.result), details }
}
/* The extra local field names (for the profiles, which keep shared fields only). */
extraLocal(): string[] {
return this.#extraLocal()
}
/*
For the drift check: which servers differ from the settings baseline and from the lists baseline.
Nothing is read for a baseline that does not exist. Field names only, never a value.
*/
async driftFindings(): Promise<{ kind: 'settings' | 'lists'; server: string; status: 'differs' | 'unreadable'; fields: string[] }[]> {
const out: { kind: 'settings' | 'lists'; server: string; status: 'differs' | 'unreadable'; fields: string[] }[] = []
const baseline = this.getBaseline()
const lists = this.getListsBaseline()
if (baseline === null && lists === null) return out
const extra = this.#extraLocal()
await Promise.all(
this.#config.servers.map(async (s) => {
if (baseline !== null) {
try {
const fields = changedFields(baseline.settings, await this.#read(s), extra)
if (fields.length > 0) out.push({ kind: 'settings', server: s.id, status: 'differs', fields })
} catch {
out.push({ kind: 'settings', server: s.id, status: 'unreadable', fields: [] })
}
}
if (lists !== null) {
try {
const live = await this.#readLists(s)
const fields = (['blocked', 'allowed'] as const).filter((l) => {
const d = diffSets(lists[l], live[l])
return d.missing.length + d.extra.length > 0
})
if (fields.length > 0) out.push({ kind: 'lists', server: s.id, status: 'differs', fields })
} catch {
out.push({ kind: 'lists', server: s.id, status: 'unreadable', fields: [] })
}
}
}),
)
return out
}
/* ---- History: list and view (no rollback; apply the baseline again, or accept an old one by hand) ---- */
historyList(): HistoryEntry[] {
@@ -447,9 +490,9 @@ export class SettingsConfig {
return { detail: `from ${serverId}: ${lists.blocked.length} blocked, ${lists.allowed.length} allowed` }
}
async listsPreview(ids: string[], removeExtras: boolean): Promise<{ targets: (ListsServerView & { tooMany?: string })[] }> {
async listsPreview(ids: string[], removeExtras: boolean, override?: ListsBaseline): Promise<{ targets: (ListsServerView & { tooMany?: string })[] }> {
const targets = this.#targets(ids)
const baseline = this.getListsBaseline()
const baseline = override ?? this.getListsBaseline()
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
const out = await Promise.all(
targets.map(async (s) => {
@@ -530,9 +573,9 @@ export class SettingsConfig {
}
}
async listsApply(ids: string[], removeExtras: boolean): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
async listsApply(ids: string[], removeExtras: boolean, override?: ListsBaseline): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
const targets = this.#targets(ids)
const baseline = this.getListsBaseline()
const baseline = override ?? this.getListsBaseline()
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
const done = await Promise.all(targets.map((s) => this.#applyLists(s, baseline, removeExtras)))
const details: Record<string, string> = {}