Pick webhook and tracer events by group, with common picks #48

Merged
jcoffey-dev merged 1 commits from feature/webhook-events into main 2026-09-28 22:12:14 +00:00
6 changed files with 417 additions and 0 deletions

No files matched your search

+12
View File
@@ -30,6 +30,7 @@ import { Calendar } from '@/components/ui/calendar';
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ExpressionEditor } from '@/components/expression/ExpressionEditor';
import { EventPicker } from '@/features/events/EventPicker';
import type { ExpressionHints } from '@/components/expression/simple';
import { OtpAuthField } from '@/components/forms/OtpAuthField';
import { SievepadButton } from '@/components/forms/SievepadButton';
@@ -1684,6 +1685,17 @@ function SetField({ scalarType, value, onChange, readOnly, schema, minItems, max
/>
);
case 'enum':
// inbuxa: 649 event types get a grouped picker with common picks (settings-reorg).
if (scalarType.enumName === 'EventType') {
return (
<EventPicker
variants={schema.enums.EventType ?? []}
items={items}
onChange={(newItems) => onChange(arrayToSetObject(newItems))}
readOnly={readOnly}
/>
);
}
return (
<EnumMultiSelect
enumName={scalarType.enumName}
+4
View File
@@ -17,6 +17,7 @@ import { DynamicForm } from '@/components/forms/DynamicForm';
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner';
import { WebhooksPage } from '@/features/events/WebhooksPage';
import { ListenersPage } from '@/features/ports/ListenersPage';
import { MetricsPage } from '@/features/metricsConnect/MetricsPage';
import { AlertsPage } from '@/features/alerts/AlertsPage';
@@ -371,5 +372,8 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (viewName === 'x:NetworkListener') {
return <ListenersPage viewName={viewName} />;
}
if (viewName === 'x:WebHook') {
return <WebhooksPage viewName={viewName} />;
}
return <DynamicList viewName={viewName} />;
}
+159
View File
@@ -0,0 +1,159 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: choosing events out of 649, grouped by category with a switch for
* the whole group, a search that reads the explanations too, and a few
* common picks (settings-reorg, second wave). Used wherever a form asks for
* a set of EventType: webhooks and tracers.
*/
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronRight, Search } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import type { EnumVariant } from '@/types/schema';
import { cn } from '@/lib/utils';
import { COMMON_PICKS, groupEvents } from './eventGroups';
export function EventPicker({
variants,
items,
onChange,
readOnly,
}: {
variants: EnumVariant[];
items: string[];
onChange: (items: string[]) => void;
readOnly: boolean;
}) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [open, setOpen] = useState<Set<string>>(new Set());
const chosen = useMemo(() => new Set(items), [items]);
const known = useMemo(() => new Set(variants.map((v) => v.name)), [variants]);
const groups = useMemo(() => groupEvents(variants, search), [variants, search]);
const set = (names: string[], on: boolean) => {
const next = new Set(chosen);
for (const n of names) {
if (on) next.add(n);
else next.delete(n);
}
onChange([...next]);
};
return (
<div className="space-y-3 rounded-lg border p-3">
<div className="space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('eventPicker.common', 'Common picks')}
</p>
<div className="flex flex-wrap gap-2">
{COMMON_PICKS.map((p) => {
const names = p.events.filter((e) => known.has(e));
const all = names.length > 0 && names.every((n) => chosen.has(n));
return (
<button
key={p.id}
type="button"
title={p.hint}
disabled={readOnly}
onClick={() => set(names, !all)}
className={cn(
'rounded-full border px-3 py-1 text-xs transition-colors',
all ? 'border-primary bg-primary/10 text-primary' : 'hover:border-primary/60',
)}
>
{p.name}
</button>
);
})}
</div>
</div>
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('eventPicker.search', 'Search events, e.g. bounce, ban, certificate')}
className="h-8"
/>
<span className="shrink-0 text-xs text-muted-foreground">
{t('eventPicker.chosen', '{{count}} chosen', { count: items.length })}
</span>
</div>
<ul className="max-h-96 divide-y overflow-y-auto">
{groups.map((g) => {
const names = g.events.map((e) => e.name);
const count = names.filter((n) => chosen.has(n)).length;
const expanded = open.has(g.category) || search.trim() !== '';
return (
<li key={g.category} className="py-1.5">
<div className="flex items-center gap-2">
<Checkbox
checked={count === 0 ? false : count === names.length ? true : 'indeterminate'}
disabled={readOnly}
onCheckedChange={(v) => set(names, v === true)}
aria-label={g.name}
/>
<button
type="button"
className="flex flex-1 items-center gap-1 text-left text-sm"
onClick={() =>
setOpen((s) => {
const next = new Set(s);
if (next.has(g.category)) next.delete(g.category);
else next.add(g.category);
return next;
})
}
>
<ChevronRight className={cn('h-3.5 w-3.5 transition-transform', expanded && 'rotate-90')} />
<span className="font-medium">{g.name}</span>
<span className="text-xs text-muted-foreground">
{count > 0 ? `${count}/${names.length}` : names.length}
</span>
</button>
</div>
{expanded && (
<ul className="ml-8 mt-1 space-y-1">
{g.events.map((e) => (
<li key={e.name}>
<label className="flex cursor-pointer items-start gap-2 text-sm">
<Checkbox
checked={chosen.has(e.name)}
disabled={readOnly}
onCheckedChange={(v) => set([e.name], v === true)}
className="mt-0.5"
/>
<span>
{e.label}
<span className="ml-1 font-mono text-[11px] text-muted-foreground">{e.name}</span>
{e.explanation && (
<span className="block text-xs text-muted-foreground">{e.explanation}</span>
)}
</span>
</label>
</li>
))}
</ul>
)}
</li>
);
})}
{groups.length === 0 && (
<li className="py-3 text-center text-sm text-muted-foreground">
{t('eventPicker.none', 'No events match.')}
</li>
)}
</ul>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: Monitoring › Webhooks, with what the server sends above the list
* (settings-reorg, second wave): the body's shape, how the signature works,
* and a way to see one arrive.
*/
import { useTranslation } from 'react-i18next';
import { Webhook } from 'lucide-react';
import { DynamicList } from '@/components/lists/DynamicList';
import { EXAMPLE_BODY } from './eventGroups';
export function WebhooksPage({ viewName }: { viewName: string }) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<div className="space-y-3 rounded-xl border bg-card p-4">
<div className="flex items-center gap-3">
<Webhook className="h-5 w-5 text-primary" />
<p className="font-medium">{t('webhooks.title', 'What a webhook receives')}</p>
</div>
<p className="text-sm text-muted-foreground">
{t(
'webhooks.body',
'A POST with a JSON body holding one or more of the events you chose, batched at most once per throttle interval:',
)}
</p>
<pre className="overflow-x-auto rounded-lg border bg-muted/40 p-3 font-mono text-xs">{EXAMPLE_BODY}</pre>
<p className="text-sm text-muted-foreground">
{t(
'webhooks.signature',
'With a signature key set, each request carries X-Signature: the HMAC-SHA256 of the raw body under that key, in base64. Compute it on your side and compare before trusting the request.',
)}
</p>
<p className="text-sm text-muted-foreground">
{t(
'webhooks.try',
'To see one arrive, choose “Failed sign-ins and bans” and sign in once with a wrong password. Failed deliveries are retried until “Discard after”.',
)}
</p>
</div>
<DynamicList viewName={viewName} />
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect } from 'vitest';
import { categoryName, groupEvents } from './eventGroups';
const variants = [
{ name: 'auth.failed', label: 'Authentication failed', explanation: 'A wrong password.' },
{ name: 'auth.success', label: 'Authentication succeeded' },
{ name: 'delivery.delivered', label: 'Message delivered' },
{ name: 'security.ip-blocked', label: 'Blocked IP address', explanation: 'A ban.' },
];
describe('event groups', () => {
it('group by category, named in words', () => {
const g = groupEvents(variants);
expect(g.map((x) => x.name)).toEqual(['Outgoing delivery', 'Security', 'Sign-in']);
expect(g.find((x) => x.category === 'auth')?.events).toHaveLength(2);
expect(categoryName('unknown-cat')).toBe('unknown-cat');
});
it('search names, labels, explanations and category names', () => {
expect(groupEvents(variants, 'ban').flatMap((g) => g.events.map((e) => e.name))).toEqual(['security.ip-blocked']);
expect(groupEvents(variants, 'wrong password').flatMap((g) => g.events.map((e) => e.name))).toEqual([
'auth.failed',
]);
expect(groupEvents(variants, 'sign-in').flatMap((g) => g.events)).toHaveLength(2);
});
});
+160
View File
@@ -0,0 +1,160 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: the server's 649 event types, grouped for picking (settings-reorg,
* second wave). Names are "category.event"; the category is the group.
*/
import type { EnumVariant } from '@/types/schema';
const CATEGORY_NAMES: Record<string, string> = {
acme: 'Certificates (ACME)',
ai: 'AI',
arc: 'ARC',
auth: 'Sign-in',
calendar: 'Calendar',
cluster: 'Cluster',
dane: 'DANE',
delivery: 'Outgoing delivery',
dkim: 'DKIM',
dmarc: 'DMARC',
dns: 'DNS',
eval: 'Expressions',
http: 'HTTP',
imap: 'IMAP',
'incoming-report': 'Reports received',
iprev: 'Reverse DNS',
jmap: 'JMAP',
limit: 'Limits',
'mail-auth': 'Mail authentication',
'manage-sieve': 'ManageSieve',
'message-ingest': 'Incoming messages',
milter: 'Milters',
'mta-hook': 'MTA hooks',
'mta-sts': 'MTA-STS',
network: 'Network',
'outgoing-report': 'Reports sent',
pop3: 'POP3',
'push-subscription': 'Push',
queue: 'Queue',
registry: 'Settings',
resource: 'Resources',
scim: 'SCIM',
security: 'Security',
server: 'Server',
sieve: 'Sieve',
smtp: 'SMTP',
spam: 'Spam filter',
spf: 'SPF',
store: 'Storage',
'task-manager': 'Tasks',
telemetry: 'Telemetry and alerts',
tls: 'TLS',
'tls-rpt': 'TLS reporting',
'web-dav': 'WebDAV',
};
export function categoryOf(event: string): string {
return event.split('.')[0];
}
export function categoryName(category: string): string {
return CATEGORY_NAMES[category] ?? category;
}
export interface EventGroup {
category: string;
name: string;
events: EnumVariant[];
}
/** Events grouped by category, groups in a readable order, optionally filtered by a search. */
export function groupEvents(variants: EnumVariant[], search = ''): EventGroup[] {
const q = search.trim().toLowerCase();
const groups = new Map<string, EnumVariant[]>();
for (const v of variants) {
if (
q &&
!v.name.toLowerCase().includes(q) &&
!v.label.toLowerCase().includes(q) &&
!(v.explanation ?? '').toLowerCase().includes(q) &&
!categoryName(categoryOf(v.name)).toLowerCase().includes(q)
) {
continue;
}
const c = categoryOf(v.name);
groups.set(c, [...(groups.get(c) ?? []), v]);
}
return [...groups.entries()]
.map(([category, events]) => ({ category, name: categoryName(category), events }))
.sort((a, b) => a.name.localeCompare(b.name));
}
export interface CommonPick {
id: string;
name: string;
hint: string;
events: string[];
}
export const COMMON_PICKS: CommonPick[] = [
{
id: 'delivery',
name: 'Delivery results',
hint: 'A message was delivered, delayed or bounced.',
events: [
'delivery.delivered',
'delivery.dsn-success',
'delivery.dsn-temp-fail',
'delivery.dsn-perm-fail',
'delivery.double-bounce',
],
},
{
id: 'security',
name: 'Failed sign-ins and bans',
hint: 'Wrong passwords, and addresses the server blocked.',
events: [
'auth.failed',
'auth.too-many-attempts',
'security.authentication-ban',
'security.abuse-ban',
'security.scan-ban',
'security.ip-blocked',
],
},
{
id: 'alerts',
name: 'Alerts',
hint: 'An alert fired (Monitoring › Alerts), as an event or an email.',
events: ['telemetry.alert-event', 'telemetry.alert-message'],
},
{
id: 'spam',
name: 'Spam verdicts',
hint: 'An incoming message was filed as spam or as not spam.',
events: ['message-ingest.spam', 'message-ingest.ham'],
},
{
id: 'certificates',
name: 'Certificates',
hint: 'A certificate was issued, or ordering one failed.',
events: ['acme.order-completed', 'acme.order-invalid', 'acme.error'],
},
];
/** The shape of what the server posts, for the example (trc/src/serializers/json.rs). */
export const EXAMPLE_BODY = `{
"events": [
{
"id": "17906227430001234",
"createdAt": "2026-09-28T20:15:24Z",
"type": "auth.failed",
"data": { "…": "the event's own details, which vary by event" }
}
]
}`;