@camstack/addon-admin-ui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/index.html +22 -0
  2. package/package.json +69 -0
  3. package/public/brand/logo-dark.svg +16 -0
  4. package/public/brand/logo-horizontal-dark.svg +21 -0
  5. package/public/brand/logo-horizontal-light.svg +21 -0
  6. package/public/brand/logo-light.svg +16 -0
  7. package/public/brand/logo-wide-dark.svg +24 -0
  8. package/public/brand/logo-wide-light.svg +24 -0
  9. package/public/favicon.svg +8 -0
  10. package/public/vendor/react-jsx-runtime.mjs +24 -0
  11. package/public/vendor/react.mjs +16 -0
  12. package/src/App.tsx +71 -0
  13. package/src/components/addons/AddonCard.tsx +339 -0
  14. package/src/components/addons/AddonUploadZone.tsx +307 -0
  15. package/src/components/addons/CapabilityBadge.tsx +55 -0
  16. package/src/components/addons/CapabilityMap.tsx +133 -0
  17. package/src/components/addons/UpdatesList.tsx +119 -0
  18. package/src/components/agents/AgentCard.tsx +281 -0
  19. package/src/components/agents/AgentLogs.tsx +231 -0
  20. package/src/components/agents/ProcessList.tsx +127 -0
  21. package/src/components/agents/ProcessTree.tsx +369 -0
  22. package/src/components/agents/TaskList.tsx +68 -0
  23. package/src/components/cameras/CameraCard.tsx +60 -0
  24. package/src/components/cameras/LiveEventsPanel.tsx +91 -0
  25. package/src/components/cameras/ProviderSection.tsx +50 -0
  26. package/src/components/cameras/StreamArea.tsx +107 -0
  27. package/src/components/cameras/tabs/AddonsTab.tsx +113 -0
  28. package/src/components/cameras/tabs/CameraEventsTab.tsx +129 -0
  29. package/src/components/cameras/tabs/PipelineTab.tsx +118 -0
  30. package/src/components/cameras/tabs/StreamsTab.tsx +114 -0
  31. package/src/components/dashboard/BlockPicker.tsx +54 -0
  32. package/src/components/dashboard/BlockWrapper.tsx +97 -0
  33. package/src/components/dashboard/DashboardGrid.tsx +160 -0
  34. package/src/components/dashboard/block-registry.ts +15 -0
  35. package/src/components/dashboard/blocks/PipelineStagesBlock.tsx +39 -0
  36. package/src/components/dashboard/blocks/StorageBlock.tsx +66 -0
  37. package/src/components/dashboard/blocks/SystemStatusBlock.tsx +67 -0
  38. package/src/components/dashboard/blocks/index.ts +32 -0
  39. package/src/components/device/DeviceHeader.tsx +116 -0
  40. package/src/components/device/FloatingPanel.tsx +132 -0
  41. package/src/components/device/FloatingPanelManager.tsx +167 -0
  42. package/src/components/device/PanelContent.tsx +196 -0
  43. package/src/components/device/QuickConfigWizard.tsx +507 -0
  44. package/src/components/device/tabs/DetectionConfigTab.tsx +96 -0
  45. package/src/components/device/tabs/EventsTab.tsx +19 -0
  46. package/src/components/device/tabs/LogsTab.tsx +22 -0
  47. package/src/components/device/tabs/OverviewTab.tsx +104 -0
  48. package/src/components/device/tabs/ProviderSettingsTab.tsx +34 -0
  49. package/src/components/device/tabs/RecordingTab.tsx +47 -0
  50. package/src/components/device/tabs/ReplTab.tsx +153 -0
  51. package/src/components/device/tabs/TrackTrailTab.tsx +49 -0
  52. package/src/components/device/tabs/ZonesTab.tsx +98 -0
  53. package/src/components/device/zone-editor/ZoneCanvas.tsx +354 -0
  54. package/src/components/device/zone-editor/ZoneForm.tsx +128 -0
  55. package/src/components/device/zone-editor/ZoneList.tsx +150 -0
  56. package/src/components/form-builder/FormBuilder.tsx +135 -0
  57. package/src/components/form-builder/FormField.tsx +732 -0
  58. package/src/components/form-builder/ModelSelector.tsx +239 -0
  59. package/src/components/integrations/AddDeviceDialog.tsx +205 -0
  60. package/src/components/integrations/CompactDeviceCard.tsx +35 -0
  61. package/src/components/integrations/DeviceCard.tsx +29 -0
  62. package/src/components/integrations/DeviceDiscoveryStep.tsx +105 -0
  63. package/src/components/integrations/DeviceGrid.tsx +79 -0
  64. package/src/components/integrations/DeviceGroupHeader.tsx +17 -0
  65. package/src/components/integrations/DiscoveredDeviceCard.tsx +26 -0
  66. package/src/components/integrations/IntegrationCard.tsx +40 -0
  67. package/src/components/integrations/IntegrationWizard.tsx +171 -0
  68. package/src/components/integrations/ProviderConfigForm.tsx +89 -0
  69. package/src/components/integrations/ProviderPicker.tsx +91 -0
  70. package/src/components/integrations/SnapshotPopover.tsx +68 -0
  71. package/src/components/metrics/AgentLoad.tsx +113 -0
  72. package/src/components/metrics/IntegrationUsage.tsx +90 -0
  73. package/src/components/metrics/PipelineStatus.tsx +105 -0
  74. package/src/components/metrics/ProcessResources.tsx +139 -0
  75. package/src/components/pipeline/PhaseSettings.tsx +131 -0
  76. package/src/components/shared/CapabilityBadges.tsx +30 -0
  77. package/src/components/shared/ProviderIcon.tsx +42 -0
  78. package/src/components/shared/StatusBadge.tsx +23 -0
  79. package/src/components/shared/WebRtcPlayer.tsx +211 -0
  80. package/src/components/timeline/EventMarker.tsx +32 -0
  81. package/src/components/timeline/TimelineBar.tsx +131 -0
  82. package/src/components/ui/ConfirmDialog.tsx +115 -0
  83. package/src/components/ui/ToastContainer.tsx +92 -0
  84. package/src/contexts/auth-context.tsx +91 -0
  85. package/src/hooks/useBackendClient.ts +6 -0
  86. package/src/hooks/useTheme.ts +1 -0
  87. package/src/i18n/en.json +164 -0
  88. package/src/i18n/index.ts +29 -0
  89. package/src/i18n/it.json +164 -0
  90. package/src/index.css +63 -0
  91. package/src/layouts/AddonPageLoader.tsx +120 -0
  92. package/src/layouts/AppLayout.tsx +238 -0
  93. package/src/layouts/ProtectedRoute.tsx +25 -0
  94. package/src/lib/addon-page-context.ts +29 -0
  95. package/src/lib/backend.ts +16 -0
  96. package/src/main.tsx +21 -0
  97. package/src/pages/AccessDenied.tsx +22 -0
  98. package/src/pages/Cameras.tsx +127 -0
  99. package/src/pages/Dashboard.tsx +6 -0
  100. package/src/pages/DeviceDetail.tsx +175 -0
  101. package/src/pages/IntegrationDetail.tsx +224 -0
  102. package/src/pages/Integrations.tsx +330 -0
  103. package/src/pages/Login.tsx +106 -0
  104. package/src/pages/Metrics.tsx +18 -0
  105. package/src/pages/PipelineConfig.tsx +282 -0
  106. package/src/pages/Showroom.tsx +351 -0
  107. package/src/pages/Timeline.tsx +269 -0
  108. package/src/pages/system/Addons.tsx +525 -0
  109. package/src/pages/system/Agents.tsx +362 -0
  110. package/src/pages/system/Logs.tsx +131 -0
  111. package/src/pages/system/Models.tsx +102 -0
  112. package/src/pages/system/Processes.tsx +129 -0
  113. package/src/pages/system/Repl.tsx +148 -0
  114. package/src/pages/system/Settings.tsx +168 -0
  115. package/src/pages/system/Users.tsx +174 -0
  116. package/src/server/addon.ts +54 -0
  117. package/src/types/config-ui.ts +210 -0
  118. package/src/types/dashboard.ts +39 -0
  119. package/tsconfig.json +29 -0
  120. package/tsconfig.server.json +16 -0
  121. package/tsup.config.ts +20 -0
  122. package/vite.config.ts +68 -0
@@ -0,0 +1,113 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { useBackendClient } from '../../hooks/useBackendClient'
3
+ import { CapabilityBadges } from '../shared/CapabilityBadges'
4
+
5
+ interface AgentStatus {
6
+ id: string
7
+ name: string
8
+ state: 'online' | 'offline' | 'degraded'
9
+ capabilities: string[]
10
+ lastHeartbeat: number
11
+ connectedSince?: number
12
+ activeTaskCount: number
13
+ completedTaskCount: number
14
+ failedTaskCount: number
15
+ resources?: {
16
+ cpuCores?: number
17
+ memoryMB?: number
18
+ gpuAvailable?: boolean
19
+ gpuModel?: string
20
+ }
21
+ }
22
+
23
+ function stateStyle(state: string): { dot: string; label: string } {
24
+ switch (state) {
25
+ case 'online': return { dot: 'bg-success', label: 'text-success' }
26
+ case 'degraded': return { dot: 'bg-warning', label: 'text-warning' }
27
+ case 'offline':
28
+ default: return { dot: 'bg-foreground-subtle/40', label: 'text-foreground-subtle' }
29
+ }
30
+ }
31
+
32
+ export function AgentLoad() {
33
+ const client = useBackendClient()
34
+
35
+ const { data, isLoading, isError } = useQuery({
36
+ queryKey: ['agents'],
37
+ queryFn: () => client.listAgents(),
38
+ refetchInterval: 5_000,
39
+ })
40
+
41
+ const agents = (data ?? []) as unknown as AgentStatus[]
42
+ const onlineCount = agents.filter((a) => a.state === 'online').length
43
+ const totalActive = agents.reduce((sum, a) => sum + (a.activeTaskCount ?? 0), 0)
44
+
45
+ return (
46
+ <div className="rounded-lg border border-border bg-surface p-4">
47
+ <h2 className="text-xs font-semibold text-foreground-subtle uppercase tracking-wider mb-3">
48
+ Agent Load
49
+ </h2>
50
+
51
+ {isLoading && (
52
+ <div className="text-xs text-foreground-subtle animate-pulse">Loading...</div>
53
+ )}
54
+
55
+ {isError && (
56
+ <div className="text-xs text-danger">Failed to load data</div>
57
+ )}
58
+
59
+ {!isLoading && !isError && agents.length === 0 && (
60
+ <div className="text-xs text-foreground-subtle">No data available</div>
61
+ )}
62
+
63
+ {!isLoading && !isError && agents.length > 0 && (
64
+ <>
65
+ {/* Aggregate */}
66
+ <div className="flex items-center gap-3 mb-3 pb-3 border-b border-border">
67
+ <span className="text-xs text-foreground-subtle">
68
+ <span className="text-foreground font-semibold">{onlineCount}</span>/{agents.length} online
69
+ </span>
70
+ <span className="text-xs text-foreground-subtle">
71
+ <span className="text-foreground font-semibold">{totalActive}</span> active tasks
72
+ </span>
73
+ </div>
74
+
75
+ {/* Agent list */}
76
+ <div className="space-y-3">
77
+ {agents.map((a) => {
78
+ const { dot, label: labelClass } = stateStyle(a.state)
79
+ const isIdle = (a.activeTaskCount ?? 0) === 0
80
+ return (
81
+ <div key={a.id} className="space-y-1">
82
+ <div className="flex items-center justify-between gap-2">
83
+ <div className="flex items-center gap-2 min-w-0">
84
+ <span className={`h-2 w-2 flex-shrink-0 rounded-full ${dot}`} />
85
+ <span className="text-xs text-foreground font-medium truncate">
86
+ {a.name ?? a.id}
87
+ </span>
88
+ </div>
89
+ <div className="flex items-center gap-2 flex-shrink-0">
90
+ <span className={`text-[10px] font-medium ${labelClass}`}>
91
+ {a.state}
92
+ </span>
93
+ <span className="text-[10px] text-foreground-subtle tabular-nums bg-surface-hover rounded px-1.5 py-0.5">
94
+ {isIdle
95
+ ? 'idle'
96
+ : `${a.activeTaskCount} task${a.activeTaskCount !== 1 ? 's' : ''}`}
97
+ </span>
98
+ </div>
99
+ </div>
100
+ {a.capabilities && a.capabilities.length > 0 && (
101
+ <div className="pl-4">
102
+ <CapabilityBadges capabilities={a.capabilities} />
103
+ </div>
104
+ )}
105
+ </div>
106
+ )
107
+ })}
108
+ </div>
109
+ </>
110
+ )}
111
+ </div>
112
+ )
113
+ }
@@ -0,0 +1,90 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { useBackendClient } from '../../hooks/useBackendClient'
3
+ import { ProviderIcon } from '../shared/ProviderIcon'
4
+ import { StatusBadge } from '../shared/StatusBadge'
5
+
6
+ interface ProviderEntry {
7
+ id: string
8
+ name: string
9
+ type: string
10
+ status: string | { state?: string }
11
+ deviceCount: number
12
+ }
13
+
14
+ function resolveStatus(status: ProviderEntry['status']): string {
15
+ if (typeof status === 'string') return status
16
+ if (typeof status === 'object' && status !== null) {
17
+ return String((status as Record<string, unknown>).state ?? 'stopped')
18
+ }
19
+ return 'stopped'
20
+ }
21
+
22
+ export function IntegrationUsage() {
23
+ const client = useBackendClient()
24
+
25
+ const { data, isLoading, isError } = useQuery({
26
+ queryKey: ['providers'],
27
+ queryFn: () => client.listProviders(),
28
+ refetchInterval: 10_000,
29
+ })
30
+
31
+ const providers = (data ?? []) as unknown as ProviderEntry[]
32
+ const totalDevices = providers.reduce((sum, p) => sum + (p.deviceCount ?? 0), 0)
33
+
34
+ return (
35
+ <div className="rounded-lg border border-border bg-surface p-4">
36
+ <h2 className="text-xs font-semibold text-foreground-subtle uppercase tracking-wider mb-3">
37
+ Integration Usage
38
+ </h2>
39
+
40
+ {isLoading && (
41
+ <div className="text-xs text-foreground-subtle animate-pulse">Loading...</div>
42
+ )}
43
+
44
+ {isError && (
45
+ <div className="text-xs text-danger">Failed to load data</div>
46
+ )}
47
+
48
+ {!isLoading && !isError && providers.length === 0 && (
49
+ <div className="text-xs text-foreground-subtle">No data available</div>
50
+ )}
51
+
52
+ {!isLoading && !isError && providers.length > 0 && (
53
+ <>
54
+ {/* Aggregate */}
55
+ <div className="flex items-center gap-3 mb-3 pb-3 border-b border-border">
56
+ <span className="text-xs text-foreground-subtle">
57
+ <span className="text-foreground font-semibold">{providers.length}</span> providers
58
+ </span>
59
+ <span className="text-xs text-foreground-subtle">
60
+ <span className="text-foreground font-semibold">{totalDevices}</span> devices total
61
+ </span>
62
+ </div>
63
+
64
+ {/* Provider list */}
65
+ <div className="space-y-2">
66
+ {providers.map((p) => (
67
+ <div
68
+ key={p.id}
69
+ className="flex items-center justify-between gap-2"
70
+ >
71
+ <div className="flex items-center gap-2 min-w-0">
72
+ <ProviderIcon type={p.type} size="sm" />
73
+ <span className="text-xs text-foreground font-medium truncate">
74
+ {p.name ?? p.id}
75
+ </span>
76
+ </div>
77
+ <div className="flex items-center gap-2 flex-shrink-0">
78
+ <span className="text-xs text-foreground-subtle tabular-nums">
79
+ {p.deviceCount ?? 0}d
80
+ </span>
81
+ <StatusBadge status={resolveStatus(p.status)} />
82
+ </div>
83
+ </div>
84
+ ))}
85
+ </div>
86
+ </>
87
+ )}
88
+ </div>
89
+ )
90
+ }
@@ -0,0 +1,105 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { useBackendClient } from '../../hooks/useBackendClient'
3
+
4
+ interface PipelineEntry {
5
+ deviceId: string
6
+ status: {
7
+ active: boolean
8
+ decode: {
9
+ fps: number
10
+ droppedFrames: number
11
+ totalFrames: number
12
+ }
13
+ videoConsumers: number
14
+ audioConsumers: number
15
+ uptime: number
16
+ source: {
17
+ protocol: string
18
+ connected: boolean
19
+ } | null
20
+ outputs: Array<{
21
+ format: string
22
+ consumers: number
23
+ active: boolean
24
+ }>
25
+ }
26
+ }
27
+
28
+ export function PipelineStatus() {
29
+ const client = useBackendClient()
30
+
31
+ const { data, isLoading, isError } = useQuery({
32
+ queryKey: ['pipelines'],
33
+ queryFn: () => client.listPipelines(),
34
+ refetchInterval: 5_000,
35
+ })
36
+
37
+ const pipelines = (data ?? []) as unknown as PipelineEntry[]
38
+ const activePipelines = pipelines.filter((p) => p.status.active)
39
+ const totalFps = activePipelines.reduce((sum, p) => sum + (p.status.decode?.fps ?? 0), 0)
40
+
41
+ return (
42
+ <div className="rounded-lg border border-border bg-surface p-4">
43
+ <h2 className="text-xs font-semibold text-foreground-subtle uppercase tracking-wider mb-3">
44
+ Pipeline Status
45
+ </h2>
46
+
47
+ {isLoading && (
48
+ <div className="text-xs text-foreground-subtle animate-pulse">Loading...</div>
49
+ )}
50
+
51
+ {isError && (
52
+ <div className="text-xs text-danger">Failed to load data</div>
53
+ )}
54
+
55
+ {!isLoading && !isError && pipelines.length === 0 && (
56
+ <div className="text-xs text-foreground-subtle">No data available</div>
57
+ )}
58
+
59
+ {!isLoading && !isError && pipelines.length > 0 && (
60
+ <>
61
+ {/* Aggregate */}
62
+ <div className="flex items-center gap-3 mb-3 pb-3 border-b border-border">
63
+ <span className="text-xs text-foreground-subtle">
64
+ <span className="text-foreground font-semibold">{activePipelines.length}</span> active
65
+ {' '}/ <span className="text-foreground font-semibold">{pipelines.length}</span> total
66
+ </span>
67
+ <span className="text-xs text-foreground-subtle">
68
+ <span className="text-foreground font-semibold tabular-nums">
69
+ {totalFps.toFixed(1)}
70
+ </span>{' '}
71
+ total FPS
72
+ </span>
73
+ </div>
74
+
75
+ {/* Pipeline list */}
76
+ <div className="space-y-1.5">
77
+ {pipelines.map((p) => (
78
+ <div
79
+ key={p.deviceId}
80
+ className="flex items-center justify-between gap-2 text-xs"
81
+ >
82
+ <div className="flex items-center gap-2 min-w-0">
83
+ <span
84
+ className={`h-2 w-2 flex-shrink-0 rounded-full ${
85
+ p.status.active ? 'bg-success' : 'bg-foreground-subtle/40'
86
+ }`}
87
+ />
88
+ <span className="text-foreground truncate font-medium">{p.deviceId}</span>
89
+ </div>
90
+ <div className="flex items-center gap-3 flex-shrink-0">
91
+ <span className="text-foreground-subtle">
92
+ {p.status.active ? 'running' : 'idle'}
93
+ </span>
94
+ <span className="tabular-nums text-foreground-subtle w-14 text-right">
95
+ {p.status.active ? `${(p.status.decode?.fps ?? 0).toFixed(1)} fps` : '—'}
96
+ </span>
97
+ </div>
98
+ </div>
99
+ ))}
100
+ </div>
101
+ </>
102
+ )}
103
+ </div>
104
+ )
105
+ }
@@ -0,0 +1,139 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { useBackendClient } from '../../hooks/useBackendClient'
3
+
4
+ interface ProcessStats {
5
+ pid: number
6
+ cpu: number
7
+ memory: number
8
+ uptime: number
9
+ restartCount: number
10
+ }
11
+
12
+ interface ProcessEntry {
13
+ id: string
14
+ label: string
15
+ state: string
16
+ pid?: number
17
+ stats?: ProcessStats
18
+ restartCount: number
19
+ lastCrashAt?: number
20
+ lastCrashError?: string
21
+ }
22
+
23
+ function formatMemoryMB(bytes?: number): string {
24
+ if (bytes == null) return '—'
25
+ return `${(bytes / 1024 / 1024).toFixed(0)} MB`
26
+ }
27
+
28
+ function formatUptime(ms?: number): string {
29
+ if (ms == null || ms <= 0) return '—'
30
+ const totalSeconds = Math.floor(ms / 1000)
31
+ const h = Math.floor(totalSeconds / 3600)
32
+ const m = Math.floor((totalSeconds % 3600) / 60)
33
+ const s = totalSeconds % 60
34
+ if (h > 0) return `${h}h ${m}m`
35
+ if (m > 0) return `${m}m ${s}s`
36
+ return `${s}s`
37
+ }
38
+
39
+ function stateColor(state: string): string {
40
+ switch (state) {
41
+ case 'running': return 'text-success'
42
+ case 'stopped': return 'text-foreground-subtle'
43
+ case 'crashed':
44
+ case 'error': return 'text-danger'
45
+ case 'starting': return 'text-warning'
46
+ default: return 'text-foreground-subtle'
47
+ }
48
+ }
49
+
50
+ export function ProcessResources() {
51
+ const client = useBackendClient()
52
+
53
+ const { data, isLoading, isError } = useQuery({
54
+ queryKey: ['processes'],
55
+ queryFn: () => client.listProcesses(),
56
+ refetchInterval: 5_000,
57
+ })
58
+
59
+ const processes = (data ?? []) as ProcessEntry[]
60
+ const runningCount = processes.filter((p) => p.state === 'running').length
61
+
62
+ const totalCpu = processes.reduce((sum, p) => sum + (p.stats?.cpu ?? 0), 0)
63
+ const totalMemory = processes.reduce((sum, p) => sum + (p.stats?.memory ?? 0), 0)
64
+
65
+ return (
66
+ <div className="rounded-lg border border-border bg-surface p-4">
67
+ <h2 className="text-xs font-semibold text-foreground-subtle uppercase tracking-wider mb-3">
68
+ Process Resources
69
+ </h2>
70
+
71
+ {isLoading && (
72
+ <div className="text-xs text-foreground-subtle animate-pulse">Loading...</div>
73
+ )}
74
+
75
+ {isError && (
76
+ <div className="text-xs text-danger">Failed to load data</div>
77
+ )}
78
+
79
+ {!isLoading && !isError && processes.length === 0 && (
80
+ <div className="text-xs text-foreground-subtle">No data available</div>
81
+ )}
82
+
83
+ {!isLoading && !isError && processes.length > 0 && (
84
+ <>
85
+ {/* System totals */}
86
+ <div className="flex items-center gap-4 mb-3 pb-3 border-b border-border">
87
+ <span className="text-xs text-foreground-subtle">
88
+ <span className="text-foreground font-semibold">{runningCount}</span>/{processes.length} running
89
+ </span>
90
+ <span className="text-xs text-foreground-subtle">
91
+ CPU: <span className="text-foreground font-semibold tabular-nums">{totalCpu.toFixed(1)}%</span>
92
+ </span>
93
+ <span className="text-xs text-foreground-subtle">
94
+ Mem: <span className="text-foreground font-semibold tabular-nums">{formatMemoryMB(totalMemory)}</span>
95
+ </span>
96
+ </div>
97
+
98
+ {/* Process table */}
99
+ <div className="space-y-1.5">
100
+ {/* Header */}
101
+ <div className="grid grid-cols-[1fr_4rem_5rem_4rem_3rem] gap-2 text-[10px] font-medium text-foreground-subtle uppercase tracking-wider pb-1">
102
+ <span>Process</span>
103
+ <span className="text-right">CPU</span>
104
+ <span className="text-right">Memory</span>
105
+ <span className="text-right">Uptime</span>
106
+ <span className="text-right">Rst</span>
107
+ </div>
108
+
109
+ {processes.map((p) => (
110
+ <div
111
+ key={p.id}
112
+ className="grid grid-cols-[1fr_4rem_5rem_4rem_3rem] gap-2 text-xs items-center"
113
+ >
114
+ <div className="flex items-center gap-1.5 min-w-0">
115
+ <span className={`text-[10px] font-semibold ${stateColor(p.state)}`}>
116
+
117
+ </span>
118
+ <span className="text-foreground truncate">{p.label ?? p.id}</span>
119
+ </div>
120
+ <span className="tabular-nums text-foreground-subtle text-right">
121
+ {p.stats?.cpu != null ? `${p.stats.cpu.toFixed(1)}%` : '—'}
122
+ </span>
123
+ <span className="tabular-nums text-foreground-subtle text-right">
124
+ {formatMemoryMB(p.stats?.memory)}
125
+ </span>
126
+ <span className="tabular-nums text-foreground-subtle text-right">
127
+ {formatUptime(p.stats?.uptime)}
128
+ </span>
129
+ <span className="tabular-nums text-foreground-subtle text-right">
130
+ {p.restartCount ?? 0}
131
+ </span>
132
+ </div>
133
+ ))}
134
+ </div>
135
+ </>
136
+ )}
137
+ </div>
138
+ )
139
+ }
@@ -0,0 +1,131 @@
1
+ interface PhaseSettingsProps {
2
+ readonly motionFps: number
3
+ readonly detectionFps: number
4
+ readonly cooldownMs: number
5
+ readonly maxConcurrentInferences: number | null
6
+ readonly onMotionFpsChange: (v: number) => void
7
+ readonly onDetectionFpsChange: (v: number) => void
8
+ readonly onCooldownMsChange: (v: number) => void
9
+ readonly onMaxConcurrentInferencesChange: (v: number | null) => void
10
+ }
11
+
12
+ interface SliderFieldProps {
13
+ readonly label: string
14
+ readonly value: number
15
+ readonly min: number
16
+ readonly max: number
17
+ readonly step?: number
18
+ readonly unit?: string
19
+ readonly hint?: string
20
+ readonly onChange: (v: number) => void
21
+ }
22
+
23
+ function SliderField({ label, value, min, max, step = 1, unit, hint, onChange }: SliderFieldProps) {
24
+ return (
25
+ <div>
26
+ <div className="flex items-center justify-between mb-1">
27
+ <label className="text-[10px] font-medium text-foreground-subtle">{label}</label>
28
+ {hint && <span className="text-[9px] text-foreground-subtle/50">{hint}</span>}
29
+ </div>
30
+ <div className="flex items-center gap-3">
31
+ <input
32
+ type="range"
33
+ className="flex-1 h-1 accent-primary cursor-pointer"
34
+ min={min}
35
+ max={max}
36
+ step={step}
37
+ value={value}
38
+ onChange={(e) => onChange(Number(e.target.value))}
39
+ />
40
+ <span className="text-xs text-foreground tabular-nums min-w-[3rem] text-right">
41
+ {value}{unit ? ` ${unit}` : ''}
42
+ </span>
43
+ </div>
44
+ </div>
45
+ )
46
+ }
47
+
48
+ export function PhaseSettings({
49
+ motionFps,
50
+ detectionFps,
51
+ cooldownMs,
52
+ maxConcurrentInferences,
53
+ onMotionFpsChange,
54
+ onDetectionFpsChange,
55
+ onCooldownMsChange,
56
+ onMaxConcurrentInferencesChange,
57
+ }: PhaseSettingsProps) {
58
+ const isAuto = maxConcurrentInferences === null
59
+
60
+ return (
61
+ <div className="space-y-4">
62
+ <h3 className="text-[10px] font-semibold text-foreground uppercase tracking-wider">
63
+ Orchestrator
64
+ </h3>
65
+ <SliderField
66
+ label="Motion FPS"
67
+ value={motionFps}
68
+ min={1}
69
+ max={15}
70
+ onChange={onMotionFpsChange}
71
+ unit="fps"
72
+ hint="Frame-diff rate"
73
+ />
74
+ <SliderField
75
+ label="Detection FPS"
76
+ value={detectionFps}
77
+ min={1}
78
+ max={30}
79
+ onChange={onDetectionFpsChange}
80
+ unit="fps"
81
+ hint="Inference rate"
82
+ />
83
+ <SliderField
84
+ label="Motion Cooldown"
85
+ value={cooldownMs / 1000}
86
+ min={1}
87
+ max={60}
88
+ onChange={(v) => onCooldownMsChange(v * 1000)}
89
+ unit="s"
90
+ hint="No motion → idle"
91
+ />
92
+
93
+ {/* Max concurrent inferences with Auto toggle */}
94
+ <div>
95
+ <div className="flex items-center justify-between mb-1">
96
+ <label className="text-[10px] font-medium text-foreground-subtle">Max Parallel</label>
97
+ <button
98
+ type="button"
99
+ onClick={() => onMaxConcurrentInferencesChange(isAuto ? 2 : null)}
100
+ className={`text-[9px] font-medium px-1.5 py-0.5 rounded transition-colors ${
101
+ isAuto
102
+ ? 'bg-primary/15 text-primary'
103
+ : 'bg-surface text-foreground-subtle hover:text-foreground'
104
+ }`}
105
+ >
106
+ {isAuto ? 'Auto' : 'Manual'}
107
+ </button>
108
+ </div>
109
+ {isAuto ? (
110
+ <p className="text-[10px] text-foreground-subtle/60">
111
+ Auto-detected from hardware (CPU cores, GPU)
112
+ </p>
113
+ ) : (
114
+ <div className="flex items-center gap-3">
115
+ <input
116
+ type="range"
117
+ className="flex-1 h-1 accent-primary cursor-pointer"
118
+ min={1}
119
+ max={8}
120
+ value={maxConcurrentInferences}
121
+ onChange={(e) => onMaxConcurrentInferencesChange(Number(e.target.value))}
122
+ />
123
+ <span className="text-xs text-foreground tabular-nums min-w-[3rem] text-right">
124
+ {maxConcurrentInferences}
125
+ </span>
126
+ </div>
127
+ )}
128
+ </div>
129
+ </div>
130
+ )
131
+ }
@@ -0,0 +1,30 @@
1
+ import { Video, Eye, CircleDot, Mic } from 'lucide-react'
2
+ import type { LucideIcon } from 'lucide-react'
3
+
4
+ const CAPABILITIES: Record<string, { icon: LucideIcon; label: string }> = {
5
+ streaming: { icon: Video, label: 'Stream' },
6
+ detection: { icon: Eye, label: 'Detection' },
7
+ recording: { icon: CircleDot, label: 'Recording' },
8
+ audio: { icon: Mic, label: 'Audio' },
9
+ }
10
+
11
+ interface CapabilityBadgesProps {
12
+ capabilities: string[]
13
+ }
14
+
15
+ export function CapabilityBadges({ capabilities }: CapabilityBadgesProps) {
16
+ return (
17
+ <div className="flex items-center gap-1">
18
+ {capabilities.map((cap) => {
19
+ const info = CAPABILITIES[cap]
20
+ if (!info) return null
21
+ const Icon = info.icon
22
+ return (
23
+ <span key={cap} className="inline-flex items-center gap-1 rounded-md bg-surface-hover px-1.5 py-0.5 text-[10px] text-foreground-subtle" title={info.label}>
24
+ <Icon className="h-3 w-3" />
25
+ </span>
26
+ )
27
+ })}
28
+ </div>
29
+ )
30
+ }
@@ -0,0 +1,42 @@
1
+ import { Cctv, Server, Radio, Home, Wifi } from 'lucide-react'
2
+ import type { LucideIcon } from 'lucide-react'
3
+
4
+ const PROVIDERS: Record<string, { icon: LucideIcon; color: string; label: string }> = {
5
+ frigate: { icon: Cctv, color: '#3b82f6', label: 'Frigate' },
6
+ scrypted: { icon: Server, color: '#a855f7', label: 'Scrypted' },
7
+ reolink: { icon: Radio, color: '#06b6d4', label: 'Reolink' },
8
+ homeassistant: { icon: Home, color: '#22d3ee', label: 'Home Assistant' },
9
+ rtsp: { icon: Wifi, color: '#78716c', label: 'RTSP' },
10
+ onvif: { icon: Wifi, color: '#78716c', label: 'ONVIF' },
11
+ }
12
+
13
+ interface ProviderIconProps {
14
+ type: string
15
+ size?: 'sm' | 'md' | 'lg'
16
+ showLabel?: boolean
17
+ }
18
+
19
+ const SIZES = { sm: 'h-4 w-4', md: 'h-5 w-5', lg: 'h-6 w-6' }
20
+ const CONTAINER_SIZES = { sm: 'h-7 w-7', md: 'h-8 w-8', lg: 'h-10 w-10' }
21
+
22
+ export function ProviderIcon({ type, size = 'md', showLabel }: ProviderIconProps) {
23
+ const provider = PROVIDERS[type] ?? PROVIDERS['rtsp']!
24
+ const Icon = provider.icon
25
+
26
+ return (
27
+ <div className="flex items-center gap-2">
28
+ <div className={`flex items-center justify-center rounded-lg ${CONTAINER_SIZES[size]}`} style={{ backgroundColor: `${provider.color}15` }}>
29
+ <Icon className={SIZES[size]} style={{ color: provider.color }} />
30
+ </div>
31
+ {showLabel && <span className="text-xs font-medium text-foreground-subtle">{provider.label}</span>}
32
+ </div>
33
+ )
34
+ }
35
+
36
+ export function getProviderColor(type: string): string {
37
+ return PROVIDERS[type]?.color ?? '#78716c'
38
+ }
39
+
40
+ export function getProviderLabel(type: string): string {
41
+ return PROVIDERS[type]?.label ?? type
42
+ }
@@ -0,0 +1,23 @@
1
+ const STATUS_STYLES: Record<string, { bg: string; text: string; dot: string; label: string }> = {
2
+ running: { bg: 'bg-success/10', text: 'text-success', dot: 'bg-success', label: 'Running' },
3
+ stopped: { bg: 'bg-foreground-subtle/10', text: 'text-foreground-subtle', dot: 'bg-foreground-subtle', label: 'Stopped' },
4
+ error: { bg: 'bg-danger/10', text: 'text-danger', dot: 'bg-danger', label: 'Error' },
5
+ online: { bg: 'bg-success/10', text: 'text-success', dot: 'bg-success', label: 'Online' },
6
+ offline: { bg: 'bg-foreground-subtle/10', text: 'text-foreground-subtle', dot: 'bg-foreground-subtle', label: 'Offline' },
7
+ idle: { bg: 'bg-info/10', text: 'text-info', dot: 'bg-info', label: 'Idle' },
8
+ }
9
+
10
+ interface StatusBadgeProps {
11
+ status: string
12
+ }
13
+
14
+ export function StatusBadge({ status }: StatusBadgeProps) {
15
+ const style = STATUS_STYLES[status] ?? STATUS_STYLES['stopped']!
16
+
17
+ return (
18
+ <span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-medium ${style.bg} ${style.text}`}>
19
+ <span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} />
20
+ {style.label}
21
+ </span>
22
+ )
23
+ }