Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"start": "next start -p 8080",
"lint": "eslint .",
"test:semantic-colors": "node --experimental-strip-types --test src/lib/semantic-colors.test.ts src/lib/reactflow-edge-colors.test.ts",
"test:event-relays": "node --experimental-strip-types --test src/lib/event-relays/path.test.ts src/lib/event-relays/client-snippet.test.ts src/lib/event-relays/preview-remote.test.ts src/lib/event-relays/sample-payload.test.ts src/lib/logic/api-error.test.ts",
"test:schema-fields": "node --experimental-strip-types --test src/lib/database/schema-field-definition.test.ts src/lib/database/system-schema-fields.test.ts",
"build:docker": "docker build --platform linux/amd64 -t ghcr.io/conduitplatform/conduit-ui:latest .",
"prepare": "husky",
Expand Down
76 changes: 76 additions & 0 deletions src/app/(dashboard)/(modules)/router/event-relays/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { getEventRelays, getRouterSettings } from '@/lib/api/router';
import { EventRelayList } from '@/components/router/event-relays/event-relay-list';
import {
PageDescription,
PageHeader,
PageTitle,
} from '@/components/ui/page-header';
import { EmptyState } from '@/components/ui/empty-state';
import { Radio } from 'lucide-react';
import { isAxiosNotFoundError } from '@/lib/logic/api-error';

export default async function EventRelaysPage(props: {
searchParams: Promise<{
skip?: string;
limit?: string;
search?: string;
}>;
}) {
const searchParams = await props.searchParams;
const skip = Number(searchParams.skip ?? 0);
const limit = Number(searchParams.limit ?? 10);

const [relaysResult, settingsResult] = await Promise.allSettled([
getEventRelays({
skip,
limit,
search: searchParams.search,
}),
getRouterSettings(),
]);

if (
relaysResult.status === 'rejected' &&
isAxiosNotFoundError(relaysResult.reason)
) {
return (
<div className="p-6">
<PageHeader>
<div>
<PageTitle>Event Relays</PageTitle>
<PageDescription>
Forward exact bus events to ReBAC-scoped socket subscribers.
</PageDescription>
</div>
</PageHeader>
<div className="mt-6">
<EmptyState
icon={Radio}
title="Event Relays are not available"
description="This Router does not expose /router/event-relays yet. Upgrade to a build that includes the event relays Admin API (Conduit PR #1600), then reload this page."
/>
</div>
</div>
);
}

if (relaysResult.status === 'rejected') {
throw relaysResult.reason;
}

const { relays, count } = relaysResult.value;
const socketsEnabled =
settingsResult.status === 'fulfilled'
? settingsResult.value.config.transports.sockets
: undefined;

return (
<div className="p-6">
<EventRelayList
relays={relays}
count={count}
socketsEnabled={socketsEnabled}
/>
</div>
);
}
15 changes: 14 additions & 1 deletion src/app/(dashboard)/(modules)/router/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import React from 'react';
import { BarChart3, Network, Route, Settings, Shield } from 'lucide-react';
import {
BarChart3,
Network,
Radio,
Route,
Settings,
Shield,
} from 'lucide-react';
import { ModuleDashboard } from '@/components/dashboard/ModuleDashboard';
import {
getModuleStatus,
Expand Down Expand Up @@ -66,6 +73,12 @@ export default async function RouterDashboard() {
icon: <Network className="h-4 w-4" />,
href: '/router/vizualize',
},
{
title: 'Event Relays',
description: 'Forward bus events to socket subscribers',
icon: <Radio className="h-4 w-4" />,
href: '/router/event-relays',
},
{
title: 'Settings',
description: 'Router module configuration',
Expand Down
1 change: 1 addition & 0 deletions src/components/navigation/navList.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export const navGroups: NavGroup[] = [
items: [
{ title: 'Visualize', url: '/router/vizualize' },
{ title: 'Security', url: '/router/security' },
{ title: 'Event Relays', url: '/router/event-relays' },
{ title: 'Settings', url: '/router/settings' },
],
},
Expand Down
267 changes: 267 additions & 0 deletions src/components/router/event-relays/event-relay-docs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
'use client';

import { ChevronDown } from 'lucide-react';
import { Card } from '@/components/ui/card';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { EVENT_RELAY_DOCS_SNIPPET } from '@/lib/event-relays/client-snippet';

const STEPS = [
{
title: 'Bus event',
body: 'A module publishes JSON on an exact Redis channel — for example database realtime or a custom module.',
},
{
title: 'Active relay',
body: 'The matching relay reads the resource id from the payload and renders the message template.',
},
{
title: 'ReBAC subscribe',
body: 'Clients never pick a room name. Subscribe succeeds only if the user has the relay permission on that resource.',
},
{
title: 'Socket emit',
body: 'Router emits socketEvent to the hashed /events/ room. Delivery is ephemeral — missed events are gone.',
},
] as const;

const SCOPE = [
{
title: 'Use when',
body: 'You already publish JSON on an exact Redis bus channel and need live, per-resource UI updates with a ReBAC check.',
},
{
title: 'Skip when',
body: 'You need replay, history, guaranteed delivery, wildcard channels, or a broadcast with no permission check. Relays are not a queue.',
},
{
title: 'Requires',
body: 'Router sockets enabled, the Authorization module available, and something publishing on that exact channel.',
},
] as const;

interface EventRelayDocsProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}

export function EventRelayDocs({ open, onOpenChange }: EventRelayDocsProps) {
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<Card id="event-relay-docs" className="overflow-hidden">
<CollapsibleTrigger
className={cn(
'flex min-h-10 w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left',
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'[&[data-state=open]>svg]:rotate-180'
)}
>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
How Event Relays work
</span>
<span className="mt-0.5 block text-sm text-pretty text-muted-foreground">
Forward an exact bus event to permission-scoped socket
subscribers. Subscribe-only, ephemeral, and not a generic
websocket broadcast.
</span>
</span>
<ChevronDown
aria-hidden
className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
/>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
<div className="space-y-6 border-t border-border/60 px-4 py-4">
<section>
<h3 className="text-sm font-medium text-foreground">Scope</h3>
<div className="mt-3 grid gap-3 md:grid-cols-3">
{SCOPE.map(item => (
<div
key={item.title}
className="rounded-md border border-border/60 bg-muted/30 p-3"
>
<p className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
{item.title}
</p>
<p className="mt-1.5 text-sm text-pretty text-foreground">
{item.body}
</p>
</div>
))}
</div>
</section>

<section>
<h3 className="text-sm font-medium text-foreground">
How a message moves
</h3>
<ol className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{STEPS.map((step, index) => (
<li key={step.title} className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border bg-background font-mono text-xs tabular-nums text-muted-foreground slashed-zero">
{index + 1}
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
{step.title}
</p>
<p className="mt-1 text-sm text-pretty text-muted-foreground">
{step.body}
</p>
</div>
</li>
))}
</ol>
</section>

<section>
<h3 className="text-sm font-medium text-foreground">
Database realtime example
</h3>
<p className="mt-1 text-sm text-pretty text-muted-foreground">
Notify clients when an Order document changes via{' '}
<Code>database:change:Order</Code>. Database realtime payloads
expose <Code>documentId</Code> (not Mongo <Code>_id</Code> on
the wire).
</p>
<dl className="mt-3 divide-y divide-border/60 rounded-md border border-border/60">
<Field
name="busEvent"
value="database:change:Order"
hint="Exact Redis channel. Wildcards are rejected."
/>
<Field
name="socketEvent"
value="order-updated"
hint="Name emitted on /events/. Reserved names like subscribe are blocked."
/>
<Field
name="resourceType / permission"
value="Order / read"
hint="ReBAC check on subscribe: User can read Order:{id}."
/>
<Field
name="resourceIdPath"
value="documentId"
hint="Dot path into the bus JSON from Database realtime."
/>
<Field
name="messageTemplate"
value={'{ "id": "{{payload.documentId}}" }'}
hint="JSON with {{payload.path}} placeholders against the bus payload."
/>
</dl>
</section>

<section>
<h3 className="text-sm font-medium text-foreground">
CRUD bus channel (advanced)
</h3>
<p className="mt-1 text-sm text-pretty text-muted-foreground">
You can relay <Code>database:update:Order</Code> instead, but
the payload is the full document (including{' '}
<Code>_id</Code>). That duplicates what clients already get on{' '}
<Code>/database/</Code> <Code>change</Code> — prefer the
database realtime channel unless you only consume{' '}
<Code>/events/</Code>.
</p>
<dl className="mt-3 divide-y divide-border/60 rounded-md border border-border/60">
<Field
name="busEvent"
value="database:update:Order"
hint="Exact CRUD bus channel; large payloads."
/>
<Field
name="resourceIdPath"
value="_id"
hint="Mongo id on the full document payload."
/>
<Field
name="messageTemplate"
value={'{ "id": "{{payload._id}}" }'}
hint="Same template language; mind payload size and duplication."
/>
</dl>
</section>

<section>
<h3 className="text-sm font-medium text-foreground">
Subscribe from a client
</h3>
<p className="mt-1 text-sm text-pretty text-muted-foreground">
Connect to <Code>{'/events/'}</Code> with{' '}
<Code>{'path: /realtime'}</Code> and{' '}
<Code>{'auth: { token: accessToken }'}</Code>. Re-subscribe
inside <Code>connect</Code> so reconnects re-join the room.
There is no replay — missed events are lost.
</p>
<pre className="mt-3 overflow-x-auto rounded-md bg-muted p-3 font-mono text-[11px] leading-5 text-foreground slashed-zero">
{EVENT_RELAY_DOCS_SNIPPET}
</pre>
</section>

<section>
<h3 className="text-sm font-medium text-foreground">Limits</h3>
<ul className="mt-3 list-disc space-y-1.5 pl-5 text-sm text-pretty text-muted-foreground">
<li>
Bus channels must match exactly. Patterns like{' '}
<Code>{'database:change:*'}</Code> are not supported.
</li>
<li>
Subscribe-only: clients do not publish on{' '}
<Code>/events/</Code>. Modules write to the bus.
</li>
<li>
Subscribe fails closed if Authorization is unavailable or the
user lacks permission.
</li>
<li>
No replay or ordering guarantee. Delivery is ephemeral.
</li>
<li>
Turn a relay off with Active to stop forwarding and evict
subscribers without deleting the relay.
</li>
</ul>
</section>
</div>
</CollapsibleContent>
</Card>
</Collapsible>
);
}

function Field({
name,
value,
hint,
}: {
name: string;
value: string;
hint: string;
}) {
return (
<div className="grid gap-1 px-3 py-2.5 sm:grid-cols-[minmax(0,11rem)_1fr]">
<dt className="font-mono text-xs slashed-zero text-muted-foreground">
{name}
</dt>
<dd className="min-w-0">
<Code>{value}</Code>
<p className="mt-1 text-sm text-pretty text-muted-foreground">{hint}</p>
</dd>
</div>
);
}

function Code({ children }: { children: string }) {
return (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs slashed-zero">
{children}
</code>
);
}
Loading
Loading