diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index 24bceb7..5df4bd4 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -63,6 +63,7 @@ import { logFormChange } from '@/lib/debug'; import { FieldWidget } from '@/components/forms/FieldWidget'; import { DnsConnectCard } from '@/features/dns/DnsConnectCard'; import { isSieveScriptField } from '@/lib/sievepad'; +import { reloadActionFor } from '@/lib/settingsApply'; import type { Field, Fields, Form, FormField, Schema } from '@/types/schema'; import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap'; @@ -529,10 +530,13 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { setCreatedObjectId(newId); setServerCreatedProps(extraProps); } else { - toast({ - title: t('form.createdSuccess', 'Created successfully'), - variant: 'success', - }); + // inbuxa: a settings object says "Saved and applied" once the server has it. + if (!reloadActionFor(obj.objectName)) { + toast({ + title: t('form.createdSuccess', 'Created successfully'), + variant: 'success', + }); + } setOriginalData({ ...formData }); navigate(`/${section}/${viewName}`); } @@ -616,10 +620,13 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { const setResult = setResponse[1] as unknown as JmapSetResponse; if (setResult.updated && updateId in setResult.updated) { - toast({ - title: t('form.savedSuccess', 'Saved successfully'), - variant: 'success', - }); + // inbuxa: a settings object says "Saved and applied" once the server has it. + if (!reloadActionFor(obj.objectName)) { + toast({ + title: t('form.savedSuccess', 'Saved successfully'), + variant: 'success', + }); + } if (isSingleton) { const getResponses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties); diff --git a/src/components/layout/SettingsApplyBanner.tsx b/src/components/layout/SettingsApplyBanner.tsx new file mode 100644 index 0000000..5811024 --- /dev/null +++ b/src/components/layout/SettingsApplyBanner.tsx @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: shown when saved settings are stored but the server couldn't apply + * them. It stays until they are applied or it is dismissed, names the object + * the server couldn't build and links to it, and offers to try again. + */ + +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, Loader2, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { humanize } from '@/lib/humanize'; +import { resolveList } from '@/lib/schemaResolver'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { useSettingsApplyStore } from '@/stores/settingsApplyStore'; + +export function SettingsApplyBanner() { + const { t } = useTranslation(); + const failure = useSettingsApplyStore((s) => s.failure); + const applying = useSettingsApplyStore((s) => s.applying); + const applyNow = useSettingsApplyStore((s) => s.applyNow); + const dismiss = useSettingsApplyStore((s) => s.dismiss); + const schema = useSchemaStore((s) => s.schema); + const viewToSection = useSchemaStore((s) => s.viewToSection); + + if (!failure) return null; + + let objectLabel: string | null = null; + let objectLink: string | null = null; + if (failure.object) { + const objectName = `x:${failure.object.object}`; + const objectType = schema?.objects[objectName]; + const list = schema ? resolveList(schema, objectName, objectName) : null; + objectLabel = list?.singularName ?? humanize(failure.object.object); + const section = viewToSection[objectName]; + if (section) { + objectLink = + objectType?.type === 'singleton' || failure.object.id === 'singleton' + ? `/${section}/${objectName}` + : `/${section}/${objectName}/${encodeURIComponent(failure.object.id)}`; + } + } + + return ( +
+ +
+

+ {t('settingsApply.failed', "Saved, but the server couldn't apply the settings: {{reason}}", { + reason: failure.message, + })} +

+ {objectLabel && ( +

+ {t('settingsApply.failedObject', 'The problem is in {{object}}.', { object: objectLabel })}{' '} + {objectLink && ( + + {t('settingsApply.openObject', 'Open it')} + + )} +

+ )} +

+ {t( + 'settingsApply.stillRunning', + 'The server keeps running on the settings it had. Your changes are saved and apply once this is fixed.', + )} +

+
+
+ + +
+
+ ); +} diff --git a/src/i18n/en.json b/src/i18n/en.json index f0c63f8..a83442f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -345,6 +345,17 @@ "periodValue": "Period value" }, "sections": "Sections", + "settingsApply": { + "applied": "Saved and applied", + "applyNow": "Apply now", + "dismiss": "Dismiss", + "failed": "Saved, but the server couldn't apply the settings: {{reason}}", + "failedObject": "The problem is in {{object}}.", + "noAnswer": "The server did not answer.", + "notConfirmed": "The server did not confirm the reload.", + "openObject": "Open it", + "stillRunning": "The server keeps running on the settings it had. Your changes are saved and apply once this is fixed." + }, "sievepad": { "debug": "Debug", "defaultName": "Sieve script", diff --git a/src/lib/settingsApply.test.ts b/src/lib/settingsApply.test.ts new file mode 100644 index 0000000..07faa5d --- /dev/null +++ b/src/lib/settingsApply.test.ts @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { + describeApplyFailure, + describeRequestFailure, + reloadActionFor, + reloadActionsFor, + writesRegistry, + writtenRegistryTypes, +} from './settingsApply'; +import type { JmapMethodResponse } from '@/types/jmap'; + +describe('reloadActionFor', () => { + it('reloads settings objects', () => { + expect(reloadActionFor('x:MtaDeliverySchedule')).toBe('ReloadSettings'); + expect(reloadActionFor('x:SpamPyzor')).toBe('ReloadSettings'); + expect(reloadActionFor('x:NetworkListener')).toBe('ReloadSettings'); + }); + + it('reloads a type it has never heard of', () => { + expect(reloadActionFor('x:SomethingNew')).toBe('ReloadSettings'); + }); + + it('uses the narrower action where one exists', () => { + expect(reloadActionFor('x:Certificate')).toBe('ReloadTlsCertificates'); + expect(reloadActionFor('x:StoreLookup')).toBe('ReloadLookupStores'); + expect(reloadActionFor('x:MemoryLookupKeyValue')).toBe('ReloadLookupStores'); + expect(reloadActionFor('x:BlockedIp')).toBe('ReloadBlockedIps'); + }); + + it('skips what the server reloads on write', () => { + expect(reloadActionFor('x:Directory')).toBeNull(); + expect(reloadActionFor('x:Authentication')).toBeNull(); + }); + + it('skips data read live, operations and stores', () => { + for (const type of ['Account', 'Domain', 'DkimSignature', 'Tenant', 'Action', 'QueuedMessage', 'DataStore']) { + expect(reloadActionFor(`x:${type}`)).toBeNull(); + } + }); + + it('ignores anything outside the registry', () => { + expect(reloadActionFor('Email')).toBeNull(); + expect(reloadActionFor('inbuxa:ProtocolPolicy')).toBeNull(); + }); +}); + +describe('reloadActionsFor', () => { + it('collapses duplicates and puts settings last', () => { + expect(reloadActionsFor(['x:MtaRoute', 'x:Certificate', 'x:MtaDeliverySchedule', 'x:Account'])).toEqual([ + 'ReloadTlsCertificates', + 'ReloadSettings', + ]); + }); +}); + +describe('registry writes', () => { + it('recognizes an x: set call among others', () => { + expect(writesRegistry([['x:MtaRoute/get', {}, '0']])).toBe(false); + expect( + writesRegistry([ + ['Blob/upload', {}, 'b'], + ['x:MtaRoute/set', {}, '0'], + ]), + ).toBe(true); + }); + + it('reports only the types a response changed', () => { + const responses: JmapMethodResponse[] = [ + ['Blob/upload', { created: { b: {} } }, 'b'], + ['x:MtaRoute/set', { created: null, updated: { a: null }, destroyed: null }, '0'], + ['x:SpamPyzor/set', { created: {}, updated: {}, destroyed: [], notUpdated: { singleton: {} } }, '1'], + ['x:Certificate/set', { destroyed: ['c1'] }, '2'], + ['error', { type: 'serverFail' }, '3'], + ]; + expect(writtenRegistryTypes(responses)).toEqual(['x:MtaRoute', 'x:Certificate']); + }); +}); + +describe('describeApplyFailure', () => { + it('keeps the server message and the object it named', () => { + expect( + describeApplyFailure({ + type: 'validationFailed', + description: 'Failed to resolve Pyzor host', + objectId: { object: 'SpamPyzor', id: 'singleton' }, + }), + ).toEqual({ message: 'Failed to resolve Pyzor host', object: { object: 'SpamPyzor', id: 'singleton' } }); + }); + + it('spells out validation errors when there is no description', () => { + const failure = describeApplyFailure({ + type: 'validationFailed', + objectId: { object: 'MtaRoute', id: 'b' }, + validationErrors: [{ type: 'Required', property: 'address' }], + }); + expect(failure.message).toBe('address: This field is required.'); + expect(failure.object).toEqual({ object: 'MtaRoute', id: 'b' }); + }); + + it('falls back to the error type', () => { + expect(describeApplyFailure({ type: 'forbidden' }).message).toBe( + 'You do not have permission to perform this action.', + ); + }); +}); + +describe('describeRequestFailure', () => { + it('reads thrown errors and method errors', () => { + expect(describeRequestFailure(new Error('Network down')).message).toBe('Network down'); + expect(describeRequestFailure({ type: 'forbidden', description: 'No' }).message).toBe('No'); + expect(describeRequestFailure(undefined).message).toBe('The server did not answer.'); + }); +}); diff --git a/src/lib/settingsApply.ts b/src/lib/settingsApply.ts new file mode 100644 index 0000000..bb52df5 --- /dev/null +++ b/src/lib/settingsApply.ts @@ -0,0 +1,174 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: which saved registry objects the running server has to be told to + * apply, and how to read its answer when it can't. + * + * A write to the registry is stored at once, but most settings only take + * effect when the server rebuilds its configuration from the registry: the + * x:Action ReloadSettings action, which also carries the change to every + * node of a cluster. The server does that by itself on a write for a few + * types only (directories and the default authentication). Everything else + * waited for someone to open Management > Actions and reload by hand. + * + * A reload is all or nothing: the new configuration replaces the running one + * only when every settings object builds, so applying straight after a save + * can't leave the server half configured. When something doesn't build, the + * server keeps what it had and names the object and the problem. + */ + +import i18n from '@/i18n'; +import { friendlySetError, validationErrorMessage } from '@/lib/jmapErrors'; +import type { JmapMethodCall, JmapMethodResponse, JmapObjectRef, JmapSetError } from '@/types/jmap'; + +export type ReloadAction = 'ReloadSettings' | 'ReloadTlsCertificates' | 'ReloadLookupStores' | 'ReloadBlockedIps'; + +/** The order the actions run in when more than one is due. */ +export const RELOAD_ORDER: readonly ReloadAction[] = [ + 'ReloadLookupStores', + 'ReloadTlsCertificates', + 'ReloadBlockedIps', + 'ReloadSettings', +]; + +/** Types whose own reload action applies them; the server rebuilds nothing else for these. */ +const OWN_ACTION: Record = { + Certificate: 'ReloadTlsCertificates', + StoreLookup: 'ReloadLookupStores', + HttpLookup: 'ReloadLookupStores', + MemoryLookupKey: 'ReloadLookupStores', + MemoryLookupKeyValue: 'ReloadLookupStores', + BlockedIp: 'ReloadBlockedIps', +}; + +/** Types that need no reload after a write. Anything not listed here is reloaded. */ +const NOTHING_TO_APPLY = new Set([ + // The server reloads these itself on every write, here and on every node. + 'Directory', + 'Authentication', + // Read from the registry when used, or kept current by cache invalidation. + 'Account', + 'AccountPassword', + 'AccountSettings', + 'ApiKey', + 'AppPassword', + 'Domain', + 'DkimSignature', + 'MailingList', + 'MaskedEmail', + 'OAuthClient', + 'PublicKey', + 'Role', + 'Tenant', + // Operations, records and telemetry rather than settings. + 'Action', + 'ArchivedItem', + 'ArfExternalReport', + 'Bootstrap', + 'ClusterNode', + 'DmarcExternalReport', + 'DmarcInternalReport', + 'Log', + 'Metric', + 'QueuedMessage', + 'SpamTrainingSample', + 'Task', + 'TlsExternalReport', + 'TlsInternalReport', + 'Trace', + // Stores are opened once at startup and a reload keeps the ones it has, so + // saying "applied" would be untrue. They take effect on a restart. + 'BlobStore', + 'Coordinator', + 'DataStore', + 'InMemoryStore', + 'SearchStore', + // Applications are unpacked by their own manager, which no reload reaches. + 'Application', +]); + +/** + * The action that applies a write to `objectName` (an `x:` registry name), or + * null when the write needs none. Types this list doesn't know are reloaded: + * an unneeded reload costs a second, a missing one leaves a setting unapplied. + */ +export function reloadActionFor(objectName: string): ReloadAction | null { + if (!objectName.startsWith('x:')) return null; + const type = objectName.slice(2); + if (NOTHING_TO_APPLY.has(type)) return null; + return OWN_ACTION[type] ?? 'ReloadSettings'; +} + +const REGISTRY_SET = /^(x:[A-Za-z0-9]+)\/set$/; + +/** Whether a request writes to the registry: any `x:/set` call. */ +export function writesRegistry(methodCalls: JmapMethodCall[]): boolean { + return methodCalls.some(([name]) => REGISTRY_SET.test(name)); +} + +/** The registry types a response says were created, changed or destroyed. */ +export function writtenRegistryTypes(methodResponses: JmapMethodResponse[]): string[] { + const types = new Set(); + for (const [name, result] of methodResponses) { + const match = REGISTRY_SET.exec(name); + if (!match || !result) continue; + const created = result.created as Record | null | undefined; + const updated = result.updated as Record | null | undefined; + const destroyed = result.destroyed as unknown[] | null | undefined; + if ( + (created && Object.keys(created).length > 0) || + (updated && Object.keys(updated).length > 0) || + (destroyed && destroyed.length > 0) + ) { + types.add(match[1]); + } + } + return [...types]; +} + +/** The actions due for a set of written types, in the order they run. */ +export function reloadActionsFor(objectNames: Iterable): ReloadAction[] { + const due = new Set(); + for (const name of objectNames) { + const action = reloadActionFor(name); + if (action) due.add(action); + } + return RELOAD_ORDER.filter((a) => due.has(a)); +} + +export interface ApplyFailure { + /** What went wrong, in the server's words where it gave any. */ + message: string; + /** The settings object that didn't build, when the server named one. */ + object?: JmapObjectRef; +} + +/** Reads a failed reload action into something to show an administrator. */ +export function describeApplyFailure(err: JmapSetError): ApplyFailure { + let message = err.description?.trim() ?? ''; + if (!message && err.validationErrors && err.validationErrors.length > 0) { + message = err.validationErrors + .map((ve) => (ve.property ? `${ve.property}: ${validationErrorMessage(ve)}` : validationErrorMessage(ve))) + .join('; '); + } + if (!message) message = friendlySetError(err); + const object = typeof err.objectId === 'object' && err.objectId?.object ? err.objectId : undefined; + return object ? { message, object } : { message }; +} + +/** A failure that came back as a method error or a failed request rather than a set error. */ +export function describeRequestFailure(err: unknown): ApplyFailure { + if (err instanceof Error && err.message) return { message: err.message }; + if (err && typeof err === 'object') { + const e = err as { type?: unknown; description?: unknown }; + if (typeof e.description === 'string' && e.description) return { message: e.description }; + if (typeof e.type === 'string' && e.type) { + return { message: i18n.t('jmapErrors.unexpected', 'Unexpected error ({{type}}).', { type: e.type }) }; + } + } + return { message: i18n.t('settingsApply.noAnswer', 'The server did not answer.') }; +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 6118343..12db2b1 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -22,6 +22,7 @@ import { TopBar } from '@/components/layout/TopBar'; import { Sidebar } from '@/components/layout/Sidebar'; import { SectionNav } from '@/components/layout/SectionNav'; import { MainContent } from '@/components/layout/MainContent'; +import { SettingsApplyBanner } from '@/components/layout/SettingsApplyBanner'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { @@ -320,6 +321,7 @@ export default function AdminPanel() { !useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'), )} > + diff --git a/src/services/jmap/client.ts b/src/services/jmap/client.ts index 1a5fd12..2218a89 100644 --- a/src/services/jmap/client.ts +++ b/src/services/jmap/client.ts @@ -12,6 +12,7 @@ import { apiFetch } from '@/services/api'; import { logJmapExchange } from '@/lib/debug'; import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap'; import type { Schema } from '@/types/schema'; +import { writesRegistry, writtenRegistryTypes } from '@/lib/settingsApply'; const JMAP_USING = [ 'urn:ietf:params:jmap:core', @@ -35,10 +36,44 @@ export function getAccountId(objectType: string): string { return activeAccountId; } +/** + * inbuxa: told about every registry write, so that saved settings can be + * applied on the server (settingsApplyStore). `started` comes before the + * request goes out and `finished` after it settles, with the types it changed. + */ +export interface RegistryWriteListener { + started(): void; + finished(objectNames: string[]): void; +} + +let registryWriteListener: RegistryWriteListener | null = null; + +export function setRegistryWriteListener(listener: RegistryWriteListener | null) { + registryWriteListener = listener; +} + export async function jmapRequest( methodCalls: JmapMethodCall[], signal?: AbortSignal, extraUsing: string[] = [], +): Promise { + const listener = writesRegistry(methodCalls) ? registryWriteListener : null; + if (!listener) return sendJmapRequest(methodCalls, signal, extraUsing); + listener.started(); + let written: string[] = []; + try { + const responses = await sendJmapRequest(methodCalls, signal, extraUsing); + written = writtenRegistryTypes(responses); + return responses; + } finally { + listener.finished(written); + } +} + +async function sendJmapRequest( + methodCalls: JmapMethodCall[], + signal?: AbortSignal, + extraUsing: string[] = [], ): Promise { const { apiUrl } = useAuthStore.getState(); let path = apiUrl || '/jmap'; diff --git a/src/stores/settingsApplyStore.test.ts b/src/stores/settingsApplyStore.test.ts new file mode 100644 index 0000000..897480f --- /dev/null +++ b/src/stores/settingsApplyStore.test.ts @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { RegistryWriteListener } from '@/services/jmap/client'; +import type { JmapMethodCall, JmapMethodResponse } from '@/types/jmap'; + +const mocks = vi.hoisted(() => ({ + jmapRequest: vi.fn<(calls: JmapMethodCall[]) => Promise>(), + listener: null as RegistryWriteListener | null, + toast: vi.fn(), +})); + +vi.mock('@/services/jmap/client', () => ({ + getAccountId: () => 'admin', + jmapRequest: mocks.jmapRequest, + setRegistryWriteListener: (l: RegistryWriteListener | null) => { + mocks.listener = l; + }, +})); + +vi.mock('@/hooks/use-toast', () => ({ toast: mocks.toast })); + +import { APPLY_DELAY_MS, resetSettingsApplyForTests, useSettingsApplyStore } from './settingsApplyStore'; + +function save(...types: string[]) { + mocks.listener!.started(); + mocks.listener!.finished(types); +} + +function reloadCreates(call: JmapMethodCall): unknown[] { + expect(call[0]).toBe('x:Action/set'); + return Object.values((call[1] as { create: Record }).create); +} + +function answer(created: string[], notCreated: Record = {}): JmapMethodResponse[] { + return [['x:Action/set', { created: Object.fromEntries(created.map((k) => [k, { id: k }])), notCreated }, 'reload']]; +} + +describe('settingsApplyStore', () => { + beforeEach(() => { + vi.useFakeTimers(); + mocks.jmapRequest.mockReset(); + mocks.toast.mockReset(); + resetSettingsApplyForTests(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('registers with the JMAP client', () => { + expect(mocks.listener).not.toBeNull(); + }); + + it('applies a settings save once writes settle, and says so', async () => { + mocks.jmapRequest.mockResolvedValue(answer(['reload-0'])); + save('x:MtaDeliverySchedule'); + expect(mocks.jmapRequest).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS); + + expect(mocks.jmapRequest).toHaveBeenCalledTimes(1); + expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([{ '@type': 'ReloadSettings' }]); + expect(mocks.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Saved and applied', variant: 'success' }), + ); + expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], applying: false, failure: null }); + }); + + it('sends one reload for a burst of saves', async () => { + mocks.jmapRequest.mockResolvedValue(answer(['reload-0', 'reload-1'])); + save('x:MtaRoute'); + await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS / 2); + save('x:MtaRoute'); + // A save still in flight holds the reload back however long it takes. + mocks.listener!.started(); + await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 5); + expect(mocks.jmapRequest).not.toHaveBeenCalled(); + mocks.listener!.finished(['x:Certificate']); + await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS); + + expect(mocks.jmapRequest).toHaveBeenCalledTimes(1); + expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([ + { '@type': 'ReloadTlsCertificates' }, + { '@type': 'ReloadSettings' }, + ]); + }); + + it('does nothing for writes the server applies itself', async () => { + save('x:Directory', 'x:Account', 'x:Domain'); + await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2); + expect(mocks.jmapRequest).not.toHaveBeenCalled(); + }); + + it('keeps a failed reload with the object and message, and retries on Apply now', async () => { + mocks.jmapRequest.mockResolvedValueOnce( + answer([], { + 'reload-0': { + type: 'validationFailed', + description: 'Failed to resolve Pyzor host', + objectId: { object: 'SpamPyzor', id: 'singleton' }, + }, + }), + ); + save('x:MtaDeliverySchedule'); + await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS); + + expect(mocks.toast).not.toHaveBeenCalled(); + expect(useSettingsApplyStore.getState()).toMatchObject({ + pending: ['ReloadSettings'], + failure: { message: 'Failed to resolve Pyzor host', object: { object: 'SpamPyzor', id: 'singleton' } }, + }); + + // An unrelated save doesn't rerun a reload that is known to fail. + save('x:Account'); + await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2); + expect(mocks.jmapRequest).toHaveBeenCalledTimes(1); + + mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0'])); + await useSettingsApplyStore.getState().applyNow(); + expect(mocks.jmapRequest).toHaveBeenCalledTimes(2); + expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null }); + expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Saved and applied' })); + }); + + it('tries again by itself when a later save needs a reload', async () => { + mocks.jmapRequest.mockResolvedValueOnce(answer([], { 'reload-0': { type: 'validationFailed', description: 'x' } })); + save('x:SpamPyzor'); + await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS); + expect(useSettingsApplyStore.getState().failure).not.toBeNull(); + + mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0'])); + save('x:SpamPyzor'); + await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS); + expect(mocks.jmapRequest).toHaveBeenCalledTimes(2); + expect(useSettingsApplyStore.getState().failure).toBeNull(); + }); + + it('reports a request that fails outright', async () => { + mocks.jmapRequest.mockRejectedValueOnce(new Error('Network down')); + save('x:MtaRoute'); + await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS); + expect(useSettingsApplyStore.getState()).toMatchObject({ + pending: ['ReloadSettings'], + failure: { message: 'Network down' }, + }); + }); + + it('reports a method error', async () => { + mocks.jmapRequest.mockResolvedValueOnce([['error', { type: 'forbidden', description: 'Not allowed' }, 'reload']]); + save('x:MtaRoute'); + await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS); + expect(useSettingsApplyStore.getState().failure).toEqual({ message: 'Not allowed' }); + }); + + it('dismissing hides the failure but keeps the reload queued', async () => { + mocks.jmapRequest.mockResolvedValueOnce(answer([], { 'reload-0': { type: 'validationFailed', description: 'x' } })); + save('x:MtaRoute'); + await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS); + useSettingsApplyStore.getState().dismiss(); + expect(useSettingsApplyStore.getState()).toMatchObject({ failure: null, pending: ['ReloadSettings'] }); + }); +}); diff --git a/src/stores/settingsApplyStore.ts b/src/stores/settingsApplyStore.ts new file mode 100644 index 0000000..7694d00 --- /dev/null +++ b/src/stores/settingsApplyStore.ts @@ -0,0 +1,168 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: applies saved settings on the running server. + * + * Every registry write that needs it queues a reload action (see + * lib/settingsApply). Once no write has been in flight for APPLY_DELAY_MS the + * queued actions go out together in one x:Action/set, so a bulk edit, or a + * page that saves several objects in a row, costs one reload rather than one + * per object. A reload that fails stays queued and shows in + * SettingsApplyBanner until it is applied, by "Apply now" or by the next save. + * + * If the server one day applies these writes by itself, the reload sent here + * is a second, harmless one: one per burst of saves, never one per object. + */ + +import { create } from 'zustand'; +import i18n from '@/i18n'; +import { toast } from '@/hooks/use-toast'; +import { getAccountId, jmapRequest, setRegistryWriteListener } from '@/services/jmap/client'; +import { + RELOAD_ORDER, + describeApplyFailure, + describeRequestFailure, + reloadActionsFor, + type ApplyFailure, + type ReloadAction, +} from '@/lib/settingsApply'; +import type { JmapSetError } from '@/types/jmap'; + +export const APPLY_DELAY_MS = 600; + +interface SettingsApplyState { + /** Actions waiting to be sent: queued by saves, or left over from a failed attempt. */ + pending: ReloadAction[]; + applying: boolean; + /** Why the last attempt didn't apply, until one does. */ + failure: ApplyFailure | null; + /** Queues the actions the written types need, and applies them once writes settle. */ + noteWrites: (objectNames: string[]) => void; + /** Sends whatever is queued now, without waiting. */ + applyNow: () => Promise; + /** Hides the failure. What failed stays queued for the next save. */ + dismiss: () => void; +} + +let timer: ReturnType | null = null; +let writesInFlight = 0; +let applyAgain = false; +// Whether a save has queued something since the last attempt. After a failed +// reload, only a save that needs one tries again by itself; editing an +// account, say, doesn't rerun a reload that is known to fail. +let freshlyQueued = false; + +function merge(a: ReloadAction[], b: ReloadAction[]): ReloadAction[] { + const all = new Set([...a, ...b]); + return RELOAD_ORDER.filter((x) => all.has(x)); +} + +function schedule() { + if (timer) clearTimeout(timer); + timer = null; + const { pending, failure } = useSettingsApplyStore.getState(); + if (pending.length === 0 || writesInFlight > 0) return; + if (failure && !freshlyQueued) return; + timer = setTimeout(() => { + timer = null; + void useSettingsApplyStore.getState().applyNow(); + }, APPLY_DELAY_MS); +} + +export const useSettingsApplyStore = create()((set, get) => ({ + pending: [], + applying: false, + failure: null, + + noteWrites: (objectNames) => { + const due = reloadActionsFor(objectNames); + if (due.length > 0) { + freshlyQueued = true; + set({ pending: merge(get().pending, due) }); + } + schedule(); + }, + + applyNow: async () => { + if (timer) clearTimeout(timer); + timer = null; + if (get().applying) { + // A save landed while the last reload was out: go again once it's back. + applyAgain = true; + return; + } + const actions = get().pending; + if (actions.length === 0) return; + + freshlyQueued = false; + set({ applying: true, pending: [] }); + let notApplied: ReloadAction[] = []; + let failure: ApplyFailure | null = null; + try { + const create: Record> = {}; + actions.forEach((action, i) => { + create[`reload-${i}`] = { '@type': action }; + }); + const responses = await jmapRequest([ + ['x:Action/set', { accountId: getAccountId('x:Action'), create }, 'reload'], + ]); + const [name, result] = responses[responses.length - 1] ?? []; + if (name !== 'x:Action/set' || !result) { + notApplied = actions; + failure = describeRequestFailure(result); + } else { + const created = (result.created ?? {}) as Record; + const notCreated = (result.notCreated ?? {}) as Record; + for (const [i, action] of actions.entries()) { + const key = `reload-${i}`; + if (key in created) continue; + notApplied.push(action); + failure ??= notCreated[key] + ? describeApplyFailure(notCreated[key]) + : { message: i18n.t('settingsApply.notConfirmed', 'The server did not confirm the reload.') }; + } + } + } catch (err) { + notApplied = actions; + failure = describeRequestFailure(err); + } + + set({ applying: false, pending: merge(get().pending, notApplied), failure }); + if (!failure) { + toast({ title: i18n.t('settingsApply.applied', 'Saved and applied'), variant: 'success' }); + } + + if (applyAgain) { + applyAgain = false; + schedule(); + } + }, + + dismiss: () => set({ failure: null }), +})); + +setRegistryWriteListener({ + started() { + writesInFlight++; + if (timer) clearTimeout(timer); + timer = null; + }, + finished(objectNames) { + writesInFlight = Math.max(0, writesInFlight - 1); + useSettingsApplyStore.getState().noteWrites(objectNames); + }, +}); + +/** Test hook: forget timers and counters between cases. */ +export function resetSettingsApplyForTests() { + if (timer) clearTimeout(timer); + timer = null; + writesInFlight = 0; + applyAgain = false; + freshlyQueued = false; + useSettingsApplyStore.setState({ pending: [], applying: false, failure: null }); +} diff --git a/src/types/jmap.ts b/src/types/jmap.ts index c16eabb..318f9d2 100644 --- a/src/types/jmap.ts +++ b/src/types/jmap.ts @@ -1,7 +1,10 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ export interface JmapRequest { @@ -52,11 +55,17 @@ export interface JmapSetError { description?: string; properties?: string[]; existingId?: string; - objectId?: string; + objectId?: string | JmapObjectRef; linkedObjects?: string[]; validationErrors?: ValidationError[]; } +/** A registry object as the server names it in an error: its type without `x:`, and its id. */ +export interface JmapObjectRef { + object: string; + id: string; +} + export interface ValidationError { type: 'Invalid' | 'Required' | 'MaxLength' | 'MinLength' | 'MaxValue' | 'MinValue'; property: string;