Merge pull request 'Apply saved settings on the server after a save' (#15) from feature/apply-saved-settings into main
ci / build (push) Successful in 1m48s
ci / publish (push) Skipped

This commit was merged in pull request #15.
This commit is contained in:
2026-09-24 18:18:25 +00:00
10 changed files with 796 additions and 9 deletions
+15 -8
View File
@@ -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);
@@ -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 (
<div
role="alert"
className="mb-4 flex flex-wrap items-start gap-x-3 gap-y-2 rounded-xl border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium">
{t('settingsApply.failed', "Saved, but the server couldn't apply the settings: {{reason}}", {
reason: failure.message,
})}
</p>
{objectLabel && (
<p className="text-muted-foreground">
{t('settingsApply.failedObject', 'The problem is in {{object}}.', { object: objectLabel })}{' '}
{objectLink && (
<Link to={objectLink} className="font-medium text-primary hover:underline">
{t('settingsApply.openObject', 'Open it')}
</Link>
)}
</p>
)}
<p className="text-muted-foreground">
{t(
'settingsApply.stillRunning',
'The server keeps running on the settings it had. Your changes are saved and apply once this is fixed.',
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => void applyNow()} disabled={applying}>
{applying && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{t('settingsApply.applyNow', 'Apply now')}
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={dismiss}
aria-label={t('settingsApply.dismiss', 'Dismiss')}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
);
}
+11
View File
@@ -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",
+119
View File
@@ -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.');
});
});
+174
View File
@@ -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<string, ReloadAction> = {
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<string>([
// 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:<Type>/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<string>();
for (const [name, result] of methodResponses) {
const match = REGISTRY_SET.exec(name);
if (!match || !result) continue;
const created = result.created as Record<string, unknown> | null | undefined;
const updated = result.updated as Record<string, unknown> | 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<string>): ReloadAction[] {
const due = new Set<ReloadAction>();
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.') };
}
+2
View File
@@ -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'),
)}
>
<SettingsApplyBanner />
<ErrorBoundary key={activeAccountId ?? 'none'}>
<MainContent viewName={viewName} id={id} section={section} />
</ErrorBoundary>
+35
View File
@@ -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<JmapMethodResponse[]> {
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<JmapMethodResponse[]> {
const { apiUrl } = useAuthStore.getState();
let path = apiUrl || '/jmap';
+167
View File
@@ -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<JmapMethodResponse[]>>(),
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<string, unknown> }).create);
}
function answer(created: string[], notCreated: Record<string, unknown> = {}): 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'] });
});
});
+168
View File
@@ -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<void>;
/** Hides the failure. What failed stays queued for the next save. */
dismiss: () => void;
}
let timer: ReturnType<typeof setTimeout> | 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<SettingsApplyState>()((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<string, Record<string, unknown>> = {};
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<string, unknown>;
const notCreated = (result.notCreated ?? {}) as Record<string, JmapSetError>;
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 });
}
+10 -1
View File
@@ -1,7 +1,10 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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;