From 10527002e018714536c5ab9a2100423154f0d190 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 10:12:19 -0700 Subject: [PATCH 1/2] Settings in nine task-based groups on the top bar, with an Overview page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server's Settings menu is nineteen groups, too many for the section bar, so Settings was the one section left on the sidebar. The admin now regroups the same 107 pages by job: Mail flow, Spam filter, Security, Network, Mail & apps, Storage, Monitoring and System, with plainer labels and protocol-level MTA pages in a dimmed Advanced group. View names and URLs are unchanged, and a page the table doesn't know lands in System › Other. Settings › Overview is the new landing page: a card per category, the guided setups, and the pages whose values differ from their defaults. --- src/components/layout/MainContent.tsx | 11 +- src/components/layout/SectionNav.tsx | 37 +- .../settings/SettingsOverviewPage.tsx | 253 +++++++++++ .../settings/changedFromDefault.test.ts | 88 ++++ src/features/settings/changedFromDefault.ts | 78 ++++ src/lib/settingsLayout.test.ts | 109 +++++ src/lib/settingsLayout.ts | 398 ++++++++++++++++++ src/stores/schemaStore.ts | 8 +- src/types/schema.ts | 5 + 9 files changed, 981 insertions(+), 6 deletions(-) create mode 100644 src/features/settings/SettingsOverviewPage.tsx create mode 100644 src/features/settings/changedFromDefault.test.ts create mode 100644 src/features/settings/changedFromDefault.ts create mode 100644 src/lib/settingsLayout.test.ts create mode 100644 src/lib/settingsLayout.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 55993ad..33432c5 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -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 ; } + // inbuxa: Settings › Overview (settings-reorg). + if (componentName === 'SettingsOverview') { + return ; + } if (componentName === 'LocalAi') { return ; } diff --git a/src/components/layout/SectionNav.tsx b/src/components/layout/SectionNav.tsx index 2ce02ed..1a00bec 100644 --- a/src/components/layout/SectionNav.tsx +++ b/src/components/layout/SectionNav.tsx @@ -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 ( - - + + {sub.name} {links.map((l) => ( 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 ( @@ -175,8 +186,26 @@ function SectionNavItem({ item, sectionName, currentPath, edition, onPick, measu - - + + {wide ? ( +
+ +
+ ) : ( + + )}
); diff --git a/src/features/settings/SettingsOverviewPage.tsx b/src/features/settings/SettingsOverviewPage.tsx new file mode 100644 index 0000000..5efadd5 --- /dev/null +++ b/src/features/settings/SettingsOverviewPage.tsx @@ -0,0 +1,253 @@ +/* + * 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 saying what lives there, 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 { ArrowRight, 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, visibleLinks } 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; +} + +async function anyExist(objectName: string): Promise { + 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>(); + 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[] | 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 ( + +
+ {setup.title} + {done && ( + + + {t('settingsOverview.setUp', 'Set up')} + + )} +
+

{setup.blurb}

+ + ); +} + +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]); + + const cards = useMemo(() => { + if (!layout) return []; + return SETTINGS_CATEGORIES.flatMap((cat) => { + const item = layout.items.find((it) => 'container' in it && it.container.name === cat.name); + if (!item || !('container' in item)) return []; + const links = visibleLinks(item.container.items, edition); + if (links.length === 0) return []; + const leaf = (name: string) => name.split(' › ').pop()!; + const popular = cat.popular + .map((v) => links.find((l) => l.viewName === v)) + .filter((l): l is { name: string; viewName: string } => l !== undefined) + .map((l) => ({ ...l, name: leaf(l.name) })); + return [{ cat, first: links[0].viewName, count: links.length, popular }]; + }); + }, [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 ( +
+
+

{t('settingsOverview.title', 'Settings')}

+

+ {t( + 'settingsOverview.subtitle', + 'Everything the server does, grouped by the job. Each page says what it controls, and anything you change is marked against its default.', + )} +

+
+ + {setups.length > 0 && ( +
+

+ + {t('settingsOverview.guided', 'Guided setup')} +

+
+ {setups.map((s) => ( + + ))} +
+
+ )} + +
+ {cards.map(({ cat, first, count, popular }) => ( +
+ + + {cat.name} + +

{cat.blurb}

+
    + {popular.map((l) => ( +
  • + + + {l.name} + +
  • + ))} +
+

+ {t('settingsOverview.pageCount', '{{count}} pages', { count })} +

+
+ ))} +
+ + {changed.length > 0 && ( +
+

+ {t('settingsOverview.changed', 'Changed from default')} +

+
    + {changed.map((pg) => ( +
  • + + {pg.label} + {pg.where} + + {t('settingsOverview.changedCount', '{{count}} changed', { count: pg.count })} + + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/features/settings/changedFromDefault.test.ts b/src/features/settings/changedFromDefault.test.ts new file mode 100644 index 0000000..033bd69 --- /dev/null +++ b/src/features/settings/changedFromDefault.test.ts @@ -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) => ({ 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' }, + ]); + }); +}); diff --git a/src/features/settings/changedFromDefault.ts b/src/features/settings/changedFromDefault.ts new file mode 100644 index 0000000..3a8a35e --- /dev/null +++ b/src/features/settings/changedFromDefault.ts @@ -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): 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; +} diff --git a/src/lib/settingsLayout.test.ts b/src/lib/settingsLayout.test.ts new file mode 100644 index 0000000..600dfba --- /dev/null +++ b/src/lib/settingsLayout.test.ts @@ -0,0 +1,109 @@ +/* + * 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); + }); + + it('only features pages its category holds', () => { + for (const cat of SETTINGS_CATEGORIES) { + const own = new Set(cat.groups.flatMap((g) => g.pages.map((pg) => pg.viewName))); + for (const v of cat.popular) expect(own.has(v), `${cat.name}: ${v}`).toBe(true); + } + }); +}); diff --git a/src/lib/settingsLayout.ts b/src/lib/settingsLayout.ts new file mode 100644 index 0000000..1dfbf68 --- /dev/null +++ b/src/lib/settingsLayout.ts @@ -0,0 +1,398 @@ +/* + * 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/), 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; + /** The pages most people come for, shown on the Overview card. */ + popular: 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.', + popular: ['x:SenderAuth', 'x:MtaRoute', 'x:MtaInboundThrottle'], + 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.', + popular: ['x:SpamSettings', 'CustomComponent/LocalAi', 'x:MemoryLookupKey/SpamTrustedDomain'], + 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.', + popular: ['x:Certificate', 'CustomComponent/LegacyProtocols', 'x:BlockedIp'], + 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.', + popular: ['x:NetworkListener', 'x:DnsServer', 'x:Http'], + 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.', + popular: ['x:Email/EmailLimits', 'x:Imap', 'x:Calendar'], + 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.', + popular: ['x:DataStore', 'x:BlobStore', 'x:DataRetention/DataExpunge'], + 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.', + popular: ['x:Tracer', 'x:Alert', 'x:WebHook'], + 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.', + popular: ['x:Coordinator', 'x:TaskManager', 'x:Enterprise'], + 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): Map { + 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(); + + 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) }; +} diff --git a/src/stores/schemaStore.ts b/src/stores/schemaStore.ts index 8769f09..ce7f2b1 100644 --- a/src/stores/schemaStore.ts +++ b/src/stores/schemaStore.ts @@ -1,11 +1,15 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ 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()((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({ diff --git a/src/types/schema.ts b/src/types/schema.ts index a83a6aa..b85a6ee 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -1,7 +1,10 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ 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 { -- 2.54.0 From d0c2f0f3709c544205b6c23557db33218cd9597e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 10:26:18 -0700 Subject: [PATCH 2/2] Overview cards explain the menu bar rather than link into it Each category card is now its name and what it covers, with no links and no page count: the bar above does the navigating. --- .../settings/SettingsOverviewPage.tsx | 45 +++++-------------- src/lib/settingsLayout.test.ts | 7 --- src/lib/settingsLayout.ts | 10 ----- 3 files changed, 11 insertions(+), 51 deletions(-) diff --git a/src/features/settings/SettingsOverviewPage.tsx b/src/features/settings/SettingsOverviewPage.tsx index 5efadd5..a2bea4d 100644 --- a/src/features/settings/SettingsOverviewPage.tsx +++ b/src/features/settings/SettingsOverviewPage.tsx @@ -6,19 +6,19 @@ /** * inbuxa: Settings › Overview, the landing page for Settings (settings-reorg). - * A card per category saying what lives there, the guided setups, and, only + * 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 { ArrowRight, Check, ListChecks } from 'lucide-react'; +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, visibleLinks } from '@/lib/navTree'; +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'; @@ -148,19 +148,12 @@ export function SettingsOverviewPage() { 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.flatMap((cat) => { + return SETTINGS_CATEGORIES.filter((cat) => { const item = layout.items.find((it) => 'container' in it && it.container.name === cat.name); - if (!item || !('container' in item)) return []; - const links = visibleLinks(item.container.items, edition); - if (links.length === 0) return []; - const leaf = (name: string) => name.split(' › ').pop()!; - const popular = cat.popular - .map((v) => links.find((l) => l.viewName === v)) - .filter((l): l is { name: string; viewName: string } => l !== undefined) - .map((l) => ({ ...l, name: leaf(l.name) })); - return [{ cat, first: links[0].viewName, count: links.length, popular }]; + return item !== undefined && topItemVisible(item, edition); }); }, [layout, edition]); @@ -178,7 +171,7 @@ export function SettingsOverviewPage() {

{t( 'settingsOverview.subtitle', - 'Everything the server does, grouped by the job. Each page says what it controls, and anything you change is marked against its default.', + 'The menu above groups everything the server does by job. Here is what each part covers. Anything you change is marked against its default.', )}

@@ -198,29 +191,13 @@ export function SettingsOverviewPage() { )}
- {cards.map(({ cat, first, count, popular }) => ( + {cards.map((cat) => (
- +
- {cat.name} - +

{cat.name}

+

{cat.blurb}

-
    - {popular.map((l) => ( -
  • - - - {l.name} - -
  • - ))} -
-

- {t('settingsOverview.pageCount', '{{count}} pages', { count })} -

))}
diff --git a/src/lib/settingsLayout.test.ts b/src/lib/settingsLayout.test.ts index 600dfba..411db63 100644 --- a/src/lib/settingsLayout.test.ts +++ b/src/lib/settingsLayout.test.ts @@ -99,11 +99,4 @@ describe('regroupSettings', () => { const all = SETTINGS_CATEGORIES.flatMap((c) => c.groups.flatMap((g) => g.pages.map((pg) => pg.viewName))); expect(new Set(all).size).toBe(all.length); }); - - it('only features pages its category holds', () => { - for (const cat of SETTINGS_CATEGORIES) { - const own = new Set(cat.groups.flatMap((g) => g.pages.map((pg) => pg.viewName))); - for (const v of cat.popular) expect(own.has(v), `${cat.name}: ${v}`).toBe(true); - } - }); }); diff --git a/src/lib/settingsLayout.ts b/src/lib/settingsLayout.ts index 1dfbf68..819e4c3 100644 --- a/src/lib/settingsLayout.ts +++ b/src/lib/settingsLayout.ts @@ -40,8 +40,6 @@ export interface SettingsCategory { icon: string; /** One sentence for the Overview card. */ blurb: string; - /** The pages most people come for, shown on the Overview card. */ - popular: string[]; groups: SettingsGroup[]; } @@ -52,7 +50,6 @@ 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.', - popular: ['x:SenderAuth', 'x:MtaRoute', 'x:MtaInboundThrottle'], groups: [ { name: 'Receiving', @@ -120,7 +117,6 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [ 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.', - popular: ['x:SpamSettings', 'CustomComponent/LocalAi', 'x:MemoryLookupKey/SpamTrustedDomain'], groups: [ { name: 'Protection', @@ -155,7 +151,6 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [ name: 'Security', icon: 'lock', blurb: 'Who can sign in and how, the certificates that encrypt connections, and which addresses are blocked.', - popular: ['x:Certificate', 'CustomComponent/LegacyProtocols', 'x:BlockedIp'], groups: [ { name: 'Sign-in', @@ -185,7 +180,6 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [ name: 'Network', icon: 'cable', blurb: 'The ports the server listens on, its web server, and how it looks up and publishes DNS.', - popular: ['x:NetworkListener', 'x:DnsServer', 'x:Http'], groups: [ { name: 'Connections', @@ -209,7 +203,6 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [ name: 'Mail & apps', icon: 'mail', blurb: 'What people get: mailbox defaults and limits, IMAP and JMAP, calendars, contacts, files and web apps.', - popular: ['x:Email/EmailLimits', 'x:Imap', 'x:Calendar'], groups: [ { name: 'Email', @@ -257,7 +250,6 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [ name: 'Storage', icon: 'database', blurb: 'Where mail, files and indexes are kept, how long deleted and old data stays, and caching.', - popular: ['x:DataStore', 'x:BlobStore', 'x:DataRetention/DataExpunge'], groups: [ { name: 'Stores', @@ -290,7 +282,6 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [ icon: 'chart-line', blurb: 'Logs and traces, metrics for Prometheus or OpenTelemetry, and the alerts and webhooks that tell you things.', - popular: ['x:Tracer', 'x:Alert', 'x:WebHook'], groups: [ { name: 'Logs & traces', @@ -314,7 +305,6 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [ name: 'System', icon: 'boxes', blurb: 'Running more than one node, background tasks, lookup tables, AI models and branding.', - popular: ['x:Coordinator', 'x:TaskManager', 'x:Enterprise'], groups: [ { name: 'Cluster', -- 2.54.0