From f45964f75576eca3b05cd063b39879a1d7717ca6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:48:50 +0000 Subject: [PATCH 1/3] Initial plan From 1f835151b236ee52ae70a542eba588fab929dd96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:59:11 +0000 Subject: [PATCH 2/3] Add HTTP API endpoints for admin features in plugins Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/audit/src/plugin.ts | 61 ++++++++++++++ packages/jobs/src/plugin.ts | 95 +++++++++++++++++++++- packages/metrics/src/plugin.ts | 119 ++++++++++++++++++++++++++++ packages/notification/src/plugin.ts | 89 +++++++++++++++++++++ packages/permissions/src/plugin.ts | 82 +++++++++++++++++++ 5 files changed, 445 insertions(+), 1 deletion(-) diff --git a/packages/audit/src/plugin.ts b/packages/audit/src/plugin.ts index 69db0178..98823ed2 100644 --- a/packages/audit/src/plugin.ts +++ b/packages/audit/src/plugin.ts @@ -75,6 +75,67 @@ export class AuditLogPlugin implements Plugin { */ async start(context: PluginContext): Promise { context.logger.info('[Audit Log] Starting...'); + + // Register HTTP routes for Audit API + try { + const httpServer = context.getService('http.server') as any; + const rawApp = httpServer?.getRawApp?.() ?? httpServer?.app; + if (rawApp) { + // GET /api/v1/audit/events - Query audit events + rawApp.get('/api/v1/audit/events', async (c: any) => { + try { + const query = c.req.query(); + const options: AuditQueryOptions = { + objectName: query.objectName, + recordId: query.recordId, + userId: query.userId, + eventType: query.eventType as AuditEventType, + startDate: query.startDate, + endDate: query.endDate, + limit: query.limit ? parseInt(query.limit) : undefined, + offset: query.offset ? parseInt(query.offset) : undefined, + }; + const events = await this.queryEvents(options); + return c.json({ success: true, data: events }); + } catch (error: any) { + context.logger.error('[Audit API] Query error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // GET /api/v1/audit/trail/:objectName/:recordId - Get audit trail + rawApp.get('/api/v1/audit/trail/:objectName/:recordId', async (c: any) => { + try { + const objectName = c.req.param('objectName'); + const recordId = c.req.param('recordId'); + const trail = await this.getAuditTrail(objectName, recordId); + return c.json({ success: true, data: trail }); + } catch (error: any) { + context.logger.error('[Audit API] Trail error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // GET /api/v1/audit/field-history/:objectName/:recordId/:fieldName - Get field history + rawApp.get('/api/v1/audit/field-history/:objectName/:recordId/:fieldName', async (c: any) => { + try { + const objectName = c.req.param('objectName'); + const recordId = c.req.param('recordId'); + const fieldName = c.req.param('fieldName'); + const history = await this.getFieldHistory(objectName, recordId, fieldName); + return c.json({ success: true, data: history }); + } catch (error: any) { + context.logger.error('[Audit API] Field history error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + context.logger.info('[Audit Log] HTTP routes registered'); + } + } catch (e: any) { + context.logger.warn(`[Audit Log] Could not register HTTP routes: ${e?.message}`); + } + context.logger.info('[Audit Log] Started successfully'); } diff --git a/packages/jobs/src/plugin.ts b/packages/jobs/src/plugin.ts index 53e21155..9d9b6b26 100644 --- a/packages/jobs/src/plugin.ts +++ b/packages/jobs/src/plugin.ts @@ -215,7 +215,7 @@ export class JobsPlugin implements Plugin { pluginVersion: this.version, status, uptime: this.startedAt ? Date.now() - this.startedAt : 0, - checks: [{ name: 'job-queue', status, message: stats ? `Queue: ${stats.pending || 0} pending, ${stats.active || 0} active` : 'Job queue active', latency: 0, timestamp: new Date().toISOString() }], + checks: [{ name: 'job-queue', status, message: stats ? `Queue: ${stats.pending || 0} pending, ${stats.running || 0} running` : 'Job queue active', latency: 0, timestamp: new Date().toISOString() }], timestamp: new Date().toISOString(), }; } @@ -248,6 +248,99 @@ export class JobsPlugin implements Plugin { */ async start(context: PluginContext): Promise { this.scheduler.start(); + + // Register HTTP routes for Jobs API + try { + const httpServer = context.getService('http.server') as any; + const rawApp = httpServer?.getRawApp?.() ?? httpServer?.app; + if (rawApp) { + // GET /api/v1/jobs - Query jobs + rawApp.get('/api/v1/jobs', async (c: any) => { + try { + const query = c.req.query(); + const options: JobQueryOptions = { + status: query.status as any, + name: query.name, + limit: query.limit ? parseInt(query.limit) : undefined, + skip: query.skip ? parseInt(query.skip) : undefined, + }; + const jobs = await this.queryJobs(options); + return c.json({ success: true, data: jobs }); + } catch (error: any) { + context.logger.error('[Jobs API] Query error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // GET /api/v1/jobs/stats - Get queue statistics + rawApp.get('/api/v1/jobs/stats', async (c: any) => { + try { + const stats = await this.getStats(); + return c.json({ success: true, data: stats }); + } catch (error: any) { + context.logger.error('[Jobs API] Stats error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // GET /api/v1/jobs/:id - Get job by ID + rawApp.get('/api/v1/jobs/:id', async (c: any) => { + try { + const id = c.req.param('id'); + const job = await this.getJob(id); + if (!job) { + return c.json({ success: false, error: 'Job not found' }, 404); + } + return c.json({ success: true, data: job }); + } catch (error: any) { + context.logger.error('[Jobs API] Get job error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // POST /api/v1/jobs/:id/retry - Retry failed job + rawApp.post('/api/v1/jobs/:id/retry', async (c: any) => { + try { + const id = c.req.param('id'); + const job = await this.getJob(id); + if (!job) { + return c.json({ success: false, error: 'Job not found' }, 404); + } + if (job.status !== 'failed') { + return c.json({ success: false, error: 'Only failed jobs can be retried' }, 400); + } + // Re-enqueue the job + const retriedJob = await this.enqueue({ + id: `${job.id}_retry_${Date.now()}`, + name: job.name, + data: job.data, + priority: job.priority, + }); + return c.json({ success: true, data: retriedJob }); + } catch (error: any) { + context.logger.error('[Jobs API] Retry error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // POST /api/v1/jobs/:id/cancel - Cancel job + rawApp.post('/api/v1/jobs/:id/cancel', async (c: any) => { + try { + const id = c.req.param('id'); + await this.cancel(id); + return c.json({ success: true, message: 'Job cancelled' }); + } catch (error: any) { + context.logger.error('[Jobs API] Cancel error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + context.logger.info('[Jobs Plugin] HTTP routes registered'); + } + } catch (e: any) { + context.logger.warn(`[Jobs Plugin] Could not register HTTP routes: ${e?.message}`); + } + context.logger.info('[Jobs Plugin] Started'); } diff --git a/packages/metrics/src/plugin.ts b/packages/metrics/src/plugin.ts index 6b6faeb4..f9fd8d18 100644 --- a/packages/metrics/src/plugin.ts +++ b/packages/metrics/src/plugin.ts @@ -84,6 +84,125 @@ export class MetricsPlugin implements Plugin { */ async start(context: PluginContext): Promise { context.logger.info('[Metrics] Starting...'); + + // Register HTTP routes for Metrics API + try { + const httpServer = context.getService('http.server') as any; + const rawApp = httpServer?.getRawApp?.() ?? httpServer?.app; + if (rawApp) { + // GET /api/v1/metrics - Get all metrics + rawApp.get('/api/v1/metrics', async (c: any) => { + try { + const metrics = this.getMetrics(); + return c.json({ success: true, data: metrics }); + } catch (error: any) { + context.logger.error('[Metrics API] Get metrics error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // GET /api/v1/metrics/prometheus - Prometheus format export + rawApp.get('/api/v1/metrics/prometheus', async (c: any) => { + try { + const prometheusText = this.exportPrometheus(); + return c.text(prometheusText); + } catch (error: any) { + context.logger.error('[Metrics API] Prometheus export error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // GET /api/v1/metrics/type/:type - Get metrics by type + rawApp.get('/api/v1/metrics/type/:type', async (c: any) => { + try { + const type = c.req.param('type'); + const metrics = this.getMetricsByType(type as any); + return c.json({ success: true, data: metrics }); + } catch (error: any) { + context.logger.error('[Metrics API] Get metrics by type error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // GET /api/v1/admin/plugins - List all loaded plugins with health status + rawApp.get('/api/v1/admin/plugins', async (c: any) => { + try { + // Get all registered services (plugins register themselves as services) + const plugins: any[] = []; + + // Try to get plugin information from the kernel context + // This is a workaround since we don't have direct access to the kernel's plugin list + const knownPlugins = [ + '@objectos/metrics', + '@objectos/cache', + '@objectos/storage', + '@objectos/auth', + '@objectos/permissions', + '@objectos/audit', + '@objectos/workflow', + '@objectos/automation', + '@objectos/jobs', + '@objectos/notification', + '@objectos/i18n', + '@objectos/realtime', + ]; + + for (const pluginName of knownPlugins) { + try { + // Try to get the service + const serviceName = pluginName.replace('@objectos/', ''); + const service = context.getService(serviceName) as any; + + if (service) { + let health: any = { status: 'unknown' }; + let manifest: any = {}; + + // Try to get health check + if (typeof service.healthCheck === 'function') { + try { + health = await service.healthCheck(); + } catch (e) { + health = { status: 'error', error: String(e) }; + } + } + + // Try to get manifest + if (typeof service.getManifest === 'function') { + try { + manifest = service.getManifest(); + } catch (e) { + manifest = { error: String(e) }; + } + } + + plugins.push({ + name: service.name || pluginName, + version: service.version || '0.1.0', + status: health.status || 'unknown', + uptime: health.uptime || 0, + health, + manifest, + }); + } + } catch (e) { + // Plugin not loaded or service not available + context.logger.debug(`[Admin API] Plugin ${pluginName} not found: ${e}`); + } + } + + return c.json({ success: true, data: plugins }); + } catch (error: any) { + context.logger.error('[Admin API] List plugins error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + context.logger.info('[Metrics] HTTP routes registered'); + } + } catch (e: any) { + context.logger.warn(`[Metrics] Could not register HTTP routes: ${e?.message}`); + } + context.logger.info('[Metrics] Started successfully'); } diff --git a/packages/notification/src/plugin.ts b/packages/notification/src/plugin.ts index e7e7b1a5..086d2a96 100644 --- a/packages/notification/src/plugin.ts +++ b/packages/notification/src/plugin.ts @@ -122,6 +122,95 @@ export class NotificationPlugin implements Plugin { context.logger.info('[NotificationPlugin] Initialized successfully'); } + /** + * Plugin lifecycle: Start + */ + async start(context: PluginContext): Promise { + context.logger.info('[NotificationPlugin] Starting...'); + + // Register HTTP routes for Notification API + try { + const httpServer = context.getService('http.server') as any; + const rawApp = httpServer?.getRawApp?.() ?? httpServer?.app; + if (rawApp) { + // GET /api/v1/notifications/channels - List configured channels + rawApp.get('/api/v1/notifications/channels', async (c: any) => { + try { + const channels: any[] = []; + if (this.emailChannel) { + channels.push({ + name: 'email', + type: NotificationChannel.Email, + enabled: true, + config: { + from: this.config.email?.from, + host: this.config.email?.host ? 'configured' : 'not configured' + } + }); + } + if (this.smsChannel) { + channels.push({ + name: 'sms', + type: NotificationChannel.SMS, + enabled: true, + config: { provider: this.config.sms?.provider || 'default' } + }); + } + if (this.pushChannel) { + channels.push({ + name: 'push', + type: NotificationChannel.Push, + enabled: true, + config: { provider: this.config.push?.provider || 'default' } + }); + } + if (this.webhookChannel) { + channels.push({ + name: 'webhook', + type: NotificationChannel.Webhook, + enabled: true, + config: {} + }); + } + return c.json({ success: true, data: channels }); + } catch (error: any) { + context.logger.error('[Notification API] Channels error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // GET /api/v1/notifications/queue/status - Get queue status + rawApp.get('/api/v1/notifications/queue/status', async (c: any) => { + try { + const status = this.getQueueStatus(); + return c.json({ success: true, data: status }); + } catch (error: any) { + context.logger.error('[Notification API] Queue status error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // POST /api/v1/notifications/send - Send notification + rawApp.post('/api/v1/notifications/send', async (c: any) => { + try { + const request = await c.req.json(); + const result = await this.send(request); + return c.json({ success: true, data: result }); + } catch (error: any) { + context.logger.error('[Notification API] Send error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + context.logger.info('[NotificationPlugin] HTTP routes registered'); + } + } catch (e: any) { + context.logger.warn(`[NotificationPlugin] Could not register HTTP routes: ${e?.message}`); + } + + context.logger.info('[NotificationPlugin] Started successfully'); + } + /** * Health check */ diff --git a/packages/permissions/src/plugin.ts b/packages/permissions/src/plugin.ts index 84b94568..dcbd2f82 100644 --- a/packages/permissions/src/plugin.ts +++ b/packages/permissions/src/plugin.ts @@ -89,6 +89,88 @@ export class PermissionsPlugin implements Plugin { // Permissions plugin is stateless and doesn't need special startup // All permission checking is done on-demand via hooks + // Register HTTP routes for Permissions API + try { + const httpServer = context.getService('http.server') as any; + const rawApp = httpServer?.getRawApp?.() ?? httpServer?.app; + if (rawApp) { + // GET /api/v1/permissions/sets - List all permission sets + rawApp.get('/api/v1/permissions/sets', async (c: any) => { + try { + const sets = await this.storage.getAllPermissionSets(); + return c.json({ success: true, data: sets }); + } catch (error: any) { + context.logger.error('[Permissions API] List error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // GET /api/v1/permissions/sets/:name - Get permission set by name + rawApp.get('/api/v1/permissions/sets/:name', async (c: any) => { + try { + const name = c.req.param('name'); + const set = await this.storage.getPermissionSet(name); + if (!set) { + return c.json({ success: false, error: 'Permission set not found' }, 404); + } + return c.json({ success: true, data: set }); + } catch (error: any) { + context.logger.error('[Permissions API] Get error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // GET /api/v1/permissions/object/:objectName - Get permissions for object + rawApp.get('/api/v1/permissions/object/:objectName', async (c: any) => { + try { + const objectName = c.req.param('objectName'); + const set = await this.storage.getPermissionSetForObject(objectName); + if (!set) { + return c.json({ success: false, error: 'No permissions found for object' }, 404); + } + return c.json({ success: true, data: set }); + } catch (error: any) { + context.logger.error('[Permissions API] Get object permissions error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + // POST /api/v1/permissions/check - Check permission + rawApp.post('/api/v1/permissions/check', async (c: any) => { + try { + const body = await c.req.json(); + const { userId, profileName, roleName, objectName, action, recordId } = body; + + if (!userId || !profileName || !objectName || !action) { + return c.json({ success: false, error: 'Missing required fields' }, 400); + } + + const permissionContext = { + userId, + profileName, + roleName, + permissionSetNames: body.permissionSetNames || [], + }; + + const hasPermission = await this.engine.checkPermission( + permissionContext, + objectName, + action as any + ); + + return c.json({ success: true, data: { hasPermission } }); + } catch (error: any) { + context.logger.error('[Permissions API] Check error:', error); + return c.json({ success: false, error: error.message }, 500); + } + }); + + context.logger.info('[Permissions Plugin] HTTP routes registered'); + } + } catch (e: any) { + context.logger.warn(`[Permissions Plugin] Could not register HTTP routes: ${e?.message}`); + } + context.logger.info('[Permissions Plugin] Started successfully'); } From 7449b03c99fc5adac3ff69d3eb018c5a2c5ca879 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:04:55 +0000 Subject: [PATCH 3/3] Add frontend admin pages for jobs, plugins, metrics, audit, notifications, and permissions Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/web/package.json | 1 + apps/web/src/App.tsx | 8 + .../src/components/layouts/SettingsLayout.tsx | 7 + apps/web/src/main.tsx | 18 +- apps/web/src/pages/settings/audit.tsx | 214 +++++++++++++- apps/web/src/pages/settings/jobs.tsx | 256 +++++++++++++++++ apps/web/src/pages/settings/metrics.tsx | 264 ++++++++++++++++++ apps/web/src/pages/settings/notifications.tsx | 249 +++++++++++++++++ apps/web/src/pages/settings/permissions.tsx | 185 ++++++++++-- apps/web/src/pages/settings/plugins.tsx | 211 ++++++++++++++ pnpm-lock.yaml | 18 ++ 11 files changed, 1394 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/pages/settings/jobs.tsx create mode 100644 apps/web/src/pages/settings/metrics.tsx create mode 100644 apps/web/src/pages/settings/notifications.tsx create mode 100644 apps/web/src/pages/settings/plugins.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 7ab6dff7..9f5032ab 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.90.20", "better-auth": "^1.4.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 27fbf8c9..27954c7a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -26,6 +26,10 @@ const AuditPage = lazy(() => import('./pages/settings/audit')); const PackagesPage = lazy(() => import('./pages/settings/packages')); const AccountSettingsPage = lazy(() => import('./pages/settings/account')); const SecuritySettingsPage = lazy(() => import('./pages/settings/security')); +const JobsPage = lazy(() => import('./pages/settings/jobs')); +const PluginsPage = lazy(() => import('./pages/settings/plugins')); +const MetricsPage = lazy(() => import('./pages/settings/metrics')); +const NotificationsPage = lazy(() => import('./pages/settings/notifications')); // ── Business Apps ───────────────────────────────────────────── const BusinessAppPage = lazy(() => import('./pages/apps/app')); @@ -66,6 +70,10 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/web/src/components/layouts/SettingsLayout.tsx b/apps/web/src/components/layouts/SettingsLayout.tsx index dced81e6..1c857703 100644 --- a/apps/web/src/components/layouts/SettingsLayout.tsx +++ b/apps/web/src/components/layouts/SettingsLayout.tsx @@ -12,6 +12,9 @@ import { UsersRound, ClipboardList, LayoutDashboard, + Briefcase, + BarChart3, + Bell, } from 'lucide-react'; import { Sidebar, @@ -52,6 +55,10 @@ const navSecurity = [ const navSystem = [ { title: 'Packages', href: '/settings/packages', icon: Package }, + { title: 'Plugins', href: '/settings/plugins', icon: Blocks }, + { title: 'Jobs', href: '/settings/jobs', icon: Briefcase }, + { title: 'Metrics', href: '/settings/metrics', icon: BarChart3 }, + { title: 'Notifications', href: '/settings/notifications', icon: Bell }, ]; const navAccount = [ diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e3c52bda..d2ed15cb 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,13 +1,25 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { App } from './App'; import './index.css'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}); + createRoot(document.getElementById('root')!).render( - - - + + + + + , ); diff --git a/apps/web/src/pages/settings/audit.tsx b/apps/web/src/pages/settings/audit.tsx index 10bf7f2e..3609c7c4 100644 --- a/apps/web/src/pages/settings/audit.tsx +++ b/apps/web/src/pages/settings/audit.tsx @@ -1,7 +1,76 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface AuditEvent { + id: string; + eventType: string; + objectName: string; + recordId?: string; + userId?: string; + timestamp: string; + changes?: Array<{ + field: string; + oldValue: any; + newValue: any; + }>; + metadata?: Record; +} + +const eventTypeColors: Record = { + 'data.create': 'default', + 'data.update': 'secondary', + 'data.delete': 'destructive', + 'data.find': 'outline', + 'job.enqueued': 'secondary', + 'job.completed': 'outline', + 'job.failed': 'destructive', +}; export default function AuditPage() { + const [objectFilter, setObjectFilter] = useState(''); + const [userFilter, setUserFilter] = useState(''); + const [eventTypeFilter, setEventTypeFilter] = useState('all'); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + const { data: eventsData, isLoading } = useQuery({ + queryKey: ['audit', 'events', objectFilter, userFilter, eventTypeFilter, startDate, endDate], + queryFn: async () => { + const params = new URLSearchParams(); + if (objectFilter) params.append('objectName', objectFilter); + if (userFilter) params.append('userId', userFilter); + if (eventTypeFilter !== 'all') params.append('eventType', eventTypeFilter); + if (startDate) params.append('startDate', startDate); + if (endDate) params.append('endDate', endDate); + params.append('limit', '100'); + + const response = await fetch(`/api/v1/audit/events?${params}`); + if (!response.ok) throw new Error('Failed to fetch audit events'); + return response.json(); + }, + }); + + const events: AuditEvent[] = eventsData?.data || []; + return (
@@ -11,20 +80,147 @@ export default function AuditPage() {

+ {/* Filters */} + + + Filters + Filter audit events by various criteria + + +
+
+ + setObjectFilter(e.target.value)} + /> +
+
+ + setUserFilter(e.target.value)} + /> +
+
+ + +
+
+ + setStartDate(e.target.value)} + /> +
+
+ + setEndDate(e.target.value)} + /> +
+
+
+
+ + {/* Event Log Table */} -
- Event Log - Scaffold +
+
+ Event Log + + Showing {events.length} event{events.length !== 1 ? 's' : ''} + +
- - A chronological stream of CRUD events, field-level changes, and login activity. - -

- Connect to @objectos/audit API to display events here. -

+ {isLoading ? ( +
+
+
+ ) : events.length === 0 ? ( +
+ No audit events found +
+ ) : ( + + + + Timestamp + Event Type + Object + Record ID + User + Changes + + + + {events.map((event) => ( + + + {new Date(event.timestamp).toLocaleString()} + + + + {event.eventType} + + + {event.objectName} + + {event.recordId || '-'} + + + {event.userId || 'System'} + + + {event.changes && event.changes.length > 0 ? ( +
+ {event.changes.slice(0, 2).map((change, idx) => ( +
+ {change.field}:{' '} + {JSON.stringify(change.oldValue)} → {JSON.stringify(change.newValue)} +
+ ))} + {event.changes.length > 2 && ( +
+ +{event.changes.length - 2} more +
+ )} +
+ ) : ( + '-' + )} +
+
+ ))} +
+
+ )}
diff --git a/apps/web/src/pages/settings/jobs.tsx b/apps/web/src/pages/settings/jobs.tsx new file mode 100644 index 00000000..1008b0cc --- /dev/null +++ b/apps/web/src/pages/settings/jobs.tsx @@ -0,0 +1,256 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +interface Job { + id: string; + name: string; + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'scheduled'; + priority: 'low' | 'normal' | 'high' | 'critical'; + createdAt: string; + startedAt?: string; + completedAt?: string; + error?: string; + retryCount?: number; + data?: any; +} + +interface JobStats { + total: number; + pending: number; + running: number; + completed: number; + failed: number; + cancelled: number; + scheduled: number; +} + +const statusColors: Record = { + pending: 'secondary', + running: 'default', + completed: 'outline', + failed: 'destructive', + cancelled: 'secondary', + scheduled: 'secondary', +}; + +const priorityColors: Record = { + low: 'secondary', + normal: 'outline', + high: 'default', + critical: 'destructive', +}; + +export default function JobsPage() { + const [statusFilter, setStatusFilter] = useState('all'); + const queryClient = useQueryClient(); + + // Fetch jobs + const { data: jobsData, isLoading: jobsLoading } = useQuery({ + queryKey: ['jobs', statusFilter], + queryFn: async () => { + const params = new URLSearchParams(); + if (statusFilter !== 'all') { + params.append('status', statusFilter); + } + const response = await fetch(`/api/v1/jobs?${params}`); + if (!response.ok) throw new Error('Failed to fetch jobs'); + return response.json(); + }, + }); + + // Fetch job stats + const { data: statsData } = useQuery({ + queryKey: ['jobs', 'stats'], + queryFn: async () => { + const response = await fetch('/api/v1/jobs/stats'); + if (!response.ok) throw new Error('Failed to fetch stats'); + return response.json(); + }, + refetchInterval: 5000, // Refresh every 5 seconds + }); + + // Retry job mutation + const retryMutation = useMutation({ + mutationFn: async (jobId: string) => { + const response = await fetch(`/api/v1/jobs/${jobId}/retry`, { + method: 'POST', + }); + if (!response.ok) throw new Error('Failed to retry job'); + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['jobs'] }); + }, + }); + + // Cancel job mutation + const cancelMutation = useMutation({ + mutationFn: async (jobId: string) => { + const response = await fetch(`/api/v1/jobs/${jobId}/cancel`, { + method: 'POST', + }); + if (!response.ok) throw new Error('Failed to cancel job'); + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['jobs'] }); + }, + }); + + const jobs: Job[] = jobsData?.data || []; + const stats: JobStats | undefined = statsData?.data; + + return ( +
+
+

Jobs Monitor

+

+ Monitor and manage background jobs, queue status, and retry failed tasks. +

+
+ + {/* Stats Cards */} + {stats && ( +
+ + + Total + {stats.total} + + + + + Running + {stats.running} + + + + + Pending + {stats.pending} + + + + + Failed + {stats.failed} + + +
+ )} + + {/* Jobs Table */} + + +
+
+ Job Queue + View and manage background jobs +
+ +
+
+ + {jobsLoading ? ( +
+
+
+ ) : jobs.length === 0 ? ( +
+ No jobs found +
+ ) : ( + + + + Job Name + Status + Priority + Created + Retries + Actions + + + + {jobs.map((job) => ( + + {job.name} + + + {job.status} + + + + + {job.priority} + + + + {new Date(job.createdAt).toLocaleString()} + + {job.retryCount || 0} + +
+ {job.status === 'failed' && ( + + )} + {(job.status === 'pending' || job.status === 'scheduled') && ( + + )} +
+
+
+ ))} +
+
+ )} + + +
+ ); +} diff --git a/apps/web/src/pages/settings/metrics.tsx b/apps/web/src/pages/settings/metrics.tsx new file mode 100644 index 00000000..77ca739f --- /dev/null +++ b/apps/web/src/pages/settings/metrics.tsx @@ -0,0 +1,264 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface Metric { + name: string; + type: 'counter' | 'gauge' | 'histogram'; + help: string; + value?: number; + labels?: Record; + timestamp: number; + observations?: number[]; + percentiles?: { + p50: number; + p75: number; + p90: number; + p95: number; + p99: number; + }; + sum?: number; + count?: number; + min?: number; + max?: number; + mean?: number; +} + +export default function MetricsPage() { + const { data: metricsData, isLoading } = useQuery({ + queryKey: ['metrics'], + queryFn: async () => { + const response = await fetch('/api/v1/metrics'); + if (!response.ok) throw new Error('Failed to fetch metrics'); + return response.json(); + }, + refetchInterval: 5000, // Refresh every 5 seconds + }); + + const metrics: Metric[] = metricsData?.data || []; + + function formatValue(value: number | undefined): string { + if (value === undefined) return '-'; + if (value >= 1000000) return `${(value / 1000000).toFixed(2)}M`; + if (value >= 1000) return `${(value / 1000).toFixed(2)}K`; + return value.toFixed(2); + } + + function formatLabels(labels?: Record): string { + if (!labels || Object.keys(labels).length === 0) return ''; + return Object.entries(labels) + .map(([key, value]) => `${key}="${value}"`) + .join(', '); + } + + // Group metrics by type + const counterMetrics = metrics.filter(m => m.type === 'counter'); + const gaugeMetrics = metrics.filter(m => m.type === 'gauge'); + const histogramMetrics = metrics.filter(m => m.type === 'histogram'); + + return ( +
+
+

Metrics Dashboard

+

+ System metrics and performance indicators from ObjectOS kernel. +

+
+ + {/* Summary Cards */} +
+ + + Total Metrics + {metrics.length} + + + + + Counters + {counterMetrics.length} + + + + + Histograms + {histogramMetrics.length} + + +
+ + {isLoading ? ( +
+
+
+ ) : metrics.length === 0 ? ( + + +
+ No metrics available +
+
+
+ ) : ( + <> + {/* Counters */} + {counterMetrics.length > 0 && ( + + + Counters + + Monotonically increasing values tracking event counts + + + + + + + Metric + Labels + Value + Description + + + + {counterMetrics.map((metric, idx) => ( + + {metric.name} + + {formatLabels(metric.labels)} + + + {formatValue(metric.value)} + + + {metric.help} + + + ))} + +
+
+
+ )} + + {/* Gauges */} + {gaugeMetrics.length > 0 && ( + + + Gauges + + Current values that can increase or decrease + + + + + + + Metric + Labels + Value + Description + + + + {gaugeMetrics.map((metric, idx) => ( + + {metric.name} + + {formatLabels(metric.labels)} + + + {formatValue(metric.value)} + + + {metric.help} + + + ))} + +
+
+
+ )} + + {/* Histograms */} + {histogramMetrics.length > 0 && ( + + + Histograms + + Distribution of values with percentile breakdowns + + + + + + + Metric + Count + Mean + P50 + P95 + P99 + Max + + + + {histogramMetrics.map((metric, idx) => ( + + {metric.name} + {metric.count || 0} + + {formatValue(metric.mean)} + + + {formatValue(metric.percentiles?.p50)} + + + {formatValue(metric.percentiles?.p95)} + + + {formatValue(metric.percentiles?.p99)} + + + {formatValue(metric.max)} + + + ))} + +
+
+
+ )} + + {/* Prometheus Export */} + + + Prometheus Export + + Export metrics in Prometheus text format + + + + + /api/v1/metrics/prometheus + + + + + )} +
+ ); +} diff --git a/apps/web/src/pages/settings/notifications.tsx b/apps/web/src/pages/settings/notifications.tsx new file mode 100644 index 00000000..1f8141d5 --- /dev/null +++ b/apps/web/src/pages/settings/notifications.tsx @@ -0,0 +1,249 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface NotificationChannel { + name: string; + type: 'email' | 'sms' | 'push' | 'webhook'; + enabled: boolean; + config: Record; +} + +interface QueueStatus { + pending: number; + processing: number; + completed: number; + failed: number; +} + +const channelTypeColors: Record = { + email: 'default', + sms: 'secondary', + push: 'outline', + webhook: 'outline', +}; + +export default function NotificationsPage() { + const { data: channelsData, isLoading: channelsLoading } = useQuery({ + queryKey: ['notifications', 'channels'], + queryFn: async () => { + const response = await fetch('/api/v1/notifications/channels'); + if (!response.ok) throw new Error('Failed to fetch channels'); + return response.json(); + }, + }); + + const { data: queueData } = useQuery({ + queryKey: ['notifications', 'queue'], + queryFn: async () => { + const response = await fetch('/api/v1/notifications/queue/status'); + if (!response.ok) throw new Error('Failed to fetch queue status'); + return response.json(); + }, + refetchInterval: 5000, // Refresh every 5 seconds + }); + + const channels: NotificationChannel[] = channelsData?.data || []; + const queueStatus: QueueStatus | undefined = queueData?.data; + + return ( +
+
+

Notification Settings

+

+ Manage notification channels, templates, and delivery settings. +

+
+ + {/* Queue Status */} + {queueStatus && ( +
+ + + Pending + {queueStatus.pending || 0} + + + + + Processing + {queueStatus.processing || 0} + + + + + Completed + + {queueStatus.completed || 0} + + + + + + Failed + + {queueStatus.failed || 0} + + + +
+ )} + + {/* Channels */} + + + Notification Channels + + Configure and manage notification delivery channels + + + + {channelsLoading ? ( +
+
+
+ ) : channels.length === 0 ? ( +
+ No notification channels configured +
+ ) : ( + + + + Channel + Type + Status + Configuration + + + + {channels.map((channel, idx) => ( + + {channel.name} + + + {channel.type} + + + + {channel.enabled ? ( + Enabled + ) : ( + Disabled + )} + + + {Object.entries(channel.config) + .filter(([_, value]) => value && value !== 'not configured') + .map(([key, value]) => ( +
+ {key}: {String(value)} +
+ ))} +
+
+ ))} +
+
+ )} + + + + {/* Email Channel Details */} + {channels.find(c => c.type === 'email') && ( + + + Email Channel + SMTP configuration and settings + + +
+
+ From Address:{' '} + {channels.find(c => c.type === 'email')?.config.from || 'Not configured'} +
+
+ SMTP Server:{' '} + {channels.find(c => c.type === 'email')?.config.host || 'Not configured'} +
+
+
+
+ )} + + {/* SMS Channel Details */} + {channels.find(c => c.type === 'sms') && ( + + + SMS Channel + SMS provider configuration + + +
+
+ Provider:{' '} + {channels.find(c => c.type === 'sms')?.config.provider || 'Not configured'} +
+
+
+
+ )} + + {/* Push Channel Details */} + {channels.find(c => c.type === 'push') && ( + + + Push Channel + Push notification provider configuration + + +
+
+ Provider:{' '} + {channels.find(c => c.type === 'push')?.config.provider || 'Not configured'} +
+
+
+
+ )} + + {/* Webhook Channel Details */} + {channels.find(c => c.type === 'webhook') && ( + + + Webhook Channel + Webhook delivery settings + + +
+ Webhook channel is available for sending HTTP POST notifications +
+
+
+ )} + + {/* Templates Section */} + + + Templates + + Manage notification templates (coming soon) + + + +
+ Template management will be available in a future update +
+
+
+
+ ); +} diff --git a/apps/web/src/pages/settings/permissions.tsx b/apps/web/src/pages/settings/permissions.tsx index 6eae02e1..199d5934 100644 --- a/apps/web/src/pages/settings/permissions.tsx +++ b/apps/web/src/pages/settings/permissions.tsx @@ -1,8 +1,43 @@ +import { useQuery } from '@tanstack/react-query'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface PermissionSet { + name: string; + label?: string; + description?: string; + isSystem?: boolean; + isActive?: boolean; + objectPermissions?: Record; +} export default function PermissionsPage() { + const { data: setsData, isLoading } = useQuery({ + queryKey: ['permissions', 'sets'], + queryFn: async () => { + const response = await fetch('/api/v1/permissions/sets'); + if (!response.ok) throw new Error('Failed to fetch permission sets'); + return response.json(); + }, + }); + + const permissionSets: PermissionSet[] = setsData?.data || []; + return (
@@ -12,50 +47,150 @@ export default function PermissionsPage() {

+ {/* Permission Sets */} -
- Roles - Scaffold +
+
+ Permission Sets + + Granular permissions for objects, fields, and records + +
- - Define high-level access roles and assign them to users. - - - - + + {isLoading ? ( +
+
+
+ ) : permissionSets.length === 0 ? ( +
+ No permission sets found +
+ ) : ( + + + + Name + Label + Description + Status + Type + Objects + + + + {permissionSets.map((set) => ( + + + {set.name} + + {set.label || set.name} + + {set.description || '-'} + + + {set.isActive !== false ? ( + Active + ) : ( + Inactive + )} + + + {set.isSystem ? ( + System + ) : ( + Custom + )} + + + {set.objectPermissions + ? Object.keys(set.objectPermissions).length + : 0} object{Object.keys(set.objectPermissions || {}).length !== 1 ? 's' : ''} + + + ))} + +
+ )} + {/* Permission Set Details */} + {permissionSets.length > 0 && ( +
+ {permissionSets.slice(0, 4).map((set) => ( + + +
+ {set.label || set.name} + {set.isSystem && System} +
+ + {set.name} + +
+ + {set.objectPermissions && Object.keys(set.objectPermissions).length > 0 ? ( +
+ {Object.entries(set.objectPermissions).slice(0, 3).map(([objectName, perms]) => ( +
+
{objectName}
+
+ {perms.create && Create} + {perms.read && Read} + {perms.update && Update} + {perms.delete && Delete} + {perms.viewAll && ViewAll} + {perms.modifyAll && ModifyAll} +
+
+ ))} + {Object.keys(set.objectPermissions).length > 3 && ( +
+ +{Object.keys(set.objectPermissions).length - 3} more objects +
+ )} +
+ ) : ( +
+ No object permissions defined +
+ )} +
+
+ ))} +
+ )} + + {/* Roles */} -
- Permission Sets - Scaffold -
+ Roles - Granular permissions for objects, fields, and records. + Define high-level access roles and assign them to users
- - - + +
+ Role management coming soon +
+ {/* Field-Level Security */} -
- Policy Preview - Scaffold -
+ Field-Level Security - Simulate access for a user or role before rollout. + Control field visibility and editability
- - + +
+ Field-level security configuration coming soon +
diff --git a/apps/web/src/pages/settings/plugins.tsx b/apps/web/src/pages/settings/plugins.tsx new file mode 100644 index 00000000..cba7eecb --- /dev/null +++ b/apps/web/src/pages/settings/plugins.tsx @@ -0,0 +1,211 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface Plugin { + name: string; + version: string; + status: 'healthy' | 'degraded' | 'error' | 'unknown'; + uptime: number; + health: { + status: string; + uptime: number; + checks?: Array<{ + name: string; + status: string; + message: string; + }>; + }; + manifest: { + capabilities?: { + services?: string[]; + emits?: string[]; + listens?: string[]; + routes?: string[]; + }; + security?: { + requiredPermissions?: string[]; + handlesSensitiveData?: boolean; + makesExternalCalls?: boolean; + }; + }; +} + +const statusColors: Record = { + healthy: 'outline', + degraded: 'default', + error: 'destructive', + unknown: 'secondary', +}; + +export default function PluginsPage() { + const { data: pluginsData, isLoading } = useQuery({ + queryKey: ['admin', 'plugins'], + queryFn: async () => { + const response = await fetch('/api/v1/admin/plugins'); + if (!response.ok) throw new Error('Failed to fetch plugins'); + return response.json(); + }, + refetchInterval: 10000, // Refresh every 10 seconds + }); + + const plugins: Plugin[] = pluginsData?.data || []; + + function formatUptime(ms: number) { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; + } + + return ( +
+
+

Plugin Management

+

+ View loaded plugins, health status, and capabilities. +

+
+ + {isLoading ? ( +
+
+
+ ) : plugins.length === 0 ? ( + + +
+ No plugins loaded +
+
+
+ ) : ( + <> + {/* Summary Card */} + + + Plugin Summary + + {plugins.length} plugin{plugins.length !== 1 ? 's' : ''} loaded + {' • '} + {plugins.filter(p => p.status === 'healthy').length} healthy + {plugins.filter(p => p.status === 'degraded').length > 0 && + ` • ${plugins.filter(p => p.status === 'degraded').length} degraded`} + {plugins.filter(p => p.status === 'error').length > 0 && + ` • ${plugins.filter(p => p.status === 'error').length} error`} + + + + + {/* Plugins Table */} + + + Loaded Plugins + Status and metadata for each plugin + + + + + + Plugin + Version + Status + Uptime + Services + Security + + + + {plugins.map((plugin) => ( + + {plugin.name} + + {plugin.version} + + + + {plugin.status} + + + + {formatUptime(plugin.uptime)} + + + {plugin.manifest?.capabilities?.services?.join(', ') || '-'} + + +
+ {plugin.manifest?.security?.handlesSensitiveData && ( + + Sensitive + + )} + {plugin.manifest?.security?.makesExternalCalls && ( + + External + + )} +
+
+
+ ))} +
+
+
+
+ + {/* Detailed Health Checks */} +
+ {plugins.map((plugin) => ( + + +
+ {plugin.name} + + {plugin.status} + +
+ + v{plugin.version} • {formatUptime(plugin.uptime)} uptime + +
+ + {plugin.health.checks && plugin.health.checks.length > 0 ? ( +
+ {plugin.health.checks.map((check, idx) => ( +
+
+
{check.name}
+
{check.message}
+
+ + {check.status} + +
+ ))} +
+ ) : ( +
+ No health checks available +
+ )} +
+
+ ))} +
+ + )} +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e8f6a24..403154e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-query': + specifier: ^5.90.20 + version: 5.90.20(react@19.2.4) better-auth: specifier: ^1.4.18 version: 1.4.18(better-sqlite3@12.6.2)(mongodb@7.0.0(socks@2.8.7))(next@16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@types/node@25.2.0)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))(vue@3.5.27(typescript@5.9.3)) @@ -2954,6 +2957,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.20': + resolution: {integrity: sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -9288,6 +9299,13 @@ snapshots: tailwindcss: 4.1.18 vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.20(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.4 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.28.6