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 <noreply@anthropic.com>
This commit is contained in:
1 parent
392b644faa
commit
9742f0fe69
32 files changed
+3123
-106
No files matched your search
+51
-8
@@ -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> = {}
|
||||
|
||||
Reference in new issue
Block a user