Pick webhook and tracer events by group, with common picks #48
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" }
|
||||
}
|
||||
]
|
||||
}`;
|
||||
Reference in new issue
Block a user