@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,55 @@
1
+ const CAPABILITY_COLORS: Record<string, string> = {
2
+ // detection / vision
3
+ detection: 'bg-green-500/10 text-green-400',
4
+ detector: 'bg-green-500/10 text-green-400',
5
+ // streaming
6
+ streaming: 'bg-blue-500/10 text-blue-400',
7
+ decode: 'bg-blue-500/10 text-blue-400',
8
+ decoder: 'bg-blue-500/10 text-blue-400',
9
+ // recording
10
+ recording: 'bg-orange-500/10 text-orange-400',
11
+ recorder: 'bg-orange-500/10 text-orange-400',
12
+ // transcoding
13
+ transcoding: 'bg-purple-500/10 text-purple-400',
14
+ transcoder: 'bg-purple-500/10 text-purple-400',
15
+ transcode: 'bg-purple-500/10 text-purple-400',
16
+ // restream
17
+ restream: 'bg-cyan-500/10 text-cyan-400',
18
+ restreamer: 'bg-cyan-500/10 text-cyan-400',
19
+ // storage
20
+ storage: 'bg-yellow-500/10 text-yellow-400',
21
+ // notification
22
+ notification: 'bg-pink-500/10 text-pink-400',
23
+ notifier: 'bg-pink-500/10 text-pink-400',
24
+ // faces
25
+ faces: 'bg-indigo-500/10 text-indigo-400',
26
+ // default
27
+ }
28
+
29
+ interface CapabilityBadgeProps {
30
+ /** Capability name (string) or declaration object ({ name, mode }) */
31
+ capability: string | { name: string; mode?: string }
32
+ /** Optional mode label shown after a separator (overrides object mode) */
33
+ mode?: string
34
+ size?: 'sm' | 'xs'
35
+ }
36
+
37
+ export function CapabilityBadge({ capability, mode, size = 'xs' }: CapabilityBadgeProps) {
38
+ const capName = typeof capability === 'string' ? capability : capability.name
39
+ const capMode = mode ?? (typeof capability === 'object' ? capability.mode : undefined)
40
+ const lower = capName.toLowerCase()
41
+ const color = CAPABILITY_COLORS[lower] ?? 'bg-primary/10 text-primary'
42
+ const textSize = size === 'sm' ? 'text-xs' : 'text-[10px]'
43
+
44
+ return (
45
+ <span className={`inline-flex items-center rounded-md px-1.5 py-0.5 font-medium gap-1 ${textSize} ${color}`}>
46
+ {capName}
47
+ {capMode && (
48
+ <>
49
+ <span className="opacity-40">·</span>
50
+ <span className="opacity-70">{capMode}</span>
51
+ </>
52
+ )}
53
+ </span>
54
+ )
55
+ }
@@ -0,0 +1,133 @@
1
+ import { CheckCircle } from 'lucide-react'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export type CapabilityMode = 'singleton' | 'collection'
8
+
9
+ export interface CapabilityProvider {
10
+ addonId: string
11
+ addonName: string
12
+ active: boolean
13
+ }
14
+
15
+ export interface CapabilityEntry {
16
+ name: string
17
+ mode: CapabilityMode
18
+ providers: CapabilityProvider[]
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Color helpers
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const CAPABILITY_BORDER_COLORS: Record<string, string> = {
26
+ detection: 'border-green-500',
27
+ detector: 'border-green-500',
28
+ streaming: 'border-blue-500',
29
+ decode: 'border-blue-500',
30
+ decoder: 'border-blue-500',
31
+ recording: 'border-orange-500',
32
+ recorder: 'border-orange-500',
33
+ transcoding: 'border-purple-500',
34
+ transcoder: 'border-purple-500',
35
+ restream: 'border-cyan-500',
36
+ restreamer: 'border-cyan-500',
37
+ storage: 'border-yellow-500',
38
+ notification: 'border-pink-500',
39
+ notifier: 'border-pink-500',
40
+ faces: 'border-indigo-500',
41
+ }
42
+
43
+ const CAPABILITY_BG_COLORS: Record<string, string> = {
44
+ detection: 'bg-green-500/5',
45
+ detector: 'bg-green-500/5',
46
+ streaming: 'bg-blue-500/5',
47
+ decode: 'bg-blue-500/5',
48
+ decoder: 'bg-blue-500/5',
49
+ recording: 'bg-orange-500/5',
50
+ recorder: 'bg-orange-500/5',
51
+ transcoding: 'bg-purple-500/5',
52
+ transcoder: 'bg-purple-500/5',
53
+ restream: 'bg-cyan-500/5',
54
+ restreamer: 'bg-cyan-500/5',
55
+ storage: 'bg-yellow-500/5',
56
+ notification: 'bg-pink-500/5',
57
+ notifier: 'bg-pink-500/5',
58
+ faces: 'bg-indigo-500/5',
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // CapabilityCard
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function CapabilityCard({ entry }: { entry: CapabilityEntry }) {
66
+ const lower = entry.name.toLowerCase()
67
+ const borderColor = CAPABILITY_BORDER_COLORS[lower] ?? 'border-primary'
68
+ const bgColor = CAPABILITY_BG_COLORS[lower] ?? 'bg-primary/5'
69
+ const activeProvider = entry.providers.find((p) => p.active)
70
+
71
+ return (
72
+ <div className={`rounded-lg border-t-2 border border-border ${borderColor} ${bgColor} p-3 space-y-2`}>
73
+ <div className="flex items-center justify-between gap-2">
74
+ <span className="text-xs font-semibold text-foreground capitalize">{entry.name}</span>
75
+ <span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${entry.mode === 'singleton' ? 'bg-primary/10 text-primary' : 'bg-info/10 text-info'}`}>
76
+ {entry.mode}
77
+ </span>
78
+ </div>
79
+
80
+ {entry.mode === 'singleton' ? (
81
+ <div>
82
+ {activeProvider ? (
83
+ <div className="flex items-center gap-1.5 text-[10px] text-foreground">
84
+ <CheckCircle className="h-3 w-3 text-green-400 shrink-0" />
85
+ <span className="font-medium">{activeProvider.addonName}</span>
86
+ <span className="rounded-full bg-green-500/10 text-green-400 px-1.5 py-0.5 font-medium ml-auto">ACTIVE</span>
87
+ </div>
88
+ ) : (
89
+ <div className="text-[10px] text-foreground-subtle italic">No active provider</div>
90
+ )}
91
+ </div>
92
+ ) : (
93
+ <div className="space-y-1">
94
+ {entry.providers.length === 0 && (
95
+ <div className="text-[10px] text-foreground-subtle italic">No providers</div>
96
+ )}
97
+ {entry.providers.map((p) => (
98
+ <div key={p.addonId} className="flex items-center justify-between gap-2 text-[10px]">
99
+ <span className="text-foreground-subtle">{p.addonName}</span>
100
+ {p.active && (
101
+ <span className="rounded-full bg-green-500/10 text-green-400 px-1.5 py-0.5 font-medium">ACTIVE</span>
102
+ )}
103
+ </div>
104
+ ))}
105
+ </div>
106
+ )}
107
+ </div>
108
+ )
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // CapabilityMap
113
+ // ---------------------------------------------------------------------------
114
+
115
+ interface CapabilityMapProps {
116
+ capabilities: CapabilityEntry[]
117
+ }
118
+
119
+ export function CapabilityMap({ capabilities }: CapabilityMapProps) {
120
+ if (capabilities.length === 0) {
121
+ return (
122
+ <div className="text-xs text-foreground-subtle text-center py-8">No capabilities registered</div>
123
+ )
124
+ }
125
+
126
+ return (
127
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
128
+ {capabilities.map((cap) => (
129
+ <CapabilityCard key={cap.name} entry={cap} />
130
+ ))}
131
+ </div>
132
+ )
133
+ }
@@ -0,0 +1,119 @@
1
+ import { ArrowRight, RotateCcw, Zap } from 'lucide-react'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface UpdateEntry {
8
+ id: string
9
+ name: string
10
+ currentVersion: string
11
+ newVersion: string
12
+ changelog?: string
13
+ requiresRestart: boolean
14
+ }
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // UpdatesList
18
+ // ---------------------------------------------------------------------------
19
+
20
+ interface UpdatesListProps {
21
+ updates: UpdateEntry[]
22
+ onUpdate: (id: string) => void
23
+ onUpdateAll: () => void
24
+ onCheckUpdates: () => void
25
+ isChecking: boolean
26
+ }
27
+
28
+ export function UpdatesList({ updates, onUpdate, onUpdateAll, onCheckUpdates, isChecking }: UpdatesListProps) {
29
+ return (
30
+ <div className="space-y-4">
31
+ {/* Toolbar */}
32
+ <div className="flex items-center justify-between gap-3">
33
+ <button
34
+ type="button"
35
+ onClick={onCheckUpdates}
36
+ disabled={isChecking}
37
+ className="flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs text-foreground hover:bg-surface transition-colors disabled:opacity-50"
38
+ >
39
+ <RotateCcw className={`h-3.5 w-3.5 ${isChecking ? 'animate-spin' : ''}`} />
40
+ {isChecking ? 'Checking…' : 'Check for updates'}
41
+ </button>
42
+
43
+ {updates.length > 0 && (
44
+ <button
45
+ type="button"
46
+ onClick={onUpdateAll}
47
+ className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground hover:bg-primary/90 transition-colors"
48
+ >
49
+ Update all ({updates.length})
50
+ </button>
51
+ )}
52
+ </div>
53
+
54
+ {updates.length === 0 && !isChecking && (
55
+ <div className="rounded-lg border border-border bg-surface py-10 text-center text-xs text-foreground-subtle">
56
+ All packages are up to date
57
+ </div>
58
+ )}
59
+
60
+ {updates.length > 0 && (
61
+ <div className="space-y-2">
62
+ {updates.map((update) => (
63
+ <div
64
+ key={update.id}
65
+ className="rounded-lg border border-border bg-surface px-4 py-3 flex items-start justify-between gap-4"
66
+ >
67
+ <div className="flex items-start gap-3 min-w-0">
68
+ {/* Icon letter */}
69
+ <div className="flex-shrink-0 h-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center">
70
+ <span className="text-xs font-bold text-primary uppercase">
71
+ {update.name.charAt(0)}
72
+ </span>
73
+ </div>
74
+
75
+ <div className="min-w-0">
76
+ <div className="flex items-center gap-2 flex-wrap">
77
+ <span className="text-sm font-semibold text-foreground">{update.name}</span>
78
+ {update.requiresRestart ? (
79
+ <span className="inline-flex items-center gap-1 rounded-full bg-warning/10 text-warning px-2 py-0.5 text-[10px] font-medium">
80
+ <RotateCcw className="h-2.5 w-2.5" />
81
+ Restart
82
+ </span>
83
+ ) : (
84
+ <span className="inline-flex items-center gap-1 rounded-full bg-green-500/10 text-green-400 px-2 py-0.5 text-[10px] font-medium">
85
+ <Zap className="h-2.5 w-2.5" />
86
+ Hot-swap
87
+ </span>
88
+ )}
89
+ </div>
90
+
91
+ {/* Version diff */}
92
+ <div className="flex items-center gap-1.5 mt-1 text-[10px]">
93
+ <span className="text-foreground-subtle font-mono">v{update.currentVersion}</span>
94
+ <ArrowRight className="h-3 w-3 text-foreground-subtle" />
95
+ <span className="text-green-400 font-mono font-medium">v{update.newVersion}</span>
96
+ </div>
97
+
98
+ {update.changelog && (
99
+ <p className="mt-1.5 text-[10px] text-foreground-subtle leading-relaxed line-clamp-2">
100
+ {update.changelog}
101
+ </p>
102
+ )}
103
+ </div>
104
+ </div>
105
+
106
+ <button
107
+ type="button"
108
+ onClick={() => onUpdate(update.id)}
109
+ className="flex-shrink-0 rounded-md bg-primary/10 text-primary hover:bg-primary/20 px-3 py-1.5 text-xs font-medium transition-colors"
110
+ >
111
+ Update
112
+ </button>
113
+ </div>
114
+ ))}
115
+ </div>
116
+ )}
117
+ </div>
118
+ )
119
+ }
@@ -0,0 +1,281 @@
1
+ import { useState } from 'react'
2
+ import {
3
+ ChevronDown,
4
+ ChevronUp,
5
+ Cpu,
6
+ HardDrive,
7
+ Monitor,
8
+ Layers,
9
+ Activity,
10
+ Server,
11
+ } from 'lucide-react'
12
+ import { TaskList } from './TaskList'
13
+ import { ProcessList } from './ProcessList'
14
+ import type { AgentTask } from './TaskList'
15
+ import type { ProcessEntry } from './ProcessList'
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface AgentRegistrationInfo {
22
+ id: string
23
+ name: string
24
+ capabilities: string[]
25
+ host: string
26
+ port: number
27
+ platform: string
28
+ arch: string
29
+ cpuModel?: string
30
+ cpuCores: number
31
+ memoryMB: number
32
+ gpuModel?: string
33
+ pythonRuntimes: string[]
34
+ httpPort: number
35
+ }
36
+
37
+ export interface AgentRuntimeStatus {
38
+ activeCameras: number
39
+ cpuPercent: number
40
+ memoryPercent: number
41
+ fps: Record<string, number>
42
+ errors: string[]
43
+ }
44
+
45
+ export interface ConnectedAgent {
46
+ info: AgentRegistrationInfo
47
+ status?: AgentRuntimeStatus
48
+ connectedSince: number
49
+ isHub?: boolean
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Sub-components
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function StatusDot({ online }: { online: boolean }) {
57
+ return (
58
+ <span
59
+ className={`inline-block h-2.5 w-2.5 rounded-full shrink-0 ${online ? 'bg-green-400' : 'bg-red-400'}`}
60
+ title={online ? 'Online' : 'Offline'}
61
+ />
62
+ )
63
+ }
64
+
65
+ function Bar({ percent, color = 'bg-primary' }: { percent: number; color?: string }) {
66
+ return (
67
+ <div className="h-1.5 w-full rounded-full bg-border overflow-hidden">
68
+ <div
69
+ className={`h-full rounded-full transition-all ${color}`}
70
+ style={{ width: `${Math.min(100, percent)}%` }}
71
+ />
72
+ </div>
73
+ )
74
+ }
75
+
76
+ const CAPABILITY_COLORS: Record<string, string> = {
77
+ decode: 'bg-blue-500/10 text-blue-400',
78
+ decoder: 'bg-blue-500/10 text-blue-400',
79
+ detect: 'bg-green-500/10 text-green-400',
80
+ detector: 'bg-green-500/10 text-green-400',
81
+ record: 'bg-orange-500/10 text-orange-400',
82
+ recorder: 'bg-orange-500/10 text-orange-400',
83
+ transcode: 'bg-purple-500/10 text-purple-400',
84
+ transcoder: 'bg-purple-500/10 text-purple-400',
85
+ restream: 'bg-cyan-500/10 text-cyan-400',
86
+ restreamer: 'bg-cyan-500/10 text-cyan-400',
87
+ }
88
+
89
+ const RUNTIME_COLORS: Record<string, string> = {
90
+ onnx: 'bg-yellow-500/10 text-yellow-400',
91
+ coreml: 'bg-pink-500/10 text-pink-400',
92
+ pytorch: 'bg-orange-500/10 text-orange-400',
93
+ openvino:'bg-blue-500/10 text-blue-400',
94
+ }
95
+
96
+ function CapBadge({ cap }: { cap: string }) {
97
+ const lower = cap.toLowerCase()
98
+ const color = CAPABILITY_COLORS[lower] ?? 'bg-primary/10 text-primary'
99
+ return (
100
+ <span className={`inline-flex rounded-md px-1.5 py-0.5 text-[10px] font-medium ${color}`}>
101
+ {cap}
102
+ </span>
103
+ )
104
+ }
105
+
106
+ function RuntimeBadge({ runtime }: { runtime: string }) {
107
+ const lower = runtime.toLowerCase()
108
+ const color = RUNTIME_COLORS[lower] ?? 'bg-foreground-subtle/10 text-foreground-subtle'
109
+ return (
110
+ <span className={`inline-flex rounded-md px-1.5 py-0.5 text-[10px] font-medium ${color}`}>
111
+ {runtime}
112
+ </span>
113
+ )
114
+ }
115
+
116
+ function formatUptime(connectedSince: number): string {
117
+ const seconds = Math.floor((Date.now() - connectedSince) / 1000)
118
+ if (seconds < 60) return `${seconds}s`
119
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
120
+ return `${Math.floor(seconds / 3600)}h`
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // AgentCard
125
+ // ---------------------------------------------------------------------------
126
+
127
+ interface AgentCardProps {
128
+ agent: ConnectedAgent
129
+ processes: ProcessEntry[]
130
+ tasks: AgentTask[]
131
+ defaultExpanded?: boolean
132
+ online?: boolean
133
+ }
134
+
135
+ export function AgentCard({ agent, processes, tasks, defaultExpanded = false, online = true }: AgentCardProps) {
136
+ const [expanded, setExpanded] = useState(defaultExpanded)
137
+ const { info, status } = agent
138
+
139
+ return (
140
+ <div className={`rounded-xl border border-border bg-surface overflow-hidden transition-opacity ${!online ? 'opacity-50' : ''}`}>
141
+ {/* Header */}
142
+ <button
143
+ type="button"
144
+ className="w-full flex items-start justify-between gap-3 px-4 py-3 hover:bg-primary/5 transition-colors text-left"
145
+ onClick={() => setExpanded((e) => !e)}
146
+ >
147
+ <div className="flex items-center gap-2.5 min-w-0">
148
+ <StatusDot online={online} />
149
+ <Server className="h-4 w-4 shrink-0 text-foreground-subtle" />
150
+ <div className="min-w-0">
151
+ <div className="flex items-center gap-2 flex-wrap">
152
+ <span className="text-sm font-semibold text-foreground truncate">{info.name}</span>
153
+ {agent.isHub && (
154
+ <span className="rounded-full bg-primary/10 text-primary px-2 py-0.5 text-[10px] font-medium shrink-0">HUB</span>
155
+ )}
156
+ {!online && (
157
+ <span className="rounded-full bg-foreground-subtle/10 text-foreground-subtle px-2 py-0.5 text-[10px] shrink-0">Offline</span>
158
+ )}
159
+ </div>
160
+ <div className="flex items-center gap-2 mt-0.5 text-[10px] text-foreground-subtle flex-wrap">
161
+ <span className="font-mono">{info.host}:{info.port}</span>
162
+ <span>{info.platform}/{info.arch}</span>
163
+ {status && (
164
+ <>
165
+ <span>CPU {status.cpuPercent.toFixed(0)}%</span>
166
+ <span>MEM {status.memoryPercent.toFixed(0)}%</span>
167
+ </>
168
+ )}
169
+ </div>
170
+ </div>
171
+ </div>
172
+
173
+ <div className="flex items-center gap-2 shrink-0">
174
+ {/* task count badge */}
175
+ {tasks.length > 0 && (
176
+ <span className="rounded-full bg-info/10 text-info px-2 py-0.5 text-[10px] font-medium">
177
+ {tasks.length} task{tasks.length !== 1 ? 's' : ''}
178
+ </span>
179
+ )}
180
+ {/* runtime badges */}
181
+ {info.pythonRuntimes.slice(0, 2).map((rt) => (
182
+ <RuntimeBadge key={rt} runtime={rt} />
183
+ ))}
184
+ <span className="text-[10px] text-foreground-subtle">up {formatUptime(agent.connectedSince)}</span>
185
+ {expanded ? <ChevronUp className="h-4 w-4 text-foreground-subtle" /> : <ChevronDown className="h-4 w-4 text-foreground-subtle" />}
186
+ </div>
187
+ </button>
188
+
189
+ {/* Load bars (always visible when online) */}
190
+ {status && !expanded && (
191
+ <div className="px-4 pb-3 space-y-1">
192
+ <Bar percent={status.cpuPercent} color="bg-blue-400" />
193
+ <Bar percent={status.memoryPercent} color="bg-purple-400" />
194
+ </div>
195
+ )}
196
+
197
+ {/* Expanded content */}
198
+ {expanded && (
199
+ <div className="border-t border-border divide-y divide-border">
200
+ {/* Hardware details */}
201
+ <div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[10px] text-foreground-subtle">
202
+ <div className="flex items-center gap-1.5">
203
+ <Cpu className="h-3 w-3 shrink-0" />
204
+ <span className="truncate">{info.cpuModel ?? `${info.cpuCores} cores`}</span>
205
+ </div>
206
+ <div className="flex items-center gap-1.5">
207
+ <HardDrive className="h-3 w-3 shrink-0" />
208
+ <span>{Math.round(info.memoryMB / 1024)} GB RAM</span>
209
+ </div>
210
+ <div className="flex items-center gap-1.5">
211
+ <Monitor className="h-3 w-3 shrink-0" />
212
+ <span>{info.platform}/{info.arch}</span>
213
+ </div>
214
+ {info.gpuModel && (
215
+ <div className="flex items-center gap-1.5">
216
+ <Layers className="h-3 w-3 shrink-0" />
217
+ <span className="truncate">{info.gpuModel}</span>
218
+ </div>
219
+ )}
220
+ </div>
221
+
222
+ {/* Load bars */}
223
+ {status && (
224
+ <div className="px-4 py-3 space-y-2">
225
+ <div className="flex items-center justify-between text-[10px] text-foreground-subtle mb-1">
226
+ <span className="flex items-center gap-1">
227
+ <Activity className="h-3 w-3" />
228
+ {status.activeCameras} active camera{status.activeCameras !== 1 ? 's' : ''}
229
+ </span>
230
+ <span>CPU {status.cpuPercent.toFixed(0)}% / MEM {status.memoryPercent.toFixed(0)}%</span>
231
+ </div>
232
+ <Bar percent={status.cpuPercent} color="bg-blue-400" />
233
+ <Bar percent={status.memoryPercent} color="bg-purple-400" />
234
+ </div>
235
+ )}
236
+
237
+ {/* Capabilities */}
238
+ <div className="px-4 py-3 space-y-2">
239
+ <div className="text-[10px] font-medium text-foreground-subtle uppercase tracking-wide">Capabilities</div>
240
+ <div className="flex flex-wrap gap-1.5">
241
+ {info.capabilities.map((cap) => (
242
+ <CapBadge key={cap} cap={cap} />
243
+ ))}
244
+ {info.capabilities.length === 0 && (
245
+ <span className="text-[10px] text-foreground-subtle italic">None registered</span>
246
+ )}
247
+ </div>
248
+ </div>
249
+
250
+ {/* Runtimes */}
251
+ {info.pythonRuntimes.length > 0 && (
252
+ <div className="px-4 py-3 space-y-2">
253
+ <div className="text-[10px] font-medium text-foreground-subtle uppercase tracking-wide">Runtimes</div>
254
+ <div className="flex flex-wrap gap-1.5">
255
+ {info.pythonRuntimes.map((rt) => (
256
+ <RuntimeBadge key={rt} runtime={rt} />
257
+ ))}
258
+ </div>
259
+ </div>
260
+ )}
261
+
262
+ {/* Active Tasks */}
263
+ <div className="px-4 py-3 space-y-2">
264
+ <div className="text-[10px] font-medium text-foreground-subtle uppercase tracking-wide">
265
+ Active Tasks ({tasks.length})
266
+ </div>
267
+ <TaskList tasks={tasks} />
268
+ </div>
269
+
270
+ {/* Processes */}
271
+ <div className="px-4 py-3 space-y-2">
272
+ <div className="text-[10px] font-medium text-foreground-subtle uppercase tracking-wide">
273
+ Processes ({processes.length})
274
+ </div>
275
+ <ProcessList processes={processes} />
276
+ </div>
277
+ </div>
278
+ )}
279
+ </div>
280
+ )
281
+ }