From dfb5deeac26dece0352ce33275f63fdd60c44dd0 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 26 Sep 2026 00:53:41 -0700 Subject: [PATCH 1/2] Explain this: ask the local model about a failure, verdict, log line or setting An Explain button sits beside four kinds of thing that are hard to read: a queued message's failed recipients (in a small card above the queue form), the Classify verdict on Actions, each Logs row and trace event (stored and live), and each settings field. It opens a panel with the local model's short reading, labeled as the model's and not as fact, with copy, cancel and a clear message when the model is busy, paused or over the hourly limit. The button appears only when the session's aiExplain flag says the server can answer: a superuser, a configured chat model, and explanations switched on. The Local AI page gains an Explanations card for that switch, the model to use, the hourly count per administrator and the longest wait. Needs a server with inbuxa:Explanation/set (sysAiExplain); on older servers the flag is absent and nothing changes. --- src/components/forms/DynamicForm.tsx | 11 + src/components/forms/FieldWidget.tsx | 19 +- src/components/lists/DynamicList.tsx | 31 ++- src/features/actions/ActionPage.tsx | 10 + src/features/ai/LocalAiPage.tsx | 2 + src/features/ai/explain/ExplainButton.tsx | 63 +++++ src/features/ai/explain/ExplainPanel.tsx | 137 ++++++++++ src/features/ai/explain/ExplanationsCard.tsx | 170 ++++++++++++ src/features/ai/explain/FailedRecipients.tsx | 51 ++++ src/features/ai/explain/explain.test.ts | 170 ++++++++++++ src/features/ai/explain/explain.ts | 250 ++++++++++++++++++ src/features/ai/explain/explainStore.ts | 77 ++++++ .../tracing/components/LiveTracingPage.tsx | 3 +- .../tracing/components/TraceDetailView.tsx | 6 +- .../tracing/components/TraceTimeline.tsx | 23 +- src/pages/AdminPanel.tsx | 6 + 16 files changed, 1015 insertions(+), 14 deletions(-) create mode 100644 src/features/ai/explain/ExplainButton.tsx create mode 100644 src/features/ai/explain/ExplainPanel.tsx create mode 100644 src/features/ai/explain/ExplanationsCard.tsx create mode 100644 src/features/ai/explain/FailedRecipients.tsx create mode 100644 src/features/ai/explain/explain.test.ts create mode 100644 src/features/ai/explain/explain.ts create mode 100644 src/features/ai/explain/explainStore.ts diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index 5df4bd4..872f189 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -11,6 +11,7 @@ import { humanize } from '@/lib/humanize'; import { PageHeader } from '@/components/common/PageHeader'; import { HelpPanel } from '@/help/HelpPanel'; import { fieldPlaceholder, formNotices, variantPrefill } from '@/features/ai/formExtras'; +import { FailedRecipients } from '@/features/ai/explain/FailedRecipients'; import { iconForView } from '@/lib/viewIcon'; import { useState, useEffect, useCallback, useMemo } from 'react'; import { flushSync } from 'react-dom'; @@ -809,6 +810,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { ))} + {/* inbuxa: "Explain this" for each failed recipient (ai-explain EX-17) */} + {resolved.obj.objectName === 'x:QueuedMessage' && objectId && ( + + )} + {sectionsToRender.map((section, sectionIdx) => ( {section.title && ( @@ -883,6 +889,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined } helpScope={helpScope} + explainTarget={ + isCreate + ? undefined + : { object: resolved.obj.objectName, id: isSingleton ? 'singleton' : String(objectId) } + } /> ); diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index c3fe186..87e32fb 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -12,6 +12,7 @@ import { useState, useEffect, useMemo, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue'; import { HelpTip } from '@/help/HelpTip'; +import { ExplainButton } from '@/features/ai/explain/ExplainButton'; import { fieldHelp } from '@/help/texts'; import { describeDefault, differsFromDefault } from '@/help/defaults'; import { Input } from '@/components/ui/input'; @@ -69,6 +70,8 @@ export interface FieldWidgetProps { sieveScriptName?: string; /** INBUXA: the object or schema that owns this field, for its help id (`scope.field`). */ helpScope?: string; + /** inbuxa: the saved record this field belongs to, for "Explain this" (ai-explain EX-17). */ + explainTarget?: { object: string; id: string }; } function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optional' | null { @@ -89,7 +92,8 @@ function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optio export function FieldWidget(props: FieldWidgetProps) { const { t } = useTranslation(); - const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName, helpScope } = props; + const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName, helpScope, explainTarget } = + props; const helpId = helpScope ? `${helpScope}.${formField.name}` : undefined; // INBUXA: the option's default, for its tooltip, and whether it has been changed. const defaultValue = helpScope ? schema.fields[helpScope]?.defaults?.[formField.name] : undefined; @@ -250,6 +254,19 @@ export function FieldWidget(props: FieldWidgetProps) { )} + {/* inbuxa: the server refuses any setting that holds a secret (EX-9); a plain secret isn't offered */} + {explainTarget && !(ft.type === 'string' && ft.format === 'secret') && ( + + )} {changed && ( s.viewToSection); const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); const edition = useAccountStore((s) => s.edition); + const explainAvailable = useExplainStore((s) => s.available); const [upsellOpen, setUpsellOpen] = useState(false); const resolved = useMemo(() => { @@ -856,6 +859,9 @@ export function DynamicList({ viewName }: DynamicListProps) { const hasMassActions = effectiveMassActions.length > 0; const hasItemActions = (list.itemActions?.length ?? 0) > 0; + // inbuxa: "Explain this" on each log entry (ai-explain EX-17) + const explainLogs = explainAvailable && obj.objectName === 'x:Log'; + const hasRowCell = hasItemActions || explainLogs; const pageStart = anchorStack.length * PAGE_SIZE; const rangeStart = pageStart + 1; @@ -1265,7 +1271,7 @@ export function DynamicList({ viewName }: DynamicListProps) { ))} - {hasItemActions && ( + {hasRowCell && ( {t('list.actions', 'Actions')} @@ -1276,7 +1282,7 @@ export function DynamicList({ viewName }: DynamicListProps) { {loading && items.length === 0 ? ( @@ -1284,10 +1290,7 @@ export function DynamicList({ viewName }: DynamicListProps) { ) : items.length === 0 ? ( - + ); })} - {hasItemActions && {renderItemActions(item)}} + {hasRowCell && ( + +
+ {explainLogs && item.id != null && ( + + )} + {renderItemActions(item)} +
+ + )} ); }) diff --git a/src/features/actions/ActionPage.tsx b/src/features/actions/ActionPage.tsx index 425cdbc..94ea7ba 100644 --- a/src/features/actions/ActionPage.tsx +++ b/src/features/actions/ActionPage.tsx @@ -19,6 +19,8 @@ import { FieldWidget } from '@/components/forms/FieldWidget'; import { DynamicView } from '@/components/views/DynamicView'; import type { Schema, ObjectVariant } from '@/types/schema'; import type { JmapSetResponse } from '@/types/jmap'; +import { ExplainButton } from '@/features/ai/explain/ExplainButton'; +import { spamVerdictSubject } from '@/features/ai/explain/explain'; type ActionState = | { kind: 'pick' } @@ -467,6 +469,14 @@ function ActionResultView({ /> )} + {/* inbuxa: "Explain this" for a spam verdict (ai-explain EX-17) */} + {variant.name === 'ClassifySpam' && props && ( + spamVerdictSubject(props)} + title={t('explain.spamVerdictTitle', 'Spam verdict: {{result}}', { result: String(props.result ?? '') })} + /> + )} + {!hasData && ( diff --git a/src/features/ai/LocalAiPage.tsx b/src/features/ai/LocalAiPage.tsx index 00feab3..37d56ed 100644 --- a/src/features/ai/LocalAiPage.tsx +++ b/src/features/ai/LocalAiPage.tsx @@ -44,6 +44,7 @@ import { type Status, } from './localAi'; import { formNotices } from './formExtras'; +import { ExplanationsCard } from './explain/ExplanationsCard'; export const LOCAL_AI_VIEW = 'CustomComponent/LocalAi'; @@ -148,6 +149,7 @@ export function LocalAiPage() { )} + ); } diff --git a/src/features/ai/explain/ExplainButton.tsx b/src/features/ai/explain/ExplainButton.tsx new file mode 100644 index 0000000..cb1efc3 --- /dev/null +++ b/src/features/ai/explain/ExplainButton.tsx @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useTranslation } from 'react-i18next'; +import { Sparkles } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { useExplainStore } from './explainStore'; +import type { ExplainSubject } from './explain'; + +/** + * inbuxa: asks the local model to explain `subject` in the side panel + * (EX-17). Drawn only when the session says Explain is available; `icon` + * is the small form for help tips and table rows. + */ +export function ExplainButton({ + subject, + title, + icon = false, + className, +}: { + subject: ExplainSubject | (() => ExplainSubject); + /** What the panel is about, e.g. the recipient or the setting's label. */ + title: string; + icon?: boolean; + className?: string; +}) { + const { t } = useTranslation(); + const available = useExplainStore((s) => s.available); + const ask = useExplainStore((s) => s.ask); + if (!available) return null; + const label = t('explain.button', 'Explain'); + const onClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + ask(typeof subject === 'function' ? subject() : subject, title); + }; + if (icon) { + return ( + + ); + } + return ( + + ); +} diff --git a/src/features/ai/explain/ExplainPanel.tsx b/src/features/ai/explain/ExplainPanel.tsx new file mode 100644 index 0000000..8453369 --- /dev/null +++ b/src/features/ai/explain/ExplainPanel.tsx @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; +import { Check, Copy, Loader2, Sparkles, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useExplainStore } from './explainStore'; +import type { ExplainFailure } from './explain'; + +/** + * inbuxa: the explanation, beside the page rather than over it, so the + * details it explains stay in view (EX-18 to EX-20). Mounted once. + */ +export function ExplainPanel() { + const { t } = useTranslation(); + const panel = useExplainStore((s) => s.panel); + const close = useExplainStore((s) => s.close); + const retry = useExplainStore((s) => s.retry); + const [copiedText, setCopiedText] = useState(null); + + useEffect(() => { + if (panel.status === 'closed') return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') close(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [panel.status, close]); + + if (panel.status === 'closed') return null; + const copied = panel.status === 'done' && copiedText === panel.explanation.text; + + return ( + + ); +} + +function failureText(t: TFunction, failure: ExplainFailure): string { + switch (failure.kind) { + case 'busy': + return t('explain.busy', 'The model is busy with incoming mail. Try again in a moment.'); + case 'timeout': + return t('explain.timeout', "The model didn't answer in time."); + case 'paused': + return t('explain.paused', 'The model is paused after repeated failures. It will be tried again shortly.'); + case 'rateLimit': + return t('explain.rateLimit', "You've asked for as many explanations as this hour allows."); + case 'unavailable': + return t('explain.unavailable', 'No local model is available to explain this right now.'); + case 'refused': + return failure.message; + } +} diff --git a/src/features/ai/explain/ExplanationsCard.tsx b/src/features/ai/explain/ExplanationsCard.tsx new file mode 100644 index 0000000..e9c7e44 --- /dev/null +++ b/src/features/ai/explain/ExplanationsCard.tsx @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { toast } from '@/hooks/use-toast'; +import { fetchSession } from '@/services/jmap/client'; +import { useAuthStore } from '@/stores/authStore'; +import type { AiModelSummary } from '../localAi'; +import { useExplainStore } from './explainStore'; +import { + DEFAULT_EXPLAIN, + explainAvailable, + fetchExplainSettings, + saveExplainSettings, + type ExplainSettings, +} from './explain'; + +/** + * inbuxa: Local AI › Explanations (ai-explain EX-21): the switch, which model + * explains, and the limits. Hidden on a server that predates explanations. + */ +export function ExplanationsCard({ canUpdate, models }: { canUpdate: boolean; models: AiModelSummary[] }) { + const { t } = useTranslation(); + const [current, setCurrent] = useState(null); + const [draft, setDraft] = useState({ enabled: true, modelId: '', perHour: '', ceiling: '' }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const fill = (s: ExplainSettings) => + setDraft({ + enabled: s.explainEnabled, + modelId: s.explainModelId ?? '', + perHour: String(s.explainCallsPerHour), + ceiling: String(s.explainCeiling / 1000), + }); + + useEffect(() => { + const ctl = new AbortController(); + fetchExplainSettings(ctl.signal) + .then((s) => { + if (ctl.signal.aborted || !s) return; + setCurrent(s); + fill(s); + }) + .catch(() => undefined); + return () => ctl.abort(); + }, []); + + if (!current) return null; + + const perHour = Number(draft.perHour); + const ceiling = Number(draft.ceiling); + const valid = Number.isInteger(perHour) && perHour >= 1 && Number.isFinite(ceiling) && ceiling >= 1 && ceiling <= 600; + + const save = async () => { + if (!valid) return; + const next: ExplainSettings = { + explainEnabled: draft.enabled, + explainModelId: draft.modelId || null, + explainCallsPerHour: perHour, + explainCeiling: Math.round(ceiling * 1000), + }; + setBusy(true); + setError(null); + const outcome = await saveExplainSettings(current, next).catch((e: unknown) => ({ + ok: false, + message: e instanceof Error ? e.message : String(e), + })); + if (outcome.ok) { + setCurrent(next); + // Explain buttons follow the switch at once + const session = await fetchSession().catch(() => null); + if (session) { + useExplainStore.getState().setAvailable(explainAvailable(session, useAuthStore.getState().primaryAccountId)); + } + toast({ title: t('explain.settingsSaved', 'Explanation settings saved.') }); + } else { + setError(outcome.message ?? ''); + } + setBusy(false); + }; + + return ( + + + {t('explain.settingsTitle', 'Explanations')} + + +

+ {t( + 'explain.settingsLead', + 'An Explain button beside delivery failures, spam verdicts, log and trace events, and settings asks the model for a short explanation in plain words. It never takes a slot the spam filter needs, and message text is never sent.', + )} +

+
+ setDraft({ ...draft, enabled })} + /> + +
+
+
+ + +
+
+ + setDraft({ ...draft, perHour: e.target.value })} + /> +

+ {t('localAi.limitDefault', 'Default: {{value}}', { value: DEFAULT_EXPLAIN.explainCallsPerHour })} +

+
+
+ + setDraft({ ...draft, ceiling: e.target.value })} + /> +

+ {t('localAi.limitDefault', 'Default: {{value}}', { value: DEFAULT_EXPLAIN.explainCeiling / 1000 })}{' '} + {t('localAi.seconds', 's')} +

+
+
+ {error &&

{error}

} + {canUpdate && ( + + )} +
+
+ ); +} diff --git a/src/features/ai/explain/FailedRecipients.tsx b/src/features/ai/explain/FailedRecipients.tsx new file mode 100644 index 0000000..d9c6044 --- /dev/null +++ b/src/features/ai/explain/FailedRecipients.tsx @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useTranslation } from 'react-i18next'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ExplainButton } from './ExplainButton'; +import { useExplainStore } from './explainStore'; +import { failedRecipients } from './explain'; + +/** + * inbuxa: a queued message's failed recipients, each with Explain (EX-17). + * Shown above the message's form, and only when there is something to + * explain and a model to explain it. + */ +export function FailedRecipients({ queueId, recipients }: { queueId: string; recipients: unknown }) { + const { t } = useTranslation(); + const available = useExplainStore((s) => s.available); + const failed = failedRecipients(recipients); + if (!available || failed.length === 0) return null; + return ( + + + {t('explain.failedRecipients', 'Failed deliveries')} + + +
    + {failed.map((rcpt) => ( +
  • +
    +

    {rcpt.address}

    +

    + {rcpt.status === 'PermanentFailure' + ? t('explain.permanent', 'Permanent failure') + : t('explain.temporary', 'Temporary failure')} + {rcpt.summary && ` · ${rcpt.summary}`} +

    +
    + +
  • + ))} +
+
+
+ ); +} diff --git a/src/features/ai/explain/explain.test.ts b/src/features/ai/explain/explain.test.ts new file mode 100644 index 0000000..3bfed39 --- /dev/null +++ b/src/features/ai/explain/explain.test.ts @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const jmapRequest = vi.fn(); +vi.mock('@/services/jmap/client', () => ({ + getAccountId: () => 'a', + jmapRequest: (...args: unknown[]) => jmapRequest(...args), +})); + +import { + DEFAULT_EXPLAIN, + explainAvailable, + failedRecipients, + isRawEvent, + liveEventSubject, + requestExplanation, + saveExplainSettings, + spamVerdictSubject, + valueText, +} from './explain'; + +beforeEach(() => jmapRequest.mockReset()); + +describe('explainAvailable', () => { + it('reads the flag from the primary account only', () => { + const session = { + accounts: { + b: { accountCapabilities: { 'urn:inbuxa:jmap': { aiExplain: true } } }, + c: { accountCapabilities: { 'urn:inbuxa:jmap': { aiExplain: false } } }, + }, + }; + expect(explainAvailable(session, 'b')).toBe(true); + expect(explainAvailable(session, 'c')).toBe(false); + expect(explainAvailable(session, null)).toBe(false); + expect(explainAvailable({ accounts: { b: { accountCapabilities: {} } } }, 'b')).toBe(false); + }); +}); + +describe('subjects', () => { + it('trims a live event to what the server accepts, without raw contents', () => { + const keyValues = [ + { key: 'contents', value: { '@type': 'String', value: 'a LOGIN bob hunter2' } }, + { key: 'remoteIp', value: { '@type': 'IpAddr', value: '192.0.2.1' } }, + { key: 'long', value: { '@type': 'String', value: 'x'.repeat(900) } }, + ...Array.from({ length: 60 }, (_, n) => ({ key: `k${n}`, value: { '@type': 'UnsignedInt', value: n } })), + ]; + const subject = liveEventSubject({ event: 'imap.command', keyValues }); + if (!('keyValues' in subject)) throw new Error('not a live event'); + expect(subject.keyValues).toHaveLength(50); + expect(JSON.stringify(subject)).not.toContain('hunter2'); + expect(subject.keyValues[0]).toEqual({ key: 'remoteIp', value: '192.0.2.1' }); + expect(subject.keyValues[1].value.length).toBeLessThanOrEqual(500); + }); + + it('flattens typed values the way the server does', () => { + expect( + valueText({ + '@type': 'List', + value: [ + { '@type': 'String', value: 'a' }, + { '@type': 'UnsignedInt', value: 2 }, + ], + }), + ).toBe('a, 2'); + expect(isRawEvent('smtp.raw-input')).toBe(true); + expect(isRawEvent('smtp.ehlo')).toBe(false); + }); + + it('lists only failed recipients, with their reply', () => { + const failed = failedRecipients({ + 'a@example.com': { status: { '@type': 'Scheduled' } }, + 'b@example.com': { + status: { + '@type': 'PermanentFailure', + responseCode: 550, + responseEnhanced: '5.7.26', + responseMessage: 'DMARC', + }, + }, + 'c@example.com': { status: { '@type': 'TemporaryFailure', errorType: 'ConnectionError' } }, + }); + expect(failed).toEqual([ + { address: 'b@example.com', status: 'PermanentFailure', summary: '550 5.7.26 DMARC' }, + { address: 'c@example.com', status: 'TemporaryFailure', summary: 'ConnectionError' }, + ]); + expect(failedRecipients(undefined)).toEqual([]); + }); + + it('sends a Classify result back as a verdict', () => { + expect( + spamVerdictSubject({ + result: 'spam', + score: 7.5, + tags: { DMARC_POLICY_REJECT: { score: 5, disposition: 'score' } }, + }), + ).toEqual({ + '@type': 'SpamVerdict', + result: 'spam', + score: 7.5, + tags: { DMARC_POLICY_REJECT: { score: 5, disposition: 'score' } }, + }); + }); +}); + +describe('requestExplanation', () => { + const setting = { '@type': 'Setting', object: 'x:Domain', id: 'b', property: 'isEnabled' } as const; + + it('returns the explanation and its provenance', async () => { + jmapRequest.mockResolvedValue([ + [ + 'inbuxa:Explanation/set', + { created: { e: { text: 'It turns it on.', model: 'qwen', node: 'host2', elapsedMs: 900 } } }, + ], + ]); + const result = await requestExplanation(setting); + expect(result).toEqual({ + ok: true, + explanation: { text: 'It turns it on.', model: 'qwen', node: 'host2', elapsedMs: 900 }, + }); + const [calls, , using] = jmapRequest.mock.calls[0]; + expect(calls[0][1]).toEqual({ accountId: 'a', create: { e: { subject: setting } } }); + expect(using).toEqual(['urn:inbuxa:jmap']); + }); + + it.each([ + [{ type: 'serverFail', description: 'busy' }, { kind: 'busy' }], + [{ type: 'serverFail', description: 'timeout' }, { kind: 'timeout' }], + [{ type: 'serverFail', description: 'paused' }, { kind: 'paused' }], + [{ type: 'serverFail', description: 'unavailable' }, { kind: 'unavailable' }], + [{ type: 'rateLimit' }, { kind: 'rateLimit' }], + [ + { type: 'forbidden', description: 'That setting holds a secret.' }, + { kind: 'refused', message: 'That setting holds a secret.' }, + ], + ])('words %o as %o (EX-20)', async (error, failure) => { + jmapRequest.mockResolvedValue([['inbuxa:Explanation/set', { notCreated: { e: error } }]]); + expect(await requestExplanation(setting)).toEqual({ ok: false, failure }); + }); + + it('treats a server without explanations as unavailable', async () => { + jmapRequest.mockResolvedValue([['error', { type: 'unknownMethod' }]]); + expect(await requestExplanation(setting)).toEqual({ ok: false, failure: { kind: 'unavailable' } }); + }); +}); + +describe('saveExplainSettings', () => { + it('sends only what changed, and a default as null', async () => { + jmapRequest.mockResolvedValue([['inbuxa:AiLimits/set', { updated: { singleton: null } }]]); + const outcome = await saveExplainSettings( + { ...DEFAULT_EXPLAIN, explainCallsPerHour: 10 }, + { ...DEFAULT_EXPLAIN, explainEnabled: false, explainModelId: 'm' }, + ); + expect(outcome).toEqual({ ok: true }); + expect(jmapRequest.mock.calls[0][0][0][1].update.singleton).toEqual({ + explainEnabled: false, + explainModelId: 'm', + explainCallsPerHour: null, + }); + }); + + it('does nothing when nothing changed', async () => { + expect(await saveExplainSettings(DEFAULT_EXPLAIN, { ...DEFAULT_EXPLAIN })).toEqual({ ok: true }); + expect(jmapRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/ai/explain/explain.ts b/src/features/ai/explain/explain.ts new file mode 100644 index 0000000..136e9ff --- /dev/null +++ b/src/features/ai/explain/explain.ts @@ -0,0 +1,250 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: "Explain this" (inbuxa-server's ai-explain spec). The console + * names what to explain; the server reads the data, builds the prompt and + * asks the local model on the node that answers. Nothing here sends free + * text to a model. + */ + +import { getAccountId, jmapRequest } from '@/services/jmap/client'; +import { INBUXA_CAPABILITY } from '../localAi'; + +/** What to explain: the `subject` of an `inbuxa:Explanation`. */ +export type ExplainSubject = + | { '@type': 'DeliveryFailure'; queueId: string; recipient: string } + | { + '@type': 'SpamVerdict'; + result: string; + score: number; + tags: Record; + } + | { '@type': 'LogEntry'; logId: string } + | { '@type': 'TraceEvent'; traceId: string; index: number } + | { '@type': 'TraceEvent'; event: string; keyValues: { key: string; value: string }[] } + | { '@type': 'Setting'; object: string; id: string; property: string }; + +export interface Explanation { + text: string; + model: string; + node: string; + elapsedMs: number; +} + +/** Why there's no explanation, in the few kinds the panel words differently (EX-20). */ +export type ExplainFailure = + { kind: 'busy' | 'timeout' | 'paused' | 'unavailable' | 'rateLimit' } | { kind: 'refused'; message: string }; + +export type ExplainResult = { ok: true; explanation: Explanation } | { ok: false; failure: ExplainFailure }; + +/** The session's flag (EX-1 to EX-4): Explain is shown only when it's true. */ +export function explainAvailable(session: Record, accountId: string | null): boolean { + const accounts = session.accounts as Record }> | undefined; + const capability = accountId ? accounts?.[accountId]?.accountCapabilities?.[INBUXA_CAPABILITY] : undefined; + return (capability as { aiExplain?: unknown } | undefined)?.aiExplain === true; +} + +// The server's limits (EX-8), kept here so a live event is trimmed rather than refused +const MAX_KEY_VALUES = 50; +const MAX_VALUE_CHARS = 500; +/** Raw protocol bytes: never sent (EX-9). */ +const DROPPED_KEYS = new Set(['contents']); + +export function isRawEvent(event: string): boolean { + return event.endsWith('.raw-input') || event.endsWith('.raw-output'); +} + +/** A trace value as plain text, the way the server flattens a stored one. */ +export function valueText(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.map(valueText).filter(Boolean).join(', '); + if (typeof value === 'object') { + return Object.entries(value as Record) + .filter(([k]) => k !== '@type') + .map(([, v]) => valueText(v)) + .filter(Boolean) + .join(' '); + } + return String(value); +} + +/** A live tracing event, trimmed to what the server accepts. */ +export function liveEventSubject(event: { + event: string; + keyValues: { key: string; value: unknown }[]; +}): ExplainSubject { + const keyValues = event.keyValues + .filter((kv) => !DROPPED_KEYS.has(kv.key)) + .slice(0, MAX_KEY_VALUES) + .map((kv) => { + const text = valueText(kv.value); + return { key: kv.key, value: text.length > MAX_VALUE_CHARS ? `${text.slice(0, MAX_VALUE_CHARS - 1)}…` : text }; + }); + return { '@type': 'TraceEvent', event: event.event, keyValues }; +} + +/** The failed recipients of a queued message, as the queue view holds it. */ +export function failedRecipients( + recipients: unknown, +): { address: string; status: 'TemporaryFailure' | 'PermanentFailure'; summary: string }[] { + if (!recipients || typeof recipients !== 'object') return []; + const out: { address: string; status: 'TemporaryFailure' | 'PermanentFailure'; summary: string }[] = []; + for (const [address, rcpt] of Object.entries(recipients as Record)) { + const status = (rcpt as { status?: Record } | null)?.status; + const type = status?.['@type']; + if (type !== 'TemporaryFailure' && type !== 'PermanentFailure') continue; + const code = [status?.responseCode, status?.responseEnhanced].filter((v) => v != null && v !== '').join(' '); + const words = status?.responseMessage ?? status?.errorMessage ?? status?.errorType ?? ''; + out.push({ address, status: type, summary: [code, String(words)].filter(Boolean).join(' ') }); + } + return out; +} + +function failureFrom(error: { type?: string; description?: string } | undefined): ExplainFailure { + const type = error?.type; + const description = error?.description ?? ''; + if (type === 'serverFail') { + if (description === 'busy' || description === 'timeout' || description === 'paused') return { kind: description }; + return { kind: 'unavailable' }; + } + if (type === 'rateLimit') return { kind: 'rateLimit' }; + if (type === 'unknownMethod' || type === 'unknownCapability') return { kind: 'unavailable' }; + return { kind: 'refused', message: description || type || 'error' }; +} + +/** Asks for one explanation. */ +export async function requestExplanation(subject: ExplainSubject, signal?: AbortSignal): Promise { + const accountId = getAccountId('x:'); + const responses = await jmapRequest( + [['inbuxa:Explanation/set', { accountId, create: { e: { subject } } }, '0']], + signal, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name === 'error') return { ok: false, failure: failureFrom(result as { type?: string; description?: string }) }; + const body = result as { + created?: Record>; + notCreated?: Record; + }; + const created = body.created?.e; + if (created && typeof created.text === 'string') { + return { + ok: true, + explanation: { + text: created.text, + model: String(created.model ?? ''), + node: String(created.node ?? ''), + elapsedMs: Number(created.elapsedMs ?? 0), + }, + }; + } + return { ok: false, failure: failureFrom(body.notCreated?.e) }; +} + +/** The result of Actions › Classify a message, sent back as a verdict. */ +export function spamVerdictSubject(result: Record): ExplainSubject { + const tags: Record = {}; + const raw = result.tags; + if (raw && typeof raw === 'object') { + for (const [name, tag] of Object.entries(raw as Record | null>)) { + tags[name] = { + score: typeof tag?.score === 'number' ? tag.score : undefined, + disposition: typeof tag?.disposition === 'string' ? tag.disposition : undefined, + }; + } + } + return { + '@type': 'SpamVerdict', + result: String(result.result ?? ''), + score: typeof result.score === 'number' ? result.score : 0, + tags, + }; +} + +// ── Settings, in inbuxa:AiLimits (EX-21) ───────────────────────────────── + +export interface ExplainSettings { + explainEnabled: boolean; + /** Unset: the spam classifier's model, or the only model there is (EX-3). */ + explainModelId: string | null; + explainCallsPerHour: number; + /** Milliseconds. */ + explainCeiling: number; +} + +export const DEFAULT_EXPLAIN: ExplainSettings = { + explainEnabled: true, + explainModelId: null, + explainCallsPerHour: 30, + explainCeiling: 45_000, +}; + +export function parseExplainSettings(raw: Record): ExplainSettings { + return { + explainEnabled: typeof raw.explainEnabled === 'boolean' ? raw.explainEnabled : DEFAULT_EXPLAIN.explainEnabled, + explainModelId: typeof raw.explainModelId === 'string' && raw.explainModelId ? raw.explainModelId : null, + explainCallsPerHour: + typeof raw.explainCallsPerHour === 'number' ? raw.explainCallsPerHour : DEFAULT_EXPLAIN.explainCallsPerHour, + explainCeiling: typeof raw.explainCeiling === 'number' ? raw.explainCeiling : DEFAULT_EXPLAIN.explainCeiling, + }; +} + +/** Null when the server predates explanations. */ +export async function fetchExplainSettings(signal?: AbortSignal): Promise { + const accountId = getAccountId('x:'); + const responses = await jmapRequest( + [ + [ + 'inbuxa:AiLimits/get', + { + accountId, + ids: null, + properties: ['explainEnabled', 'explainModelId', 'explainCallsPerHour', 'explainCeiling'], + }, + '0', + ], + ], + signal, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name === 'error') return null; + const row = ((result as { list?: Record[] }).list ?? [])[0] ?? {}; + // An older server ignores the properties it doesn't know + if (!('explainEnabled' in row)) return null; + return parseExplainSettings(row); +} + +/** + * Saves what changed; a value equal to the default goes as null, so the + * server keeps following the default. The error names the property, if any. + */ +export async function saveExplainSettings( + current: ExplainSettings, + next: ExplainSettings, +): Promise<{ ok: boolean; message?: string }> { + const patch: Record = {}; + for (const key of Object.keys(DEFAULT_EXPLAIN) as (keyof ExplainSettings)[]) { + if (next[key] === current[key]) continue; + patch[key] = next[key] === DEFAULT_EXPLAIN[key] ? null : next[key]; + } + if (Object.keys(patch).length === 0) return { ok: true }; + const accountId = getAccountId('x:'); + const responses = await jmapRequest( + [['inbuxa:AiLimits/set', { accountId, update: { singleton: patch } }, '0']], + undefined, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name === 'error') { + return { ok: false, message: String((result as { description?: string })?.description ?? 'error') }; + } + const failure = (result as { notUpdated?: Record }).notUpdated + ?.singleton; + return failure ? { ok: false, message: failure.description ?? failure.type } : { ok: true }; +} diff --git a/src/features/ai/explain/explainStore.ts b/src/features/ai/explain/explainStore.ts new file mode 100644 index 0000000..b808b9d --- /dev/null +++ b/src/features/ai/explain/explainStore.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: whether Explain is offered (the session's `aiExplain`), and the one + * explanation panel. Asking again replaces what the panel shows; a request + * left behind is abandoned in the console (EX-19), and the server finishes + * or times out on its own. + */ + +import { create } from 'zustand'; +import { requestExplanation, type ExplainFailure, type ExplainSubject, type Explanation } from './explain'; + +type PanelState = + | { status: 'closed' } + | { status: 'asking'; title: string; subject: ExplainSubject } + | { status: 'done'; title: string; subject: ExplainSubject; explanation: Explanation } + | { status: 'failed'; title: string; subject: ExplainSubject; failure: ExplainFailure }; + +interface ExplainState { + available: boolean; + panel: PanelState; + setAvailable: (available: boolean) => void; + ask: (subject: ExplainSubject, title: string) => void; + retry: () => void; + close: () => void; +} + +let controller: AbortController | null = null; + +export const useExplainStore = create()((set, get) => ({ + available: false, + panel: { status: 'closed' }, + + setAvailable: (available) => set({ available }), + + ask: (subject, title) => { + controller?.abort(); + const mine = new AbortController(); + controller = mine; + set({ panel: { status: 'asking', title, subject } }); + requestExplanation(subject, mine.signal) + .then((result) => { + if (controller !== mine) return; + set({ + panel: result.ok + ? { status: 'done', title, subject, explanation: result.explanation } + : { status: 'failed', title, subject, failure: result.failure }, + }); + }) + .catch((err: unknown) => { + if (controller !== mine || mine.signal.aborted) return; + set({ + panel: { + status: 'failed', + title, + subject, + failure: { kind: 'refused', message: err instanceof Error ? err.message : String(err) }, + }, + }); + }); + }, + + retry: () => { + const panel = get().panel; + if (panel.status !== 'closed') get().ask(panel.subject, panel.title); + }, + + close: () => { + controller?.abort(); + controller = null; + set({ panel: { status: 'closed' } }); + }, +})); diff --git a/src/features/tracing/components/LiveTracingPage.tsx b/src/features/tracing/components/LiveTracingPage.tsx index 658424b..632d183 100644 --- a/src/features/tracing/components/LiveTracingPage.tsx +++ b/src/features/tracing/components/LiveTracingPage.tsx @@ -16,6 +16,7 @@ import { useSchemaStore } from '@/stores/schemaStore'; import { apiFetch, getApiBaseUrl } from '@/services/api'; import type { TraceEvent } from '../types'; import { TraceTimeline } from './TraceTimeline'; +import { liveEventSubject } from '@/features/ai/explain/explain'; const MAX_EVENTS = 1000; const MAX_RECONNECT = 5; @@ -336,7 +337,7 @@ export function LiveTracingPage() { - + liveEventSubject(event)} /> diff --git a/src/features/tracing/components/TraceDetailView.tsx b/src/features/tracing/components/TraceDetailView.tsx index f745a88..089edda 100644 --- a/src/features/tracing/components/TraceDetailView.tsx +++ b/src/features/tracing/components/TraceDetailView.tsx @@ -200,7 +200,11 @@ export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) { - + ({ '@type': 'TraceEvent', traceId: objectId, index })} + /> diff --git a/src/features/tracing/components/TraceTimeline.tsx b/src/features/tracing/components/TraceTimeline.tsx index 5b93cbd..5e540c4 100644 --- a/src/features/tracing/components/TraceTimeline.tsx +++ b/src/features/tracing/components/TraceTimeline.tsx @@ -10,6 +10,8 @@ import { Link } from 'react-router-dom'; import { CircleDot, CircleX, Clock } from 'lucide-react'; import { useSchemaStore } from '@/stores/schemaStore'; import type { TraceEvent, TraceKeyValue, TraceValue } from '../types'; +import { ExplainButton } from '@/features/ai/explain/ExplainButton'; +import { isRawEvent, type ExplainSubject } from '@/features/ai/explain/explain'; const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013'; @@ -36,9 +38,11 @@ function isIdKey(key: string): boolean { interface TraceTimelineProps { events: TraceEvent[]; anchorTimestamp?: string; + /** inbuxa: what "Explain this" asks about an event (ai-explain EX-17). */ + explainSubject?: (event: TraceEvent, index: number) => ExplainSubject; } -export function TraceTimeline({ events, anchorTimestamp }: TraceTimelineProps) { +export function TraceTimeline({ events, anchorTimestamp, explainSubject }: TraceTimelineProps) { const { t } = useTranslation(); const schema = useSchemaStore((s) => s.schema); const containerRef = useRef(null); @@ -139,9 +143,20 @@ export function TraceTimeline({ events, anchorTimestamp }: TraceTimelineProps) { )} {explanation &&

{explanation}

} -
-
{formatAbsoluteTime(event.timestamp)}
-
{formatRelativeTime(event.timestamp)}
+
+ {/* inbuxa: raw protocol traffic isn't sent to the model (EX-9) */} + {explainSubject && !isRawEvent(event.event) && ( + explainSubject(event, idx)} + /> + )} +
+
{formatAbsoluteTime(event.timestamp)}
+
{formatRelativeTime(event.timestamp)}
+
diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 12db2b1..fc924d7 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -23,6 +23,9 @@ 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 { ExplainPanel } from '@/features/ai/explain/ExplainPanel'; +import { useExplainStore } from '@/features/ai/explain/explainStore'; +import { explainAvailable } from '@/features/ai/explain/explain'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { @@ -164,6 +167,8 @@ export default function AdminPanel() { if (cancelled) return; setSession(accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet); + // inbuxa: whether "Explain this" is offered (ai-explain EX-1 to EX-4) + useExplainStore.getState().setAvailable(explainAvailable(session, primaryAccountId)); useAuthStore.getState().setUsername(typeof session.username === 'string' ? session.username : null); // INBUXA: the theme lives with the account, shared with the webmail. setAccountSettingsTarget(session); @@ -327,6 +332,7 @@ export default function AdminPanel() { + ); } -- 2.54.0 From 3b85d1f98416b01b4088a4a0182f55df6c127671 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 26 Sep 2026 01:09:49 -0700 Subject: [PATCH 2/2] Live tracing: read key-values as the stream sends them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live stream sends each event's key-values as a JMAP map ({"0": …}), not an array. The live page cast it straight to an array, so Explain threw on every live event, and the timeline never showed an event's details. Live events now go through the same normalizing as stored traces, moved to features/tracing/normalize.ts. Also adds the modification notice to four upstream files this branch changed. --- src/features/actions/ActionPage.tsx | 2 + src/features/ai/explain/explain.test.ts | 17 +++++++ .../tracing/components/LiveTracingPage.tsx | 5 ++- .../tracing/components/TraceDetailView.tsx | 37 ++------------- .../tracing/components/TraceTimeline.tsx | 2 + src/features/tracing/normalize.ts | 45 +++++++++++++++++++ 6 files changed, 74 insertions(+), 34 deletions(-) create mode 100644 src/features/tracing/normalize.ts diff --git a/src/features/actions/ActionPage.tsx b/src/features/actions/ActionPage.tsx index 94ea7ba..012f845 100644 --- a/src/features/actions/ActionPage.tsx +++ b/src/features/actions/ActionPage.tsx @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import { useState, useMemo, useCallback } from 'react'; diff --git a/src/features/ai/explain/explain.test.ts b/src/features/ai/explain/explain.test.ts index 3bfed39..9d2e63b 100644 --- a/src/features/ai/explain/explain.test.ts +++ b/src/features/ai/explain/explain.test.ts @@ -23,6 +23,7 @@ import { spamVerdictSubject, valueText, } from './explain'; +import { normalizeTraceEvents } from '@/features/tracing/normalize'; beforeEach(() => jmapRequest.mockReset()); @@ -57,6 +58,22 @@ describe('subjects', () => { expect(subject.keyValues[1].value.length).toBeLessThanOrEqual(500); }); + it('takes a live event as the stream sends it, key-values as a JMAP map', () => { + const [event] = normalizeTraceEvents( + JSON.parse( + '[{"event":"http.response-body","timestamp":"2026-09-26T08:07:09Z","keyValues":{' + + '"0":{"key":"spanId","value":{"value":331622707493871616,"@type":"UnsignedInt"}},' + + '"1":{"key":"contents","value":{"value":"{\\"accessToken\\":\\"secret\\"}","@type":"String"}},' + + '"2":{"key":"code","value":{"value":200,"@type":"UnsignedInt"}}}}]', + ), + ); + const subject = liveEventSubject(event); + if (!('keyValues' in subject)) throw new Error('not a live event'); + expect(subject.keyValues.map((kv) => kv.key)).toEqual(['spanId', 'code']); + expect(subject.keyValues[1]).toEqual({ key: 'code', value: '200' }); + expect(JSON.stringify(subject)).not.toContain('secret'); + }); + it('flattens typed values the way the server does', () => { expect( valueText({ diff --git a/src/features/tracing/components/LiveTracingPage.tsx b/src/features/tracing/components/LiveTracingPage.tsx index 632d183..8830881 100644 --- a/src/features/tracing/components/LiveTracingPage.tsx +++ b/src/features/tracing/components/LiveTracingPage.tsx @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; @@ -15,6 +17,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { useSchemaStore } from '@/stores/schemaStore'; import { apiFetch, getApiBaseUrl } from '@/services/api'; import type { TraceEvent } from '../types'; +import { normalizeTraceEvents } from '../normalize'; import { TraceTimeline } from './TraceTimeline'; import { liveEventSubject } from '@/features/ai/explain/explain'; @@ -88,7 +91,7 @@ export function LiveTracingPage() { es.addEventListener('trace', (event) => { try { - const batch = JSON.parse(event.data) as TraceEvent[]; + const batch = normalizeTraceEvents(JSON.parse(event.data)); const currentEvents = eventsRef.current; for (const evt of batch) { diff --git a/src/features/tracing/components/TraceDetailView.tsx b/src/features/tracing/components/TraceDetailView.tsx index 089edda..954447d 100644 --- a/src/features/tracing/components/TraceDetailView.tsx +++ b/src/features/tracing/components/TraceDetailView.tsx @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import { useEffect, useState } from 'react'; @@ -13,41 +15,10 @@ import { Card, CardContent } from '@/components/ui/card'; import { useSchemaStore } from '@/stores/schemaStore'; import { jmapGet, getAccountId } from '@/services/jmap/client'; import { resolveObject } from '@/lib/schemaResolver'; -import type { TraceEvent, TraceKeyValue, TraceValue } from '../types'; +import type { TraceEvent } from '../types'; +import { normalizeTraceEvents } from '../normalize'; import { TraceTimeline } from './TraceTimeline'; -import { jmapMapToArray } from '@/lib/jmapUtils'; - -function normalizeTraceEvents(raw: unknown): TraceEvent[] { - const events = jmapMapToArray>(raw); - return events.map((evt) => ({ - event: String(evt.event ?? ''), - timestamp: String(evt.timestamp ?? ''), - keyValues: normalizeKeyValues(evt.keyValues), - })); -} - -function normalizeKeyValues(raw: unknown): TraceKeyValue[] { - const kvs = jmapMapToArray>(raw); - return kvs.map((kv) => ({ - key: String(kv.key ?? ''), - value: normalizeTraceValue(kv.value), - })); -} - -function normalizeTraceValue(raw: unknown): TraceValue { - if (!raw || typeof raw !== 'object') return { '@type': 'Null' }; - const obj = raw as Record; - const type = obj['@type'] as string; - - if (type === 'List') { - return { '@type': 'List', value: jmapMapToArray(obj.value).map(normalizeTraceValue) }; - } - if (type === 'Event') { - return { '@type': 'Event', event: String(obj.event ?? ''), value: normalizeKeyValues(obj.value) }; - } - return raw as TraceValue; -} interface TraceDetailViewProps { viewName: string; diff --git a/src/features/tracing/components/TraceTimeline.tsx b/src/features/tracing/components/TraceTimeline.tsx index 5e540c4..0f45a1c 100644 --- a/src/features/tracing/components/TraceTimeline.tsx +++ b/src/features/tracing/components/TraceTimeline.tsx @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import { useEffect, useRef } from 'react'; diff --git a/src/features/tracing/normalize.ts b/src/features/tracing/normalize.ts new file mode 100644 index 0000000..2807b52 --- /dev/null +++ b/src/features/tracing/normalize.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +import { jmapMapToArray } from '@/lib/jmapUtils'; +import type { TraceEvent, TraceKeyValue, TraceValue } from './types'; + +/** + * Trace events as the server sends them, stored or live: key-values arrive as + * a JMAP map ({"0": …, "1": …}), not an array. + */ +export function normalizeTraceEvents(raw: unknown): TraceEvent[] { + const events = jmapMapToArray>(raw); + return events.map((evt) => ({ + event: String(evt.event ?? ''), + timestamp: String(evt.timestamp ?? ''), + keyValues: normalizeKeyValues(evt.keyValues), + })); +} + +function normalizeKeyValues(raw: unknown): TraceKeyValue[] { + const kvs = jmapMapToArray>(raw); + return kvs.map((kv) => ({ + key: String(kv.key ?? ''), + value: normalizeTraceValue(kv.value), + })); +} + +function normalizeTraceValue(raw: unknown): TraceValue { + if (!raw || typeof raw !== 'object') return { '@type': 'Null' }; + const obj = raw as Record; + const type = obj['@type'] as string; + + if (type === 'List') { + return { '@type': 'List', value: jmapMapToArray(obj.value).map(normalizeTraceValue) }; + } + if (type === 'Event') { + return { '@type': 'Event', event: String(obj.event ?? ''), value: normalizeKeyValues(obj.value) }; + } + return raw as TraceValue; +} -- 2.54.0