@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,107 @@
1
+ import { useState } from 'react'
2
+ import { Camera, Eye, Activity, Crosshair, HardDrive } from 'lucide-react'
3
+
4
+ const STREAM_SOURCES = ['WebRTC', 'HLS', 'MJPEG', 'go2rtc'] as const
5
+ const OVERLAY_OPTIONS = ['BBox', 'Segm', 'Zones', 'Trail'] as const
6
+
7
+ interface StreamAreaProps {
8
+ readonly deviceName: string
9
+ readonly detectionsToday: number
10
+ readonly inferenceMs: number
11
+ readonly activeTracks: number
12
+ readonly storageGb: number
13
+ }
14
+
15
+ export function StreamArea({ deviceName, detectionsToday, inferenceMs, activeTracks, storageGb }: StreamAreaProps) {
16
+ const [activeSource, setActiveSource] = useState<string>('WebRTC')
17
+ const [activeOverlays, setActiveOverlays] = useState<ReadonlySet<string>>(new Set())
18
+
19
+ function toggleOverlay(overlay: string) {
20
+ setActiveOverlays((prev) => {
21
+ const next = new Set(prev)
22
+ if (next.has(overlay)) {
23
+ next.delete(overlay)
24
+ } else {
25
+ next.add(overlay)
26
+ }
27
+ return next
28
+ })
29
+ }
30
+
31
+ return (
32
+ <div className="space-y-3">
33
+ {/* Stream preview */}
34
+ <div className="relative w-full max-w-[320px] aspect-video bg-background rounded-lg border border-border flex items-center justify-center overflow-hidden">
35
+ <Camera className="h-10 w-10 text-foreground-subtle/20" />
36
+ <div className="absolute bottom-2 left-2 text-[10px] text-foreground-subtle bg-background/80 rounded px-1.5 py-0.5">
37
+ {deviceName}
38
+ </div>
39
+ </div>
40
+
41
+ {/* Source selector chips */}
42
+ <div className="flex flex-wrap gap-1.5">
43
+ <span className="text-[10px] text-foreground-subtle mr-1 self-center">Source:</span>
44
+ {STREAM_SOURCES.map((source) => (
45
+ <button
46
+ key={source}
47
+ onClick={() => setActiveSource(source)}
48
+ className={`rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors ${
49
+ activeSource === source
50
+ ? 'bg-primary/15 text-primary border border-primary/30'
51
+ : 'bg-surface border border-border text-foreground-subtle hover:text-foreground'
52
+ }`}
53
+ >
54
+ {source}
55
+ </button>
56
+ ))}
57
+ </div>
58
+
59
+ {/* Overlay toggle chips */}
60
+ <div className="flex flex-wrap gap-1.5">
61
+ <span className="text-[10px] text-foreground-subtle mr-1 self-center">Overlay:</span>
62
+ {OVERLAY_OPTIONS.map((overlay) => {
63
+ const isActive = activeOverlays.has(overlay)
64
+ return (
65
+ <button
66
+ key={overlay}
67
+ onClick={() => toggleOverlay(overlay)}
68
+ className={`rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors ${
69
+ isActive
70
+ ? 'bg-info/15 text-info border border-info/30'
71
+ : 'bg-surface border border-border text-foreground-subtle hover:text-foreground'
72
+ }`}
73
+ >
74
+ {overlay}
75
+ </button>
76
+ )
77
+ })}
78
+ </div>
79
+
80
+ {/* Quick stats */}
81
+ <div className="grid grid-cols-4 gap-2">
82
+ <StatCounter icon={Eye} label="Today" value={String(detectionsToday)} />
83
+ <StatCounter icon={Activity} label="Inference" value={`${inferenceMs}ms`} />
84
+ <StatCounter icon={Crosshair} label="Tracks" value={String(activeTracks)} />
85
+ <StatCounter icon={HardDrive} label="Storage" value={`${storageGb.toFixed(1)}GB`} />
86
+ </div>
87
+ </div>
88
+ )
89
+ }
90
+
91
+ function StatCounter({
92
+ icon: Icon,
93
+ label,
94
+ value,
95
+ }: {
96
+ readonly icon: React.ComponentType<{ className?: string }>
97
+ readonly label: string
98
+ readonly value: string
99
+ }) {
100
+ return (
101
+ <div className="flex flex-col items-center gap-1 rounded-lg border border-border bg-surface px-2 py-2">
102
+ <Icon className="h-3.5 w-3.5 text-foreground-subtle" />
103
+ <span className="text-xs font-semibold text-foreground">{value}</span>
104
+ <span className="text-[9px] text-foreground-subtle">{label}</span>
105
+ </div>
106
+ )
107
+ }
@@ -0,0 +1,113 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { Settings, Package } from 'lucide-react'
3
+ import { useBackendClient } from '../../../hooks/useBackendClient'
4
+
5
+ interface AddonsTabProps {
6
+ readonly deviceId: string
7
+ }
8
+
9
+ interface RawAddon {
10
+ readonly id: string
11
+ readonly packageName: string
12
+ readonly slot: string | null
13
+ }
14
+
15
+ interface AddonDisplay {
16
+ readonly id: string
17
+ readonly name: string
18
+ readonly type: string
19
+ readonly enabled: boolean
20
+ readonly version?: string
21
+ }
22
+
23
+ function toAddonDisplay(raw: RawAddon): AddonDisplay {
24
+ return {
25
+ id: raw.id,
26
+ name: raw.packageName ?? raw.id,
27
+ type: raw.slot ?? 'addon',
28
+ enabled: true,
29
+ }
30
+ }
31
+
32
+ export function AddonsTab({ deviceId: _deviceId }: AddonsTabProps) {
33
+ const client = useBackendClient()
34
+
35
+ const { data: addonsData, isLoading } = useQuery({
36
+ queryKey: ['bridge-addons'],
37
+ queryFn: () => client.bridgeListAddons(),
38
+ })
39
+
40
+ const rawAddons = (addonsData ?? []) as unknown as readonly RawAddon[]
41
+ const addons = rawAddons.map(toAddonDisplay)
42
+ const visionAddons = addons.filter(
43
+ (a) => a.type === 'vision' || a.type === 'detection' || a.type === 'inference'
44
+ )
45
+
46
+ if (isLoading) {
47
+ return (
48
+ <div className="space-y-2">
49
+ {[1, 2, 3].map((i) => (
50
+ <div key={i} className="h-16 rounded-lg border border-border bg-surface animate-pulse" />
51
+ ))}
52
+ </div>
53
+ )
54
+ }
55
+
56
+ if (!visionAddons.length && !addons.length) {
57
+ return (
58
+ <div className="rounded-lg border border-border bg-surface overflow-hidden">
59
+ <div className="border-b border-border px-4 py-2.5">
60
+ <h2 className="text-xs font-semibold text-foreground uppercase tracking-wider">Vision Addons</h2>
61
+ </div>
62
+ <div className="flex flex-col items-center justify-center py-16 text-foreground-subtle">
63
+ <Package className="h-8 w-8 mb-3 opacity-30" />
64
+ <p className="text-sm">No vision addons installed</p>
65
+ <p className="text-xs mt-1 opacity-70">Install addons to enable detection and inference</p>
66
+ </div>
67
+ </div>
68
+ )
69
+ }
70
+
71
+ const displayAddons: readonly AddonDisplay[] = visionAddons.length > 0 ? visionAddons : addons
72
+
73
+ return (
74
+ <div className="space-y-4">
75
+ <div className="flex items-center justify-between">
76
+ <h2 className="text-xs font-semibold text-foreground uppercase tracking-wider">Vision Addons</h2>
77
+ <span className="text-[10px] text-foreground-subtle">
78
+ {displayAddons.length} addon{displayAddons.length !== 1 ? 's' : ''}
79
+ </span>
80
+ </div>
81
+
82
+ <div className="space-y-2">
83
+ {displayAddons.map((addon) => (
84
+ <div
85
+ key={addon.id}
86
+ className="flex items-center gap-3 rounded-lg border border-border bg-surface px-4 py-3"
87
+ >
88
+ <div className="flex items-center justify-center rounded-lg h-8 w-8 bg-primary/10 flex-shrink-0">
89
+ <Package className="h-4 w-4 text-primary" />
90
+ </div>
91
+ <div className="flex-1 min-w-0">
92
+ <p className="text-xs font-medium text-foreground truncate">{addon.name || addon.id}</p>
93
+ <p className="text-[10px] text-foreground-subtle">
94
+ {addon.type} {addon.version ? `v${addon.version}` : ''}
95
+ </p>
96
+ </div>
97
+ <div
98
+ className={`h-2 w-2 rounded-full flex-shrink-0 ${
99
+ addon.enabled !== false ? 'bg-success' : 'bg-foreground-subtle/30'
100
+ }`}
101
+ />
102
+ <button
103
+ className="p-1.5 rounded-md text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors"
104
+ title="Settings"
105
+ >
106
+ <Settings className="h-3.5 w-3.5" />
107
+ </button>
108
+ </div>
109
+ ))}
110
+ </div>
111
+ </div>
112
+ )
113
+ }
@@ -0,0 +1,129 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { Bell, User, Car, Dog, Mic } from 'lucide-react'
3
+ import { useBackendClient } from '../../../hooks/useBackendClient'
4
+
5
+ interface CameraEventsTabProps {
6
+ readonly deviceId: string
7
+ }
8
+
9
+ type EventCategory = 'person' | 'vehicle' | 'animal' | 'audio' | 'other'
10
+
11
+ const CATEGORY_CONFIG: Record<EventCategory, { color: string; borderColor: string; icon: React.ComponentType<{ className?: string }> }> = {
12
+ person: { color: 'text-success', borderColor: 'border-l-success', icon: User },
13
+ vehicle: { color: 'text-info', borderColor: 'border-l-info', icon: Car },
14
+ animal: { color: 'text-warning', borderColor: 'border-l-warning', icon: Dog },
15
+ audio: { color: 'text-danger', borderColor: 'border-l-danger', icon: Mic },
16
+ other: { color: 'text-foreground-subtle', borderColor: 'border-l-foreground-subtle', icon: Bell },
17
+ }
18
+
19
+ function classifyEvent(event: Record<string, unknown>): EventCategory {
20
+ const label = String(event.label ?? event.type ?? '').toLowerCase()
21
+ if (label.includes('person') || label.includes('face')) return 'person'
22
+ if (label.includes('vehicle') || label.includes('car') || label.includes('truck')) return 'vehicle'
23
+ if (label.includes('animal') || label.includes('dog') || label.includes('cat')) return 'animal'
24
+ if (label.includes('audio') || label.includes('sound')) return 'audio'
25
+ return 'other'
26
+ }
27
+
28
+ export function CameraEventsTab({ deviceId }: CameraEventsTabProps) {
29
+ const client = useBackendClient()
30
+
31
+ const { data: eventsData, isLoading } = useQuery({
32
+ queryKey: ['events', deviceId],
33
+ queryFn: () => client.getEvents(deviceId, { limit: 50 }),
34
+ enabled: !!deviceId,
35
+ refetchInterval: 5_000,
36
+ })
37
+
38
+ const events = (eventsData ?? []) as readonly Record<string, unknown>[]
39
+
40
+ if (isLoading) {
41
+ return (
42
+ <div className="space-y-2">
43
+ {[1, 2, 3, 4].map((i) => (
44
+ <div key={i} className="h-16 rounded-lg border border-border bg-surface animate-pulse" />
45
+ ))}
46
+ </div>
47
+ )
48
+ }
49
+
50
+ return (
51
+ <div className="space-y-4">
52
+ <div className="flex items-center justify-between">
53
+ <h2 className="text-xs font-semibold text-foreground uppercase tracking-wider">Detection Events</h2>
54
+ <span className="text-[10px] text-foreground-subtle">
55
+ {events.length} event{events.length !== 1 ? 's' : ''}
56
+ </span>
57
+ </div>
58
+
59
+ {/* Category legend */}
60
+ <div className="flex flex-wrap gap-3">
61
+ {(Object.entries(CATEGORY_CONFIG) as Array<[EventCategory, typeof CATEGORY_CONFIG[EventCategory]]>).map(
62
+ ([cat, cfg]) => {
63
+ const Icon = cfg.icon
64
+ return (
65
+ <span key={cat} className={`inline-flex items-center gap-1 text-[10px] ${cfg.color}`}>
66
+ <Icon className="h-3 w-3" />
67
+ {cat}
68
+ </span>
69
+ )
70
+ }
71
+ )}
72
+ </div>
73
+
74
+ {events.length === 0 ? (
75
+ <div className="rounded-lg border border-border bg-surface overflow-hidden">
76
+ <div className="flex flex-col items-center justify-center py-16 text-foreground-subtle">
77
+ <Bell className="h-8 w-8 mb-3 opacity-30" />
78
+ <p className="text-sm">No events yet</p>
79
+ <p className="text-xs mt-1 opacity-70">Detection events will appear here as they occur</p>
80
+ </div>
81
+ </div>
82
+ ) : (
83
+ <div className="space-y-1.5">
84
+ {events.map((event, index) => {
85
+ const category = classifyEvent(event)
86
+ const cfg = CATEGORY_CONFIG[category]
87
+ const Icon = cfg.icon
88
+ const label = String(event.label ?? event.type ?? 'Unknown')
89
+ const score = event.score != null ? Number(event.score) : null
90
+ const zone = event.zone ? String(event.zone) : null
91
+ const trackId = event.trackId ? String(event.trackId) : null
92
+ const timestamp = event.timestamp
93
+ ? new Date(event.timestamp as string | number).toLocaleTimeString('en-GB', { hour12: false })
94
+ : ''
95
+
96
+ return (
97
+ <div
98
+ key={event.id ? String(event.id) : index}
99
+ className={`flex items-start gap-3 rounded-lg border border-border border-l-4 ${cfg.borderColor} bg-surface px-3 py-2.5 hover:bg-surface-hover transition-colors`}
100
+ >
101
+ {/* Crop thumbnail placeholder */}
102
+ <div className="h-10 w-10 rounded bg-background flex items-center justify-center flex-shrink-0">
103
+ <Icon className={`h-4 w-4 ${cfg.color}`} />
104
+ </div>
105
+
106
+ <div className="flex-1 min-w-0">
107
+ <div className="flex items-center gap-2">
108
+ <span className="text-xs font-medium text-foreground capitalize">{label}</span>
109
+ {score != null && (
110
+ <span className="text-[9px] bg-surface-hover rounded px-1.5 py-0.5 text-foreground-subtle">
111
+ {(score * 100).toFixed(0)}%
112
+ </span>
113
+ )}
114
+ </div>
115
+ <div className="flex items-center gap-2 mt-0.5 text-[10px] text-foreground-subtle">
116
+ {trackId && <span>Track: {trackId}</span>}
117
+ {zone && <span>Zone: {zone}</span>}
118
+ </div>
119
+ </div>
120
+
121
+ <span className="text-[10px] text-foreground-subtle flex-shrink-0">{timestamp}</span>
122
+ </div>
123
+ )
124
+ })}
125
+ </div>
126
+ )}
127
+ </div>
128
+ )
129
+ }
@@ -0,0 +1,118 @@
1
+ import { useState } from 'react'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { ChevronDown, ChevronRight, GitBranch } from 'lucide-react'
4
+ import { useBackendClient } from '../../../hooks/useBackendClient'
5
+
6
+ interface PipelineTabProps {
7
+ readonly deviceId: string
8
+ }
9
+
10
+ interface PipelineStep {
11
+ readonly id: string
12
+ readonly name: string
13
+ readonly type: string
14
+ readonly enabled: boolean
15
+ readonly config: Record<string, unknown>
16
+ }
17
+
18
+ export function PipelineTab({ deviceId }: PipelineTabProps) {
19
+ const client = useBackendClient()
20
+ const [expandedSteps, setExpandedSteps] = useState<ReadonlySet<string>>(new Set())
21
+
22
+ const { data: pipelineData, isLoading } = useQuery({
23
+ queryKey: ['pipeline', deviceId],
24
+ queryFn: () => client.bridgeGetPipeline(deviceId),
25
+ enabled: !!deviceId,
26
+ })
27
+
28
+ const pipeline = pipelineData as Record<string, unknown> | null | undefined
29
+ const videoSteps = (pipeline?.video ?? []) as readonly PipelineStep[]
30
+
31
+ function toggleStep(stepId: string) {
32
+ setExpandedSteps((prev) => {
33
+ const next = new Set(prev)
34
+ if (next.has(stepId)) {
35
+ next.delete(stepId)
36
+ } else {
37
+ next.add(stepId)
38
+ }
39
+ return next
40
+ })
41
+ }
42
+
43
+ if (isLoading) {
44
+ return (
45
+ <div className="space-y-3">
46
+ {[1, 2, 3].map((i) => (
47
+ <div key={i} className="h-14 rounded-lg border border-border bg-surface animate-pulse" />
48
+ ))}
49
+ </div>
50
+ )
51
+ }
52
+
53
+ if (!videoSteps.length) {
54
+ return (
55
+ <div className="rounded-lg border border-border bg-surface overflow-hidden">
56
+ <div className="border-b border-border px-4 py-2.5">
57
+ <h2 className="text-xs font-semibold text-foreground uppercase tracking-wider">Pipeline Config</h2>
58
+ </div>
59
+ <div className="flex flex-col items-center justify-center py-16 text-foreground-subtle">
60
+ <GitBranch className="h-8 w-8 mb-3 opacity-30" />
61
+ <p className="text-sm">No pipeline configured</p>
62
+ <p className="text-xs mt-1 opacity-70">Pipeline steps will appear here once configured</p>
63
+ </div>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ return (
69
+ <div className="space-y-2">
70
+ <div className="flex items-center justify-between mb-3">
71
+ <h2 className="text-xs font-semibold text-foreground uppercase tracking-wider">Pipeline Steps</h2>
72
+ <span className="text-[10px] text-foreground-subtle">
73
+ {videoSteps.length} step{videoSteps.length !== 1 ? 's' : ''} — read-only
74
+ </span>
75
+ </div>
76
+
77
+ {videoSteps.map((step, index) => {
78
+ const isExpanded = expandedSteps.has(step.id ?? String(index))
79
+ const stepId = step.id ?? String(index)
80
+
81
+ return (
82
+ <div key={stepId} className="rounded-lg border border-border bg-surface overflow-hidden">
83
+ <button
84
+ onClick={() => toggleStep(stepId)}
85
+ className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-surface-hover transition-colors"
86
+ >
87
+ {isExpanded ? (
88
+ <ChevronDown className="h-3.5 w-3.5 text-foreground-subtle flex-shrink-0" />
89
+ ) : (
90
+ <ChevronRight className="h-3.5 w-3.5 text-foreground-subtle flex-shrink-0" />
91
+ )}
92
+ <span className="text-[10px] text-foreground-subtle w-5">{index + 1}</span>
93
+ <span className="text-xs font-medium text-foreground flex-1 truncate">
94
+ {step.name || step.type || `Step ${index + 1}`}
95
+ </span>
96
+ <span className="text-[10px] text-foreground-subtle bg-surface-hover rounded px-1.5 py-0.5">
97
+ {step.type || 'unknown'}
98
+ </span>
99
+ <div
100
+ className={`h-2 w-2 rounded-full flex-shrink-0 ${
101
+ step.enabled !== false ? 'bg-success' : 'bg-foreground-subtle/30'
102
+ }`}
103
+ />
104
+ </button>
105
+
106
+ {isExpanded && (
107
+ <div className="border-t border-border px-4 py-3 bg-background">
108
+ <pre className="text-[11px] font-mono text-foreground-subtle whitespace-pre-wrap break-all">
109
+ {JSON.stringify(step.config ?? step, null, 2)}
110
+ </pre>
111
+ </div>
112
+ )}
113
+ </div>
114
+ )
115
+ })}
116
+ </div>
117
+ )
118
+ }
@@ -0,0 +1,114 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { Radio, Copy, Check } from 'lucide-react'
3
+ import { useState } from 'react'
4
+ import { useBackendClient } from '../../../hooks/useBackendClient'
5
+
6
+ interface StreamsTabProps {
7
+ readonly deviceId: string
8
+ }
9
+
10
+ export function StreamsTab({ deviceId }: StreamsTabProps) {
11
+ const client = useBackendClient()
12
+ const [copiedUrl, setCopiedUrl] = useState<string | null>(null)
13
+
14
+ const { data: deviceData } = useQuery({
15
+ queryKey: ['device', deviceId],
16
+ queryFn: () => client.getDevice(deviceId),
17
+ enabled: !!deviceId,
18
+ })
19
+
20
+ const device = (deviceData ?? {}) as Record<string, unknown>
21
+ const serverUrl = (client as unknown as { serverUrl?: string }).serverUrl ?? window.location.origin
22
+
23
+ // Build stream URLs from known patterns
24
+ const streams = [
25
+ {
26
+ id: 'webrtc',
27
+ label: 'WebRTC',
28
+ resolution: 'Native',
29
+ codec: 'H.264',
30
+ url: `${serverUrl}/webrtc/${deviceId}`,
31
+ },
32
+ {
33
+ id: 'hls',
34
+ label: 'HLS',
35
+ resolution: 'Native',
36
+ codec: 'H.264',
37
+ url: `${serverUrl}/hls/${deviceId}/index.m3u8`,
38
+ },
39
+ {
40
+ id: 'mjpeg',
41
+ label: 'MJPEG',
42
+ resolution: 'Scaled',
43
+ codec: 'MJPEG',
44
+ url: `${serverUrl}/mjpeg/${deviceId}`,
45
+ },
46
+ ]
47
+
48
+ async function handleCopy(url: string) {
49
+ try {
50
+ await navigator.clipboard.writeText(url)
51
+ setCopiedUrl(url)
52
+ setTimeout(() => setCopiedUrl(null), 2000)
53
+ } catch {
54
+ // clipboard not available
55
+ }
56
+ }
57
+
58
+ return (
59
+ <div className="space-y-4">
60
+ {/* Available streams */}
61
+ <div className="rounded-lg border border-border bg-surface overflow-hidden">
62
+ <div className="border-b border-border px-4 py-2.5 flex items-center justify-between">
63
+ <h2 className="text-xs font-semibold text-foreground uppercase tracking-wider">Available Streams</h2>
64
+ <span className="text-[10px] text-foreground-subtle">{streams.length} streams</span>
65
+ </div>
66
+ <div className="divide-y divide-border">
67
+ {streams.map((stream) => (
68
+ <div key={stream.id} className="flex items-center gap-3 px-4 py-3">
69
+ <div className="flex items-center justify-center rounded-lg h-8 w-8 bg-info/10 flex-shrink-0">
70
+ <Radio className="h-4 w-4 text-info" />
71
+ </div>
72
+ <div className="flex-1 min-w-0">
73
+ <div className="flex items-center gap-2">
74
+ <p className="text-xs font-medium text-foreground">{stream.label}</p>
75
+ <span className="text-[9px] bg-surface-hover rounded px-1.5 py-0.5 text-foreground-subtle">
76
+ {stream.resolution}
77
+ </span>
78
+ <span className="text-[9px] bg-surface-hover rounded px-1.5 py-0.5 text-foreground-subtle">
79
+ {stream.codec}
80
+ </span>
81
+ </div>
82
+ <p className="text-[10px] font-mono text-foreground-subtle mt-0.5 truncate">{stream.url}</p>
83
+ </div>
84
+ <button
85
+ onClick={() => handleCopy(stream.url)}
86
+ className="p-1.5 rounded-md text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors flex-shrink-0"
87
+ title="Copy URL"
88
+ >
89
+ {copiedUrl === stream.url ? (
90
+ <Check className="h-3.5 w-3.5 text-success" />
91
+ ) : (
92
+ <Copy className="h-3.5 w-3.5" />
93
+ )}
94
+ </button>
95
+ </div>
96
+ ))}
97
+ </div>
98
+ </div>
99
+
100
+ {/* Restreamer URLs */}
101
+ <div className="rounded-lg border border-border bg-surface overflow-hidden">
102
+ <div className="border-b border-border px-4 py-2.5">
103
+ <h2 className="text-xs font-semibold text-foreground uppercase tracking-wider">Restreamer</h2>
104
+ </div>
105
+ <div className="px-4 py-8 flex flex-col items-center text-center text-foreground-subtle">
106
+ <p className="text-sm">Restreamer URLs</p>
107
+ <p className="text-xs mt-1 opacity-70">
108
+ Restreamer endpoints will be displayed here when a restreamer is configured
109
+ </p>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ )
114
+ }
@@ -0,0 +1,54 @@
1
+ import { X } from 'lucide-react'
2
+ import { getAllBlocks } from './block-registry'
3
+ import type { DashboardBlock } from '../../types/dashboard'
4
+
5
+ interface BlockPickerProps {
6
+ open: boolean
7
+ onClose: () => void
8
+ onSelect: (block: DashboardBlock) => void
9
+ }
10
+
11
+ export function BlockPicker({ open, onClose, onSelect }: BlockPickerProps) {
12
+ if (!open) return null
13
+
14
+ const blocks = getAllBlocks()
15
+
16
+ return (
17
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
18
+ <div
19
+ className="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-2xl shadow-black/20"
20
+ onClick={(e) => e.stopPropagation()}
21
+ >
22
+ <div className="flex items-center justify-between mb-4">
23
+ <h2 className="text-sm font-semibold text-foreground">Add Block</h2>
24
+ <button
25
+ onClick={onClose}
26
+ className="p-1 rounded-md hover:bg-surface-hover text-foreground-subtle hover:text-foreground transition-colors"
27
+ >
28
+ <X className="h-4 w-4" />
29
+ </button>
30
+ </div>
31
+ <div className="grid grid-cols-2 gap-2">
32
+ {blocks.map((block) => {
33
+ const Icon = block.icon
34
+ return (
35
+ <button
36
+ key={block.id}
37
+ onClick={() => { onSelect(block); onClose() }}
38
+ className="flex items-center gap-3 rounded-lg border border-border p-3 text-left hover:bg-surface-hover hover:border-primary/30 transition-all group"
39
+ >
40
+ <div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 group-hover:bg-primary/15 transition-colors shrink-0">
41
+ <Icon className="h-4 w-4 text-primary" />
42
+ </div>
43
+ <div>
44
+ <span className="text-xs font-medium text-foreground block">{block.name}</span>
45
+ <span className="text-[10px] text-foreground-subtle">{block.defaultSize.w}x{block.defaultSize.h}</span>
46
+ </div>
47
+ </button>
48
+ )
49
+ })}
50
+ </div>
51
+ </div>
52
+ </div>
53
+ )
54
+ }