diff --git a/.cursor/conduit-backend/docker-compose.yml b/.cursor/conduit-backend/docker-compose.yml
index e7bf83366..6d2081e09 100644
--- a/.cursor/conduit-backend/docker-compose.yml
+++ b/.cursor/conduit-backend/docker-compose.yml
@@ -21,8 +21,32 @@ services:
MONGO_INITDB_DATABASE: 'conduit'
MONGO_INITDB_ROOT_USERNAME: 'conduit'
MONGO_INITDB_ROOT_PASSWORD: 'pass'
+ entrypoint:
+ - bash
+ - -c
+ - |
+ cp /mongo-keyfile /tmp/keyfile
+ chmod 400 /tmp/keyfile
+ chown mongodb:mongodb /tmp/keyfile
+ exec docker-entrypoint.sh mongod --replSet rs0 --bind_ip_all --keyFile /tmp/keyfile
volumes:
- mongo:/data/db
+ - ./mongo/keyfile:/mongo-keyfile:ro
+
+ mongo-init-replica:
+ container_name: 'conduit-mongo-init'
+ image: 'docker.io/library/mongo:4.4.15'
+ restart: on-failure
+ network_mode: 'host'
+ depends_on:
+ - mongodb
+ environment:
+ MONGO_HOST: '127.0.0.1'
+ MONGO_INITDB_ROOT_USERNAME: 'conduit'
+ MONGO_INITDB_ROOT_PASSWORD: 'pass'
+ volumes:
+ - ./mongo/init-replica.sh:/init-replica.sh:ro
+ command: ['bash', '/init-replica.sh']
redis:
container_name: 'conduit-redis'
@@ -47,7 +71,7 @@ services:
CLIENT_HTTP_PORT: '3000'
CLIENT_SOCKET_PORT: '3001'
__DEFAULT_HOST_URL: '${ADMIN_DEFAULT_HOST_URL:-http://localhost:3030}'
- DB_CONN_URI: 'mongodb://conduit:pass@127.0.0.1:27017/conduit?authSource=admin'
+ DB_CONN_URI: 'mongodb://conduit:pass@127.0.0.1:27017/conduit?authSource=admin&replicaSet=rs0'
volumes:
mongo:
diff --git a/.cursor/conduit-backend/mongo/init-replica.sh b/.cursor/conduit-backend/mongo/init-replica.sh
new file mode 100644
index 000000000..e4bb536d2
--- /dev/null
+++ b/.cursor/conduit-backend/mongo/init-replica.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+set -euo pipefail
+HOST="${MONGO_HOST:-conduit-mongo}"
+USER="${MONGO_INITDB_ROOT_USERNAME:-conduit}"
+PASS="${MONGO_INITDB_ROOT_PASSWORD:-pass}"
+
+until mongo --host "$HOST" -u "$USER" -p "$PASS" --authenticationDatabase admin --quiet --eval 'db.adminCommand({ ping: 1 })' >/dev/null 2>&1; do
+ sleep 2
+done
+
+mongo --host "$HOST" -u "$USER" -p "$PASS" --authenticationDatabase admin --quiet --eval '
+ try {
+ rs.status();
+ } catch (err) {
+ rs.initiate({
+ _id: "rs0",
+ members: [{ _id: 0, host: "'"$HOST"':27017" }]
+ });
+ }
+'
diff --git a/.cursor/conduit-backend/mongo/keyfile b/.cursor/conduit-backend/mongo/keyfile
new file mode 100644
index 000000000..8d7ffaf78
--- /dev/null
+++ b/.cursor/conduit-backend/mongo/keyfile
@@ -0,0 +1 @@
+conduit-dev-mongo-replica-keyfile-do-not-use-in-production-replace-me-before-any-real-deployment-0123456789abcdefghijklmnopqrstuvwxyz
diff --git a/apps/.DS_Store b/apps/.DS_Store
deleted file mode 100644
index 7d5804ed1..000000000
Binary files a/apps/.DS_Store and /dev/null differ
diff --git a/package.json b/package.json
index e5f90b63c..f970c6e73 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,8 @@
"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/preview.test.ts",
+ "test:realtime": "node --experimental-strip-types --test src/lib/realtime/adminSocketUrl.test.ts src/lib/realtime/change-events.test.ts src/lib/realtime/connection-snippets.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",
@@ -80,6 +82,7 @@
"recharts": "^3.8.1",
"rehype-prism-plus": "^2.0.2",
"server-only": "^0.0.1",
+ "socket.io-client": "^4.8.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.2.2",
"tailwindcss-animate": "^1.0.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 44a6c2ade..6f27a7453 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -198,6 +198,9 @@ importers:
server-only:
specifier: ^0.0.1
version: 0.0.1
+ socket.io-client:
+ specifier: ^4.8.3
+ version: 4.8.3
tailwind-merge:
specifier: ^3.4.0
version: 3.5.0
@@ -4603,6 +4606,12 @@ packages:
integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==,
}
+ engine.io-client@6.6.6:
+ resolution:
+ {
+ integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==,
+ }
+
engine.io-parser@5.2.3:
resolution:
{
@@ -8119,6 +8128,13 @@ packages:
integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==,
}
+ socket.io-client@4.8.3:
+ resolution:
+ {
+ integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==,
+ }
+ engines: { node: '>=10.0.0' }
+
socket.io-parser@4.2.6:
resolution:
{
@@ -8971,6 +8987,21 @@ packages:
utf-8-validate:
optional: true
+ ws@8.21.3:
+ resolution:
+ {
+ integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==,
+ }
+ engines: { node: '>=10.0.0' }
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
wsl-utils@0.1.0:
resolution:
{
@@ -8978,6 +9009,13 @@ packages:
}
engines: { node: '>=18' }
+ xmlhttprequest-ssl@2.1.2:
+ resolution:
+ {
+ integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==,
+ }
+ engines: { node: '>=0.4.0' }
+
xtend@4.0.2:
resolution:
{
@@ -11989,6 +12027,18 @@ snapshots:
emoji-regex@9.2.2: {}
+ engine.io-client@6.6.6:
+ dependencies:
+ '@socket.io/component-emitter': 3.1.2
+ debug: 4.4.3
+ engine.io-parser: 5.2.3
+ ws: 8.21.3
+ xmlhttprequest-ssl: 2.1.2
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
engine.io-parser@5.2.3: {}
engine.io@6.6.6:
@@ -14371,6 +14421,17 @@ snapshots:
- supports-color
- utf-8-validate
+ socket.io-client@4.8.3:
+ dependencies:
+ '@socket.io/component-emitter': 3.1.2
+ debug: 4.4.3
+ engine.io-client: 6.6.6
+ socket.io-parser: 4.2.6
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
socket.io-parser@4.2.6:
dependencies:
'@socket.io/component-emitter': 3.1.2
@@ -14936,10 +14997,14 @@ snapshots:
ws@8.20.0: {}
+ ws@8.21.3: {}
+
wsl-utils@0.1.0:
dependencies:
is-wsl: 3.1.0
+ xmlhttprequest-ssl@2.1.2: {}
+
xtend@4.0.2: {}
y18n@5.0.8: {}
diff --git a/src/app/(dashboard)/(modules)/database/settings/page.tsx b/src/app/(dashboard)/(modules)/database/settings/page.tsx
index 20308cca0..7b8b50c19 100644
--- a/src/app/(dashboard)/(modules)/database/settings/page.tsx
+++ b/src/app/(dashboard)/(modules)/database/settings/page.tsx
@@ -1,14 +1,19 @@
import { Settings } from '@/components/database/settings/settings';
-import { getDatabaseSettings, getDatabaseType } from '@/lib/api/database';
+import {
+ getDatabaseRealtimeStatus,
+ getDatabaseSettings,
+ getDatabaseType,
+} from '@/lib/api/database';
export default async function DatabaseSettingsPage() {
- const [settingsResult, typeResult] = await Promise.allSettled([
- getDatabaseSettings(),
- getDatabaseType(),
- ]);
+ const [settingsResult, typeResult, realtimeResult] = await Promise.allSettled(
+ [getDatabaseSettings(), getDatabaseType(), getDatabaseRealtimeStatus()]
+ );
const databaseType =
typeResult.status === 'fulfilled' ? typeResult.value.result : 'Unknown';
+ const realtimeStatus =
+ realtimeResult.status === 'fulfilled' ? realtimeResult.value : null;
if (settingsResult.status === 'rejected') {
return (
@@ -25,6 +30,10 @@ export default async function DatabaseSettingsPage() {
}
return (
-
+
);
}
diff --git a/src/app/(dashboard)/(modules)/router/event-relays/page.tsx b/src/app/(dashboard)/(modules)/router/event-relays/page.tsx
new file mode 100644
index 000000000..509f7094a
--- /dev/null
+++ b/src/app/(dashboard)/(modules)/router/event-relays/page.tsx
@@ -0,0 +1,32 @@
+import { getEventRelays, getRouterSettings } from '@/lib/api/router';
+import { EventRelayList } from '@/components/router/event-relays/event-relay-list';
+
+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 [{ relays, count }, { config }] = await Promise.all([
+ getEventRelays({
+ skip,
+ limit,
+ search: searchParams.search,
+ }),
+ getRouterSettings(),
+ ]);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/(modules)/router/page.tsx b/src/app/(dashboard)/(modules)/router/page.tsx
index 64eaa8897..11e104ea1 100644
--- a/src/app/(dashboard)/(modules)/router/page.tsx
+++ b/src/app/(dashboard)/(modules)/router/page.tsx
@@ -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,
@@ -66,6 +73,12 @@ export default async function RouterDashboard() {
icon: ,
href: '/router/vizualize',
},
+ {
+ title: 'Event Relays',
+ description: 'Forward bus events to socket subscribers',
+ icon: ,
+ href: '/router/event-relays',
+ },
{
title: 'Settings',
description: 'Router module configuration',
diff --git a/src/components/database/models/data-explorer/index.tsx b/src/components/database/models/data-explorer/index.tsx
index f87754449..59ceb830d 100644
--- a/src/components/database/models/data-explorer/index.tsx
+++ b/src/components/database/models/data-explorer/index.tsx
@@ -31,6 +31,8 @@ import {
import { Badge } from '@/components/ui/badge';
import { toast } from '@/lib/hooks/use-toast';
import { deleteSchemaDocument } from '@/lib/api/database';
+import { useDatabaseLiveUpdates } from '@/lib/hooks/use-database-live-updates';
+import { LiveUpdatesStatus } from './live-updates-status';
import {
analyzeModelDataPermissions,
getExtensionFieldNames,
@@ -74,6 +76,11 @@ export function DataExplorer({ schema, documents }: DataExplorerProps) {
() => analyzeModelDataPermissions(schema),
[schema]
);
+ const liveEnabled = schema.modelOptions?.conduit?.realtime?.enabled === true;
+ const liveUpdates = useDatabaseLiveUpdates({
+ schemaName: schema.name,
+ enabled: liveEnabled,
+ });
// Get schema fields for column definitions
const schemaFields = React.useMemo(() => {
@@ -160,6 +167,7 @@ export function DataExplorer({ schema, documents }: DataExplorerProps) {
};
const handleRefresh = () => {
+ liveUpdates.consumePendingUpdates();
router.refresh();
toast({ title: 'Data refreshed' });
};
@@ -185,6 +193,7 @@ export function DataExplorer({ schema, documents }: DataExplorerProps) {
const handleDocumentSaved = () => {
setSelectedDocument(null);
setIsCreatingNew(false);
+ liveUpdates.consumePendingUpdates();
router.refresh();
};
@@ -372,9 +381,22 @@ export function DataExplorer({ schema, documents }: DataExplorerProps) {
+
+
{/* Refresh */}
-