Apply saved settings on the server after a save
ci / build (pull_request) Successful in 1m13s
ci / publish (pull_request) Skipped

A registry write is stored at once, but the running server only picks up
most settings when it rebuilds its configuration, which it does on a write
for directories and the default authentication alone. Everything else, a
delivery schedule for one, sat unapplied until someone ran Management >
Actions > Reload > Server settings by hand.

Every x:<Type>/set now goes past a listener in the JMAP client. A write that
changed a settings object queues the reload action it needs, and once no
write has been in flight for 600 ms the queued actions go out together in
one x:Action/set. A bulk edit or a page that saves several objects in a row
costs one reload, not one per object; a save still in flight holds the
reload back however long it takes.

Which action a type needs lives in lib/settingsApply.ts. Certificates, lookup
stores and blocked IPs have their own reload actions, and the full settings
reload doesn't rebuild them. Directories and authentication, data read live
or kept current by cache invalidation (accounts, domains, DKIM keys, tenants,
roles, lists and the like), operations and records, and stores, which a
reload never reopens, get none. A type the list doesn't know is reloaded: an
unneeded reload costs a second, a missing one leaves a setting unapplied. If
the server later applies these writes by itself, this becomes one extra,
harmless reload per burst of saves.

Applying straight after a save is safe because a reload is all or nothing:
the new configuration replaces the running one only when every settings
object builds. When one doesn't, the server keeps what it had and names the
object and the problem. That now shows as a banner above the page, "Saved,
but the server couldn't apply the settings: <reason>", naming the object with
a link to it, and an Apply now button. It stays until a reload succeeds or it
is dismissed. A failure that is already known is not retried on unrelated
saves, only on Apply now or a save that needs a reload.

On success a "Saved and applied" toast replaces the form's own "Saved
successfully" and "Created successfully" for settings objects, so a save
shows one message, not two.

New strings, in src/i18n/en.json (the only catalogue) under settingsApply:
applied, applyNow, dismiss, failed, failedObject, noAnswer, notConfirmed,
openObject, stillRunning.
This commit is contained in:
2026-09-24 11:16:09 -07:00
parent dbf911e0a6
commit 28c49b4056
10 changed files with 796 additions and 9 deletions
@@ -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>
);
}