Apply saved settings on the server after a save #15
@@ -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 {
|
||||
// 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) {
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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.') };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'] });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user