Settings in nine task-based groups on the top bar, with an Overview page #34

Merged
jcoffey-dev merged 2 commits from feature/settings-menu into main 2026-09-28 21:52:27 +00:00
9 changed files with 941 additions and 6 deletions

No files matched your search

+10 -1
View File
@@ -61,7 +61,12 @@ const DataInventoryPage = lazyFeature(
() => import('@/features/compliance/DataInventoryPage'),
(m) => m.DataInventoryPage,
);
// inbuxa: Settings › Spam Filter › Local AI (ai-spam-classification spec).
// inbuxa: Settings › Overview (settings-reorg spec).
const SettingsOverviewPage = lazyFeature(
() => import('@/features/settings/SettingsOverviewPage'),
(m) => m.SettingsOverviewPage,
);
// inbuxa: Settings › Spam filter › Local AI (ai-spam-classification spec).
const LocalAiPage = lazyFeature(
() => import('@/features/ai/LocalAiPage'),
(m) => m.LocalAiPage,
@@ -149,6 +154,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (componentName === 'LegacyProtocols') {
return <LegacyProtocolsPage />;
}
// inbuxa: Settings › Overview (settings-reorg).
if (componentName === 'SettingsOverview') {
return <SettingsOverviewPage />;
}
if (componentName === 'LocalAi') {
return <LocalAiPage />;
}
+33 -4
View File
@@ -47,6 +47,9 @@ import {
} from '@/lib/navTree';
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
/** Past this many links a menu is drawn in two columns. */
const WIDE_MENU_LINKS = 12;
/** Room kept for the "More" trigger when not everything fits. */
const MORE_WIDTH = 92;
@@ -110,14 +113,20 @@ function MenuBody({ items, sectionName, currentPath, edition, onPick }: MenuBody
if (!subtreeHasVisibleLink(sub.items, edition)) return null;
const links = visibleLinks(sub.items, edition);
return (
<DropdownMenuGroup key={`${sub.name}-${i}`}>
<DropdownMenuLabel className="pt-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<DropdownMenuGroup key={`${sub.name}-${i}`} className="break-inside-avoid">
<DropdownMenuLabel
className={cn(
'pt-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground',
sub.advanced && 'opacity-70',
)}
>
{sub.name}
</DropdownMenuLabel>
{links.map((l) => (
<DropdownMenuItem
key={l.viewName}
className={cn(
sub.advanced && 'text-muted-foreground',
pathMatchesView(currentPath, sectionName, l.viewName) && 'bg-accent text-accent-foreground',
)}
onClick={() => onPick(l.viewName, checkIsEnterprise(l.viewName) && edition === 'community')}
@@ -163,6 +172,8 @@ function SectionNavItem({ item, sectionName, currentPath, edition, onPick, measu
const { name, items } = item.container;
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
// inbuxa: a long menu (Settings › Mail flow) splits into two columns rather than running off the screen.
const wide = visibleLinks(items, edition).length > WIDE_MENU_LINKS;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -175,8 +186,26 @@ function SectionNavItem({ item, sectionName, currentPath, edition, onPick, measu
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-70 transition-transform duration-200 data-[state=open]:rotate-180" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={0} className="w-60">
<MenuBody items={items} sectionName={sectionName} currentPath={currentPath} edition={edition} onPick={onPick} />
<DropdownMenuContent align="start" sideOffset={0} className={wide ? 'w-[34rem]' : 'w-60'}>
{wide ? (
<div className="columns-2 gap-2">
<MenuBody
items={items}
sectionName={sectionName}
currentPath={currentPath}
edition={edition}
onPick={onPick}
/>
</div>
) : (
<MenuBody
items={items}
sectionName={sectionName}
currentPath={currentPath}
edition={edition}
onPick={onPick}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -0,0 +1,230 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: Settings › Overview, the landing page for Settings (settings-reorg).
* A card per category explaining what that menu item covers, the guided setups, and, only
* when there are any, the pages whose values differ from the defaults.
*/
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Check, ListChecks } from 'lucide-react';
import { IconTile } from '@/components/common/IconTile';
import { useSchemaStore } from '@/stores/schemaStore';
import { useAccountStore } from '@/stores/accountStore';
import { getAccountId, jmapRequest } from '@/services/jmap/client';
import { checkLinkVisible, resolveViewPath, topItemVisible } from '@/lib/navTree';
import { SETTINGS_CATEGORIES, SETTINGS_LAYOUT_NAME } from '@/lib/settingsLayout';
import { resolveObject } from '@/lib/schemaResolver';
import { countChanged, singletonPages, type SettingsPageRef } from './changedFromDefault';
/** Calls per JMAP request; the protocol's own floor, so any server takes it. */
const CALLS_PER_REQUEST = 16;
interface GuidedSetup {
id: string;
title: string;
blurb: string;
/** Where the job starts. */
to: string;
/** The view whose visibility decides whether this reader sees the card. */
gate: string;
/** Whether it is already set up; left out when there's no cheap way to tell. */
isDone?: () => Promise<boolean>;
}
async function anyExist(objectName: string): Promise<boolean> {
const [res] = await jmapRequest([
[`${objectName}/get`, { accountId: getAccountId(objectName), ids: null, properties: ['id'] }, '0'],
]);
const list = res?.[1]?.list as unknown[] | undefined;
return (list?.length ?? 0) > 0;
}
/** Each guided setup registers here as it lands. */
const GUIDED_SETUPS: GuidedSetup[] = [
{
id: 'dns',
title: 'Publish DNS records automatically',
blurb: 'Connect your DNS provider once, and each domain’s mail records are written and kept up to date for you.',
to: '/Management/x:Domain',
gate: 'x:DnsServer',
isDone: () => anyExist('x:DnsServer'),
},
{
id: 'local-ai',
title: 'Local AI spam filtering',
blurb: 'Run a small model on this server to help sort spam, with nothing sent anywhere else.',
to: resolveViewPath(SETTINGS_LAYOUT_NAME, 'CustomComponent/LocalAi'),
gate: 'CustomComponent/LocalAi',
},
];
function useChangedPages(pages: SettingsPageRef[]) {
const schema = useSchemaStore((s) => s.schema);
const [changed, setChanged] = useState<(SettingsPageRef & { count: number })[]>([]);
useEffect(() => {
if (!schema || pages.length === 0) return;
const ctrl = new AbortController();
const objects = [...new Set(pages.map((pg) => resolveObject(schema, pg.viewName)!.objectName))];
(async () => {
const values = new Map<string, Record<string, unknown>>();
const batches: string[][] = [];
for (let i = 0; i < objects.length; i += CALLS_PER_REQUEST) batches.push(objects.slice(i, i + CALLS_PER_REQUEST));
await Promise.all(
batches.map(async (batch) => {
const responses = await jmapRequest(
batch.map((obj, i) => [`${obj}/get`, { accountId: getAccountId(obj), ids: ['singleton'] }, String(i)]),
ctrl.signal,
);
for (const [method, result, callId] of responses) {
if (method === 'error') continue;
const item = (result.list as Record<string, unknown>[] | undefined)?.[0];
if (item) values.set(batch[Number(callId)], item);
}
}),
);
if (ctrl.signal.aborted) return;
const found = [];
for (const pg of pages) {
const data = values.get(resolveObject(schema, pg.viewName)!.objectName);
const count = data ? countChanged(schema, pg.viewName, data) : 0;
if (count > 0) found.push({ ...pg, count });
}
setChanged(found);
})().catch(() => {
// The list is a convenience; a failed read just leaves it out.
});
return () => ctrl.abort();
}, [schema, pages]);
return changed;
}
function SetupCard({ setup }: { setup: GuidedSetup }) {
const { t } = useTranslation();
const [done, setDone] = useState(false);
useEffect(() => {
let live = true;
setup
.isDone?.()
.then((d) => live && setDone(d))
.catch(() => undefined);
return () => {
live = false;
};
}, [setup]);
return (
<Link
to={setup.to}
className="group flex flex-col gap-2 rounded-xl border bg-card p-4 transition-all hover:-translate-y-0.5 hover:border-primary/60 hover:shadow-soft"
>
<div className="flex items-center gap-2">
<span className="font-medium">{setup.title}</span>
{done && (
<span className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground">
<Check className="h-3.5 w-3.5" />
{t('settingsOverview.setUp', 'Set up')}
</span>
)}
</div>
<p className="text-sm text-muted-foreground">{setup.blurb}</p>
</Link>
);
}
export function SettingsOverviewPage() {
const { t } = useTranslation();
const schema = useSchemaStore((s) => s.schema);
const edition = useAccountStore((s) => s.edition);
const layout = useMemo(() => schema?.layouts.find((l) => l.name === SETTINGS_LAYOUT_NAME), [schema]);
// What each item in the menu bar covers: explainers only, the bar does the navigating.
const cards = useMemo(() => {
if (!layout) return [];
return SETTINGS_CATEGORIES.filter((cat) => {
const item = layout.items.find((it) => 'container' in it && it.container.name === cat.name);
return item !== undefined && topItemVisible(item, edition);
});
}, [layout, edition]);
const pages = useMemo(
() => (schema && layout ? singletonPages(schema, layout.items).filter((pg) => checkLinkVisible(pg.viewName)) : []),
[schema, layout],
);
const changed = useChangedPages(pages);
const setups = GUIDED_SETUPS.filter((s) => checkLinkVisible(s.gate));
return (
<div className="mx-auto max-w-6xl space-y-8">
<header className="space-y-1">
<h1 className="text-2xl font-semibold">{t('settingsOverview.title', 'Settings')}</h1>
<p className="text-muted-foreground">
{t(
'settingsOverview.subtitle',
'The menu above groups everything the server does by job. Here is what each part covers. Anything you change is marked against its default.',
)}
</p>
</header>
{setups.length > 0 && (
<section className="space-y-3">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
<ListChecks className="h-4 w-4" />
{t('settingsOverview.guided', 'Guided setup')}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{setups.map((s) => (
<SetupCard key={s.id} setup={s} />
))}
</div>
</section>
)}
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{cards.map((cat) => (
<div key={cat.name} className="flex flex-col gap-3 rounded-xl border bg-card p-5">
<div className="flex items-center gap-3">
<IconTile name={cat.icon} size="lg" />
<h2 className="text-lg font-semibold">{cat.name}</h2>
</div>
<p className="text-sm text-muted-foreground">{cat.blurb}</p>
</div>
))}
</section>
{changed.length > 0 && (
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t('settingsOverview.changed', 'Changed from default')}
</h2>
<ul className="divide-y rounded-xl border bg-card">
{changed.map((pg) => (
<li key={pg.viewName}>
<Link
to={resolveViewPath(SETTINGS_LAYOUT_NAME, pg.viewName)}
className="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-muted/50"
>
<span className="font-medium">{pg.label}</span>
<span className="text-muted-foreground">{pg.where}</span>
<span className="ml-auto text-muted-foreground">
{t('settingsOverview.changedCount', '{{count}} changed', { count: pg.count })}
</span>
</Link>
</li>
))}
</ul>
</section>
)}
</div>
);
}
@@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect } from 'vitest';
import { countChanged, singletonPages } from './changedFromDefault';
import type { Schema } from '@/types/schema';
const field = (type: Record<string, unknown>) => ({ description: '', type, update: 'mutable' });
const schema = {
objects: {
'x:Imap': { type: 'singleton', permissionPrefix: 'sysImap' },
'x:Email': { type: 'singleton', permissionPrefix: 'sysEmail' },
'x:Email/EmailLimits': { type: 'view', objectName: 'x:Email' },
'x:Tracer': { type: 'object', permissionPrefix: 'sysTracer' },
},
schemas: {
'x:Imap': { type: 'single', schemaName: 'x:Imap' },
'x:Email': { type: 'single', schemaName: 'x:Email' },
},
fields: {
'x:Imap': {
properties: {
allowPlainText: field({ type: 'boolean' }),
timeout: field({ type: 'number', format: 'duration' }),
banner: field({ type: 'string' }),
},
defaults: { allowPlainText: false, timeout: 1800000 },
},
'x:Email': {
properties: { maxSize: field({ type: 'number', format: 'size' }), defaultFolder: field({ type: 'string' }) },
defaults: { maxSize: 1000, defaultFolder: 'Inbox' },
},
},
forms: {
'x:Imap': { sections: [{ fields: [{ name: 'allowPlainText' }, { name: 'timeout' }, { name: 'banner' }] }] },
'x:Email/EmailLimits': { sections: [{ fields: [{ name: 'maxSize' }] }] },
},
enums: {},
lists: {},
layouts: [],
} as unknown as Schema;
describe('countChanged', () => {
it('counts fields that differ from a default it can describe', () => {
expect(countChanged(schema, 'x:Imap', { allowPlainText: true, timeout: 1800000, banner: 'hi' })).toBe(1);
});
it('treats an unset value as the default', () => {
expect(countChanged(schema, 'x:Imap', {})).toBe(0);
});
it('only counts the fields on the view it was asked about', () => {
// defaultFolder differs too, but it is not on the Limits page.
expect(countChanged(schema, 'x:Email/EmailLimits', { maxSize: 5, defaultFolder: 'Other' })).toBe(1);
});
});
describe('singletonPages', () => {
it('lists singleton pages with where they sit, skipping lists', () => {
const pages = singletonPages(schema, [
{
container: {
name: 'Mail & apps',
icon: 'mail',
items: [
{
type: 'container',
name: 'Protocols',
items: [
{ type: 'link', name: 'IMAP', viewName: 'x:Imap' },
{ type: 'link', name: 'Tracers', viewName: 'x:Tracer' },
],
},
{ type: 'link', name: 'Limits', viewName: 'x:Email/EmailLimits' },
],
},
},
]);
expect(pages).toEqual([
{ viewName: 'x:Imap', label: 'IMAP', where: 'Mail & apps › Protocols' },
{ viewName: 'x:Email/EmailLimits', label: 'Limits', where: 'Mail & apps' },
]);
});
});
@@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: which Settings pages hold values someone has changed from the
* default, for the Overview (settings-reorg). It counts exactly what a form
* marks as changed (FieldWidget): a field whose default can be put into words
* and whose value differs from it.
*/
import { describeDefault, differsFromDefault } from '@/help/defaults';
import { resolveForm, resolveObject, resolveSchema } from '@/lib/schemaResolver';
import type { LayoutItem, LayoutSubItem, Schema } from '@/types/schema';
const WORDS = { on: 'on', off: 'off', none: 'none' };
export interface SettingsPageRef {
viewName: string;
label: string;
/** Category › group, for the list. */
where: string;
}
/** The singleton pages in a layout: the only ones with one value set to compare. */
export function singletonPages(schema: Schema, items: LayoutItem[]): SettingsPageRef[] {
const out: SettingsPageRef[] = [];
const visit = (subs: LayoutSubItem[], where: string) => {
for (const sub of subs) {
if (sub.type === 'container') {
visit(sub.items, `${where} › ${sub.name}`);
} else if (resolveObject(schema, sub.viewName)?.objectType.type === 'singleton') {
out.push({ viewName: sub.viewName, label: sub.name, where });
}
}
};
for (const item of items) {
if ('container' in item) visit(item.container.items, item.container.name);
}
return out;
}
/** How many of a page's fields differ from their defaults, given the object's current value. */
export function countChanged(schema: Schema, viewName: string, data: Record<string, unknown>): number {
const resolved = resolveObject(schema, viewName);
if (!resolved) return 0;
const sch = resolveSchema(schema, resolved.objectName);
if (!sch) return 0;
let scope: string;
let schemaName: string;
if (sch.type === 'single') {
scope = resolved.objectName;
schemaName = sch.schemaName;
} else {
const variant = sch.variants.find((v) => v.name === data['@type']);
if (!variant?.schemaName) return 0;
scope = variant.schemaName;
schemaName = variant.schemaName;
}
const fields = schema.fields[scope] ?? schema.fields[schemaName];
const form = resolveForm(schema, viewName, resolved.objectName, schemaName);
if (!fields || !form) return 0;
let changed = 0;
for (const section of form.sections) {
for (const { name } of section.fields) {
const field = fields.properties[name];
if (!field) continue;
const def = fields.defaults?.[name];
if (describeDefault(field, def, schema, WORDS) !== null && differsFromDefault(data[name], def)) changed++;
}
}
return changed;
}
+102
View File
@@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect } from 'vitest';
import { regroupSettings, SETTINGS_CATEGORIES, SETTINGS_OVERVIEW_VIEW, withRegroupedSettings } from './settingsLayout';
import type { Layout, LayoutItem, LayoutSubItem, Schema } from '@/types/schema';
const served: Layout = {
name: 'Settings',
icon: 'sliders-horizontal',
items: [
{
container: {
name: 'MTA',
icon: 'route',
items: [
{
type: 'container',
name: 'Session',
items: [
{ type: 'link', name: 'EHLO Stage', viewName: 'x:MtaStageEhlo' },
{ type: 'link', name: 'Extensions', viewName: 'x:MtaExtensions' },
],
},
{ type: 'link', name: 'Sender Authentication', viewName: 'x:SenderAuth' },
],
},
},
{ link: { name: 'Branding', icon: 'palette', viewName: 'x:Enterprise' } },
{ link: { name: 'Something New', icon: 'sparkles', viewName: 'x:SomethingNew' } },
],
};
function tree(items: (LayoutItem | LayoutSubItem)[]): unknown[] {
return items.map((it) => {
if ('link' in it) return it.link.name;
if ('container' in it) return { [it.container.name]: tree(it.container.items) };
if (it.type === 'link') return it.name;
return { [`${it.name}${it.advanced ? ' (advanced)' : ''}`]: tree(it.items) };
});
}
describe('regroupSettings', () => {
it('puts every page under its category, with plain labels, Overview first', () => {
expect(tree(regroupSettings(served).items)).toEqual([
'Overview',
{
'Mail flow': [
{ Receiving: ['Sender checks (SPF, DKIM, DMARC)'] },
{ 'Advanced (advanced)': ['Greeting (EHLO)', 'SMTP extensions'] },
],
},
{ System: [{ Server: ['Branding'] }, { Other: ['Something New'] }] },
]);
});
it('keeps view names, so URLs do not move', () => {
const out = regroupSettings(served);
expect(out.items[0]).toEqual({
link: { name: 'Overview', icon: 'layout-grid', viewName: SETTINGS_OVERVIEW_VIEW },
});
const views: string[] = [];
const walk = (items: (LayoutItem | LayoutSubItem)[]) => {
for (const it of items) {
if ('link' in it) views.push(it.link.viewName);
else if ('container' in it) walk(it.container.items);
else if (it.type === 'link') views.push(it.viewName);
else walk(it.items);
}
};
walk(out.items);
expect(views.sort()).toEqual(
[
SETTINGS_OVERVIEW_VIEW,
'x:Enterprise',
'x:MtaExtensions',
'x:MtaStageEhlo',
'x:SenderAuth',
'x:SomethingNew',
].sort(),
);
});
it('leaves the other layouts alone', () => {
const management: Layout = { ...served, name: 'Management' };
expect(regroupSettings(management)).toBe(management);
const schema = { layouts: [management, served] } as unknown as Schema;
expect(withRegroupedSettings(schema).layouts[0]).toBe(management);
});
it('fits the section bar', () => {
expect(SETTINGS_CATEGORIES.length + 1).toBeLessThanOrEqual(12);
});
it('places each page once', () => {
const all = SETTINGS_CATEGORIES.flatMap((c) => c.groups.flatMap((g) => g.pages.map((pg) => pg.viewName)));
expect(new Set(all).size).toBe(all.length);
});
});
+388
View File
@@ -0,0 +1,388 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: Settings, grouped by what someone comes to do rather than by the
* configuration file (spec: settings-reorg). The server's schema ships
* Settings as nineteen groups; this regroups the same pages into a handful of
* categories, short enough for the section bar.
*
* Only the menu changes. Every page keeps its view name, so its URL
* (/Settings/<viewName>), bookmarks, last-visited and help links all stay.
* The server still decides which pages exist: a page listed here that the
* schema doesn't carry is left out, and a page the schema carries that isn't
* listed here lands in System › Other, so an upstream addition never vanishes.
*/
import type { Layout, LayoutItem, LayoutSubItem, Schema } from '@/types/schema';
export const SETTINGS_LAYOUT_NAME = 'Settings';
export const SETTINGS_OVERVIEW_VIEW = 'CustomComponent/SettingsOverview';
/** A page: its view, and a plainer label than the schema's where one helps. */
export interface SettingsPage {
viewName: string;
label?: string;
}
export interface SettingsGroup {
name: string;
/** Protocol-level detail most servers never touch: drawn last and dimmed. */
advanced?: boolean;
pages: SettingsPage[];
}
export interface SettingsCategory {
name: string;
icon: string;
/** One sentence for the Overview card. */
blurb: string;
groups: SettingsGroup[];
}
const p = (viewName: string, label?: string): SettingsPage => (label ? { viewName, label } : { viewName });
export const SETTINGS_CATEGORIES: SettingsCategory[] = [
{
name: 'Mail flow',
icon: 'route',
blurb: 'How mail comes in and goes out: checks on senders, relays, limits and the reports sent to other servers.',
groups: [
{
name: 'Receiving',
pages: [
p('x:MtaInboundSession', 'Incoming connections'),
p('x:SenderAuth', 'Sender checks (SPF, DKIM, DMARC)'),
p('x:MtaSts', 'MTA-STS'),
],
},
{
name: 'Sending',
pages: [
p('x:MtaOutboundStrategy', 'Delivery strategy'),
p('x:MtaRoute', 'Routes and relays'),
p('x:MtaDeliverySchedule', 'Retry schedules'),
p('x:MtaVirtualQueue', 'Queues'),
],
},
{
name: 'Limits',
pages: [
p('x:MtaInboundThrottle', 'Incoming rate limits'),
p('x:MtaOutboundThrottle', 'Outgoing rate limits'),
p('x:MtaQueueQuota', 'Queue quotas'),
],
},
{
name: 'Reports',
pages: [
p('x:ReportSettings', 'General'),
p('x:DmarcReportSettings', 'DMARC'),
p('x:TlsReportSettings', 'TLS'),
p('x:DkimReportSettings', 'DKIM'),
p('x:SpfReportSettings', 'SPF'),
p('x:DsnReportSettings', 'Bounce messages (DSN)'),
],
},
{
name: 'Filters & scripts',
pages: [
p('x:MtaMilter', 'Milters'),
p('x:MtaHook', 'Hooks'),
p('x:SieveSystemScript', 'Server Sieve scripts'),
p('x:SieveSystemInterpreter', 'Server Sieve interpreter'),
],
},
{
name: 'Advanced',
advanced: true,
pages: [
p('x:MtaStageConnect', 'Connection (Connect)'),
p('x:MtaStageEhlo', 'Greeting (EHLO)'),
p('x:MtaStageAuth', 'Sign-in (AUTH)'),
p('x:MtaStageMail', 'Sender (MAIL FROM)'),
p('x:MtaStageRcpt', 'Recipients (RCPT TO)'),
p('x:MtaStageData', 'Message (DATA)'),
p('x:MtaExtensions', 'SMTP extensions'),
p('x:MtaConnectionStrategy', 'Connection strategies'),
p('x:MtaTlsStrategy', 'TLS strategies'),
],
},
],
},
{
name: 'Spam filter',
icon: 'shield-alert',
blurb: 'How strict the filter is, what it learns from, and the lists of senders and domains it trusts or blocks.',
groups: [
{
name: 'Protection',
pages: [p('x:SpamSettings', 'Filter settings'), p('x:SpamTag', 'Scores'), p('x:SpamRule', 'Rules')],
},
{
name: 'Classifiers',
pages: [
p('x:SpamClassifier', 'Statistical classifier'),
p('CustomComponent/LocalAi', 'Local AI'),
p('x:SpamLlm', 'LLM classifier'),
p('x:SpamPyzor', 'Pyzor'),
],
},
{
name: 'Blocklists (DNSBL)',
pages: [p('x:SpamDnsblSettings', 'Settings'), p('x:SpamDnsblServer', 'Servers')],
},
{
name: 'Lists',
pages: [
p('x:MemoryLookupKey/SpamTrustedDomain', 'Trusted domains'),
p('x:MemoryLookupKey/SpamBlockedDomain', 'Blocked domains'),
p('x:MemoryLookupKey/SpamTrap', 'Spam traps'),
p('x:MemoryLookupKey/SpamUrlRedirector', 'URL redirectors'),
p('x:SpamFileExtension', 'File extensions'),
],
},
],
},
{
name: 'Security',
icon: 'lock',
blurb: 'Who can sign in and how, the certificates that encrypt connections, and which addresses are blocked.',
groups: [
{
name: 'Sign-in',
pages: [
p('x:Authentication', 'General'),
p('x:Directory', 'Directories'),
p('x:OidcProvider', 'OIDC provider'),
],
},
{
name: 'Certificates',
pages: [p('x:Certificate', 'Certificates'), p('x:AcmeProvider', 'Automatic certificates (ACME)')],
},
{
name: 'Protection',
pages: [
p('x:Security', 'Settings'),
p('CustomComponent/LegacyProtocols', 'Hardening'),
p('x:BlockedIp', 'Blocked IPs'),
p('x:AllowedIp', 'Allowed IPs'),
p('x:Asn', 'ASN & GeoIP'),
],
},
],
},
{
name: 'Network',
icon: 'cable',
blurb: 'The ports the server listens on, its web server, and how it looks up and publishes DNS.',
groups: [
{
name: 'Connections',
pages: [
p('x:SystemSettings/NetworkSettings', 'General'),
p('x:NetworkListener', 'Listeners (ports)'),
p('x:SystemSettings/NetworkServices', 'Services'),
],
},
{
name: 'Web',
pages: [p('x:Http', 'HTTP'), p('x:Http/HttpSecurity', 'HTTP security'), p('x:HttpForm', 'Contact form')],
},
{
name: 'DNS',
pages: [p('x:DnsServer', 'DNS providers'), p('x:DnsResolver', 'DNS resolver')],
},
],
},
{
name: 'Mail & apps',
icon: 'mail',
blurb: 'What people get: mailbox defaults and limits, IMAP and JMAP, calendars, contacts, files and web apps.',
groups: [
{
name: 'Email',
pages: [
p('x:Email/EmailStorage', 'Storage'),
p('x:Email/EmailDefaults', 'Defaults'),
p('x:Email/EmailLimits', 'Email limits'),
p('x:Email/EmailEncryption', 'Encryption'),
],
},
{
name: 'Protocols',
pages: [
p('x:Imap', 'IMAP'),
p('x:Jmap/JmapLimits', 'JMAP limits'),
p('x:Jmap/JmapPush', 'JMAP push'),
p('x:Jmap/JmapWebsocket', 'JMAP WebSocket'),
p('x:WebDav', 'WebDAV'),
],
},
{
name: 'Calendar & contacts',
pages: [
p('x:Calendar', 'Calendar'),
p('x:CalendarScheduling', 'Scheduling'),
p('x:CalendarAlarm', 'Alarms'),
p('x:AddressBook', 'Address book'),
],
},
{
name: 'Files & sharing',
pages: [p('x:FileStorage', 'File storage'), p('x:Sharing', 'Sharing')],
},
{
name: "People's filters",
pages: [p('x:SieveUserInterpreter', 'Sieve interpreter'), p('x:SieveUserScript', 'Sieve scripts')],
},
{
name: 'Apps',
pages: [p('x:Application', 'Web applications')],
},
],
},
{
name: 'Storage',
icon: 'database',
blurb: 'Where mail, files and indexes are kept, how long deleted and old data stays, and caching.',
groups: [
{
name: 'Stores',
pages: [
p('x:DataStore', 'Data store'),
p('x:BlobStore', 'Blobs (messages & files)'),
p('x:SearchStore', 'Search'),
p('x:InMemoryStore', 'In-memory'),
p('x:TracingStore', 'Tracing'),
p('x:MetricsStore', 'Metrics'),
],
},
{
name: 'Retention',
pages: [
p('x:DataRetention/DataExpunge', 'Auto-expunge'),
p('x:DataRetention/DataCleanup', 'Data cleanup'),
p('x:DataRetention/ArchivingRetention', 'Archiving'),
p('x:DataRetention/TelemetryRetention', 'Telemetry'),
],
},
{
name: 'Performance',
pages: [p('x:Cache', 'Cache'), p('x:Search', 'Search indexing')],
},
],
},
{
name: 'Monitoring',
icon: 'chart-line',
blurb:
'Logs and traces, metrics for Prometheus or OpenTelemetry, and the alerts and webhooks that tell you things.',
groups: [
{
name: 'Logs & traces',
pages: [p('x:Tracer', 'Tracers'), p('x:EventTracingLevel', 'Event levels')],
},
{
name: 'Metrics',
pages: [
p('x:Metrics', 'General'),
p('x:Metrics/CollectorOtel', 'OpenTelemetry'),
p('x:Metrics/CollectorPrometheus', 'Prometheus'),
],
},
{
name: 'Notifications',
pages: [p('x:Alert', 'Alerts'), p('x:WebHook', 'Webhooks')],
},
],
},
{
name: 'System',
icon: 'boxes',
blurb: 'Running more than one node, background tasks, lookup tables, AI models and branding.',
groups: [
{
name: 'Cluster',
pages: [p('x:Coordinator', 'Coordinator'), p('x:ClusterRole', 'Roles')],
},
{
name: 'Server',
pages: [p('x:TaskManager', 'Task manager'), p('x:AiModel', 'AI models'), p('x:Enterprise', 'Branding')],
},
{
name: 'Lookups',
pages: [
p('x:HttpLookup', 'HTTP lists'),
p('x:StoreLookup', 'Store lookups'),
p('x:MemoryLookupKey', 'In-memory keys'),
p('x:MemoryLookupKeyValue', 'In-memory key-values'),
],
},
],
},
];
/** Where pages the table doesn't know about go. */
const OTHER_CATEGORY = 'System';
const OTHER_GROUP = 'Other';
/** Every link in a layout, view name → the schema's label, in schema order. */
function collectLinks(items: (LayoutItem | LayoutSubItem)[], out: Map<string, string>): Map<string, string> {
for (const item of items) {
if ('link' in item) out.set(item.link.viewName, item.link.name);
else if ('container' in item) collectLinks(item.container.items, out);
else if (item.type === 'link') out.set(item.viewName, item.name);
else collectLinks(item.items, out);
}
return out;
}
/** The Settings layout, regrouped. Other layouts pass through untouched. */
export function regroupSettings(layout: Layout): Layout {
if (layout.name !== SETTINGS_LAYOUT_NAME) return layout;
const serverLinks = collectLinks(layout.items, new Map());
const placed = new Set<string>();
const categories = SETTINGS_CATEGORIES.map((cat) => {
const groups: LayoutSubItem[] = [];
for (const group of cat.groups) {
const links: LayoutSubItem[] = [];
for (const page of group.pages) {
const serverName = serverLinks.get(page.viewName);
if (serverName === undefined || placed.has(page.viewName)) continue;
placed.add(page.viewName);
links.push({ type: 'link', name: page.label ?? serverName, viewName: page.viewName });
}
if (links.length > 0) {
groups.push({ type: 'container', name: group.name, items: links, ...(group.advanced && { advanced: true }) });
}
}
return { name: cat.name, icon: cat.icon, groups };
});
const leftovers: LayoutSubItem[] = [];
for (const [viewName, name] of serverLinks) {
if (!placed.has(viewName)) leftovers.push({ type: 'link', name, viewName });
}
if (leftovers.length > 0) {
const home = categories.find((c) => c.name === OTHER_CATEGORY) ?? categories[categories.length - 1];
home.groups.push({ type: 'container', name: OTHER_GROUP, items: leftovers });
}
const items: LayoutItem[] = [{ link: { name: 'Overview', icon: 'layout-grid', viewName: SETTINGS_OVERVIEW_VIEW } }];
for (const cat of categories) {
if (cat.groups.length > 0) items.push({ container: { name: cat.name, icon: cat.icon, items: cat.groups } });
}
return { ...layout, items };
}
/** The schema with its Settings layout regrouped; applied once, when the schema loads. */
export function withRegroupedSettings(schema: Schema): Schema {
if (!schema.layouts.some((l) => l.name === SETTINGS_LAYOUT_NAME)) return schema;
return { ...schema, layouts: schema.layouts.map(regroupSettings) };
}
+7 -1
View File
@@ -1,11 +1,15 @@
/*
* 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.
*/
import { create } from 'zustand';
import type { Schema, LayoutSubItem } from '@/types/schema';
import { withRegroupedSettings } from '@/lib/settingsLayout';
export interface SearchIndexEntry {
text: string;
@@ -163,7 +167,9 @@ export const useSchemaStore = create<SchemaState>()((set) => ({
viewToSection: {},
searchIndex: [],
setSchema: (schema) => {
setSchema: (served) => {
// inbuxa: Settings grouped by task, not by config file (settings-reorg).
const schema = withRegroupedSettings(served);
const { viewToSection, linkEntries } = walkLayouts(schema);
const searchIndex = buildSearchIndex(schema, viewToSection, linkEntries);
set({
+5
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.
*/
import type { Dashboard } from '@/features/dashboard/types/schema';
@@ -369,6 +372,8 @@ export interface LayoutSubItemContainer {
type: 'container';
name: string;
items: LayoutSubItem[];
/** inbuxa: protocol-level detail, drawn last and dimmed (settings-reorg). */
advanced?: boolean;
}
export interface LayoutSubItemLink {