@camstack/addon-admin-ui 0.1.2 → 0.1.4
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.
- package/dist/assets/index-BoVZEQ1j.js +598 -0
- package/dist/assets/index-DwSc8ann.css +1 -0
- package/{index.html → dist/index.html} +3 -1
- package/dist/server/addon.d.ts +11 -0
- package/dist/server/addon.js +50 -0
- package/dist/server/addon.js.map +1 -0
- package/package.json +4 -1
- package/src/App.tsx +0 -71
- package/src/components/addons/AddonCard.tsx +0 -355
- package/src/components/addons/AddonUploadZone.tsx +0 -69
- package/src/components/addons/CapabilityBadge.tsx +0 -55
- package/src/components/addons/CapabilityMap.tsx +0 -133
- package/src/components/addons/UpdatesList.tsx +0 -108
- package/src/components/agents/AgentCard.tsx +0 -281
- package/src/components/agents/AgentLogs.tsx +0 -231
- package/src/components/agents/ProcessList.tsx +0 -127
- package/src/components/agents/ProcessTree.tsx +0 -369
- package/src/components/agents/TaskList.tsx +0 -68
- package/src/components/cameras/CameraCard.tsx +0 -60
- package/src/components/cameras/LiveEventsPanel.tsx +0 -91
- package/src/components/cameras/ProviderSection.tsx +0 -50
- package/src/components/cameras/StreamArea.tsx +0 -107
- package/src/components/cameras/tabs/AddonsTab.tsx +0 -113
- package/src/components/cameras/tabs/CameraEventsTab.tsx +0 -129
- package/src/components/cameras/tabs/PipelineTab.tsx +0 -118
- package/src/components/cameras/tabs/StreamsTab.tsx +0 -114
- package/src/components/dashboard/BlockPicker.tsx +0 -54
- package/src/components/dashboard/BlockWrapper.tsx +0 -97
- package/src/components/dashboard/DashboardGrid.tsx +0 -160
- package/src/components/dashboard/block-registry.ts +0 -15
- package/src/components/dashboard/blocks/PipelineStagesBlock.tsx +0 -39
- package/src/components/dashboard/blocks/StorageBlock.tsx +0 -66
- package/src/components/dashboard/blocks/SystemStatusBlock.tsx +0 -67
- package/src/components/dashboard/blocks/index.ts +0 -32
- package/src/components/device/DeviceHeader.tsx +0 -116
- package/src/components/device/FloatingPanel.tsx +0 -132
- package/src/components/device/FloatingPanelManager.tsx +0 -167
- package/src/components/device/PanelContent.tsx +0 -196
- package/src/components/device/QuickConfigWizard.tsx +0 -507
- package/src/components/device/tabs/DetectionConfigTab.tsx +0 -96
- package/src/components/device/tabs/EventsTab.tsx +0 -19
- package/src/components/device/tabs/LogsTab.tsx +0 -22
- package/src/components/device/tabs/OverviewTab.tsx +0 -104
- package/src/components/device/tabs/ProviderSettingsTab.tsx +0 -34
- package/src/components/device/tabs/RecordingTab.tsx +0 -47
- package/src/components/device/tabs/ReplTab.tsx +0 -153
- package/src/components/device/tabs/TrackTrailTab.tsx +0 -49
- package/src/components/device/tabs/ZonesTab.tsx +0 -98
- package/src/components/device/zone-editor/ZoneCanvas.tsx +0 -354
- package/src/components/device/zone-editor/ZoneForm.tsx +0 -128
- package/src/components/device/zone-editor/ZoneList.tsx +0 -150
- package/src/components/form-builder/FormBuilder.tsx +0 -135
- package/src/components/form-builder/FormField.tsx +0 -732
- package/src/components/form-builder/ModelSelector.tsx +0 -239
- package/src/components/integrations/AddDeviceDialog.tsx +0 -205
- package/src/components/integrations/CompactDeviceCard.tsx +0 -35
- package/src/components/integrations/DeviceCard.tsx +0 -29
- package/src/components/integrations/DeviceDiscoveryStep.tsx +0 -105
- package/src/components/integrations/DeviceGrid.tsx +0 -79
- package/src/components/integrations/DeviceGroupHeader.tsx +0 -17
- package/src/components/integrations/DiscoveredDeviceCard.tsx +0 -26
- package/src/components/integrations/IntegrationCard.tsx +0 -40
- package/src/components/integrations/IntegrationWizard.tsx +0 -172
- package/src/components/integrations/ProviderConfigForm.tsx +0 -89
- package/src/components/integrations/ProviderPicker.tsx +0 -91
- package/src/components/integrations/SnapshotPopover.tsx +0 -68
- package/src/components/metrics/AgentLoad.tsx +0 -105
- package/src/components/metrics/IntegrationUsage.tsx +0 -73
- package/src/components/metrics/PipelineStatus.tsx +0 -74
- package/src/components/metrics/ProcessResources.tsx +0 -123
- package/src/components/pipeline/PhaseSettings.tsx +0 -131
- package/src/components/shared/CapabilityBadges.tsx +0 -30
- package/src/components/shared/ProviderIcon.tsx +0 -42
- package/src/components/shared/StatusBadge.tsx +0 -23
- package/src/components/shared/WebRtcPlayer.tsx +0 -211
- package/src/components/timeline/EventMarker.tsx +0 -32
- package/src/components/timeline/TimelineBar.tsx +0 -131
- package/src/components/ui/ConfirmDialog.tsx +0 -115
- package/src/components/ui/ToastContainer.tsx +0 -92
- package/src/contexts/auth-context.tsx +0 -91
- package/src/hooks/useBackendClient.ts +0 -6
- package/src/hooks/useTheme.ts +0 -1
- package/src/i18n/en.json +0 -164
- package/src/i18n/index.ts +0 -29
- package/src/i18n/it.json +0 -164
- package/src/index.css +0 -63
- package/src/layouts/AddonPageLoader.tsx +0 -120
- package/src/layouts/AppLayout.tsx +0 -254
- package/src/layouts/ProtectedRoute.tsx +0 -25
- package/src/lib/addon-page-context.ts +0 -29
- package/src/lib/backend.ts +0 -16
- package/src/main.tsx +0 -21
- package/src/pages/AccessDenied.tsx +0 -22
- package/src/pages/Cameras.tsx +0 -127
- package/src/pages/Dashboard.tsx +0 -6
- package/src/pages/DeviceDetail.tsx +0 -175
- package/src/pages/IntegrationDetail.tsx +0 -222
- package/src/pages/Integrations.tsx +0 -333
- package/src/pages/Login.tsx +0 -106
- package/src/pages/Metrics.tsx +0 -18
- package/src/pages/PipelineConfig.tsx +0 -282
- package/src/pages/Showroom.tsx +0 -351
- package/src/pages/Timeline.tsx +0 -269
- package/src/pages/system/Addons.tsx +0 -396
- package/src/pages/system/Agents.tsx +0 -362
- package/src/pages/system/Logs.tsx +0 -131
- package/src/pages/system/Models.tsx +0 -102
- package/src/pages/system/Processes.tsx +0 -129
- package/src/pages/system/Repl.tsx +0 -148
- package/src/pages/system/Settings.tsx +0 -168
- package/src/pages/system/Users.tsx +0 -174
- package/src/server/addon.ts +0 -54
- package/src/types/config-ui.ts +0 -28
- package/src/types/dashboard.ts +0 -39
- package/tsconfig.json +0 -29
- package/tsconfig.server.json +0 -16
- package/tsup.config.ts +0 -20
- package/vite.config.ts +0 -68
- /package/{public → dist}/brand/logo-dark.svg +0 -0
- /package/{public → dist}/brand/logo-horizontal-dark.svg +0 -0
- /package/{public → dist}/brand/logo-horizontal-light.svg +0 -0
- /package/{public → dist}/brand/logo-light.svg +0 -0
- /package/{public → dist}/brand/logo-wide-dark.svg +0 -0
- /package/{public → dist}/brand/logo-wide-light.svg +0 -0
- /package/{public → dist}/favicon.svg +0 -0
- /package/{public → dist}/vendor/react-jsx-runtime.mjs +0 -0
- /package/{public → dist}/vendor/react.mjs +0 -0
package/src/pages/Timeline.tsx
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
import { useState, useMemo } from 'react'
|
|
2
|
-
import { useQuery } from '@tanstack/react-query'
|
|
3
|
-
import { Film, Calendar, ChevronLeft, ChevronRight } from 'lucide-react'
|
|
4
|
-
import { useTranslation } from 'react-i18next'
|
|
5
|
-
import { useBackendClient } from '../hooks/useBackendClient'
|
|
6
|
-
import { TimelineBar, type RecordingSegment, type TimelineEvent } from '../components/timeline/TimelineBar'
|
|
7
|
-
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Class → colour mapping
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
const CLASS_COLORS: Record<string, string> = {
|
|
12
|
-
person: '#f59e0b',
|
|
13
|
-
vehicle: '#3b82f6',
|
|
14
|
-
car: '#3b82f6',
|
|
15
|
-
face: '#a855f7',
|
|
16
|
-
plate: '#ec4899',
|
|
17
|
-
animal: '#10b981',
|
|
18
|
-
motion: '#ef4444',
|
|
19
|
-
unknown: '#6b7280',
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function colorForClass(cls: string): string {
|
|
23
|
-
return CLASS_COLORS[cls.toLowerCase()] ?? CLASS_COLORS['unknown']!
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Date helpers
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
function startOfDay(date: Date): number {
|
|
30
|
-
const d = new Date(date)
|
|
31
|
-
d.setHours(0, 0, 0, 0)
|
|
32
|
-
return d.getTime()
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function formatDateInput(ms: number): string {
|
|
36
|
-
return new Date(ms).toISOString().slice(0, 10)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function parseDateInput(value: string): number {
|
|
40
|
-
return new Date(value + 'T00:00:00').getTime()
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
// Placeholder segments (shown when no real API data exists)
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
function buildPlaceholderSegments(dayStartMs: number): readonly RecordingSegment[] {
|
|
47
|
-
return [
|
|
48
|
-
{ startMs: dayStartMs + 0 * 3_600_000, endMs: dayStartMs + 4 * 3_600_000 },
|
|
49
|
-
{ startMs: dayStartMs + 6 * 3_600_000, endMs: dayStartMs + 10 * 3_600_000 },
|
|
50
|
-
{ startMs: dayStartMs + 12 * 3_600_000, endMs: dayStartMs + 16 * 3_600_000 },
|
|
51
|
-
{ startMs: dayStartMs + 18 * 3_600_000, endMs: dayStartMs + 23 * 3_600_000 },
|
|
52
|
-
]
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function buildPlaceholderEvents(dayStartMs: number): readonly TimelineEvent[] {
|
|
56
|
-
const classes = ['person', 'vehicle', 'face', 'motion']
|
|
57
|
-
return Array.from({ length: 24 }, (_, i) => ({
|
|
58
|
-
id: `placeholder-${i}`,
|
|
59
|
-
timestampMs: dayStartMs + Math.floor(Math.random() * 24 * 3_600_000),
|
|
60
|
-
label: classes[i % classes.length]!,
|
|
61
|
-
color: colorForClass(classes[i % classes.length]!),
|
|
62
|
-
})).sort((a, b) => a.timestampMs - b.timestampMs)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
// Component
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
export function TimelinePage() {
|
|
69
|
-
const { t } = useTranslation()
|
|
70
|
-
const client = useBackendClient()
|
|
71
|
-
|
|
72
|
-
const today = startOfDay(new Date())
|
|
73
|
-
const [selectedDayMs, setSelectedDayMs] = useState(today)
|
|
74
|
-
const [selectedCameraId, setSelectedCameraId] = useState<string | null>(null)
|
|
75
|
-
const [playheadMs, setPlayheadMs] = useState(today + 12 * 3_600_000)
|
|
76
|
-
|
|
77
|
-
const dayStartMs = selectedDayMs
|
|
78
|
-
const dayEndMs = selectedDayMs + 24 * 3_600_000
|
|
79
|
-
|
|
80
|
-
// Fetch devices to populate camera tabs
|
|
81
|
-
const { data: devices, isLoading: devicesLoading } = useQuery({
|
|
82
|
-
queryKey: ['devices'],
|
|
83
|
-
queryFn: () => client.listDevices(),
|
|
84
|
-
staleTime: 30_000,
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
const deviceList = (devices ?? []) as unknown as Array<Record<string, unknown>>
|
|
88
|
-
|
|
89
|
-
const cameras = useMemo(() => {
|
|
90
|
-
const list = deviceList.filter((d) => {
|
|
91
|
-
const type = String(d.type ?? '').toLowerCase()
|
|
92
|
-
const caps = (d.capabilities ?? []) as string[]
|
|
93
|
-
return (
|
|
94
|
-
type === 'camera' ||
|
|
95
|
-
type.includes('camera') ||
|
|
96
|
-
caps.some((c) => c.toLowerCase().includes('video') || c.toLowerCase().includes('stream'))
|
|
97
|
-
)
|
|
98
|
-
})
|
|
99
|
-
return list.map((d) => ({ id: String(d.id), name: String(d.name ?? d.id) }))
|
|
100
|
-
}, [deviceList])
|
|
101
|
-
|
|
102
|
-
// Auto-select first camera
|
|
103
|
-
const activeCameraId = selectedCameraId ?? cameras[0]?.id ?? null
|
|
104
|
-
|
|
105
|
-
// Fetch real events for the selected camera on the selected day
|
|
106
|
-
const { data: rawEvents } = useQuery({
|
|
107
|
-
queryKey: ['events', activeCameraId, dayStartMs],
|
|
108
|
-
queryFn: (): Promise<unknown> =>
|
|
109
|
-
activeCameraId ? client.getEvents(activeCameraId, { limit: 200 }) : Promise.resolve(null),
|
|
110
|
-
enabled: !!activeCameraId,
|
|
111
|
-
staleTime: 60_000,
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
const timelineEvents: readonly TimelineEvent[] = useMemo(() => {
|
|
115
|
-
const raw = rawEvents as unknown
|
|
116
|
-
// getEvents returns { events: [...], total: n } or an array depending on router version
|
|
117
|
-
const evtsArr: unknown[] =
|
|
118
|
-
Array.isArray(raw)
|
|
119
|
-
? (raw as unknown[])
|
|
120
|
-
: raw != null && typeof raw === 'object' && Array.isArray((raw as Record<string, unknown>)['events'])
|
|
121
|
-
? ((raw as Record<string, unknown>)['events'] as unknown[])
|
|
122
|
-
: []
|
|
123
|
-
const evts = evtsArr as Array<Record<string, unknown>>
|
|
124
|
-
if (evts.length === 0) {
|
|
125
|
-
return activeCameraId ? buildPlaceholderEvents(dayStartMs) : []
|
|
126
|
-
}
|
|
127
|
-
return evts.map((ev, idx) => {
|
|
128
|
-
const cls = String((ev.class ?? ev.type ?? ev.label ?? 'unknown')).toLowerCase()
|
|
129
|
-
const ts = typeof ev.timestamp === 'number' ? ev.timestamp : dayStartMs
|
|
130
|
-
return {
|
|
131
|
-
id: String(ev.id ?? idx),
|
|
132
|
-
timestampMs: ts,
|
|
133
|
-
label: cls,
|
|
134
|
-
color: colorForClass(cls),
|
|
135
|
-
}
|
|
136
|
-
})
|
|
137
|
-
}, [rawEvents, activeCameraId, dayStartMs])
|
|
138
|
-
|
|
139
|
-
// Use placeholder segments (no real segment API exposed yet)
|
|
140
|
-
const segments: readonly RecordingSegment[] = useMemo(
|
|
141
|
-
() => (activeCameraId ? buildPlaceholderSegments(dayStartMs) : []),
|
|
142
|
-
[activeCameraId, dayStartMs],
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
function navigateDay(delta: number) {
|
|
146
|
-
setSelectedDayMs((prev) => prev + delta * 24 * 3_600_000)
|
|
147
|
-
setPlayheadMs((prev) => prev + delta * 24 * 3_600_000)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return (
|
|
151
|
-
<div className="flex flex-col h-full p-6 gap-5 overflow-hidden">
|
|
152
|
-
{/* ── Header ── */}
|
|
153
|
-
<div className="flex items-center justify-between gap-4 shrink-0">
|
|
154
|
-
<h1 className="text-lg font-semibold text-foreground">{t('nav.timeline', 'Timeline')}</h1>
|
|
155
|
-
|
|
156
|
-
{/* Date navigation */}
|
|
157
|
-
<div className="flex items-center gap-2">
|
|
158
|
-
<button
|
|
159
|
-
onClick={() => navigateDay(-1)}
|
|
160
|
-
className="p-1 rounded hover:bg-surface-hover text-foreground-subtle hover:text-foreground transition-colors"
|
|
161
|
-
>
|
|
162
|
-
<ChevronLeft className="h-4 w-4" />
|
|
163
|
-
</button>
|
|
164
|
-
<div className="relative flex items-center">
|
|
165
|
-
<Calendar className="absolute left-2 h-3.5 w-3.5 text-foreground-subtle pointer-events-none" />
|
|
166
|
-
<input
|
|
167
|
-
type="date"
|
|
168
|
-
value={formatDateInput(selectedDayMs)}
|
|
169
|
-
onChange={(e) => {
|
|
170
|
-
if (e.target.value) {
|
|
171
|
-
const ms = parseDateInput(e.target.value)
|
|
172
|
-
setSelectedDayMs(ms)
|
|
173
|
-
setPlayheadMs(ms + 12 * 3_600_000)
|
|
174
|
-
}
|
|
175
|
-
}}
|
|
176
|
-
className="rounded-lg border border-border bg-surface pl-7 pr-3 py-1.5 text-xs text-foreground focus:outline-none focus:border-primary/50"
|
|
177
|
-
/>
|
|
178
|
-
</div>
|
|
179
|
-
<button
|
|
180
|
-
onClick={() => navigateDay(1)}
|
|
181
|
-
className="p-1 rounded hover:bg-surface-hover text-foreground-subtle hover:text-foreground transition-colors"
|
|
182
|
-
disabled={selectedDayMs >= today}
|
|
183
|
-
>
|
|
184
|
-
<ChevronRight className="h-4 w-4" />
|
|
185
|
-
</button>
|
|
186
|
-
</div>
|
|
187
|
-
</div>
|
|
188
|
-
|
|
189
|
-
{/* ── Camera selector tabs ── */}
|
|
190
|
-
<div className="flex gap-1.5 shrink-0 overflow-x-auto pb-1">
|
|
191
|
-
{devicesLoading && (
|
|
192
|
-
<div className="h-8 w-48 rounded-lg bg-surface animate-pulse" />
|
|
193
|
-
)}
|
|
194
|
-
{!devicesLoading && cameras.length === 0 && (
|
|
195
|
-
<span className="text-xs text-foreground-subtle">No cameras found</span>
|
|
196
|
-
)}
|
|
197
|
-
{cameras.map((cam) => {
|
|
198
|
-
const active = cam.id === activeCameraId
|
|
199
|
-
return (
|
|
200
|
-
<button
|
|
201
|
-
key={cam.id}
|
|
202
|
-
onClick={() => setSelectedCameraId(cam.id)}
|
|
203
|
-
className={`shrink-0 rounded-lg px-3 py-1.5 text-[12px] font-medium transition-all border ${
|
|
204
|
-
active
|
|
205
|
-
? 'bg-primary/12 text-primary border-primary/30'
|
|
206
|
-
: 'bg-surface text-foreground-subtle border-border hover:bg-surface-hover hover:text-foreground'
|
|
207
|
-
}`}
|
|
208
|
-
>
|
|
209
|
-
{cam.name}
|
|
210
|
-
</button>
|
|
211
|
-
)
|
|
212
|
-
})}
|
|
213
|
-
</div>
|
|
214
|
-
|
|
215
|
-
{/* ── Video player placeholder ── */}
|
|
216
|
-
<div className="relative rounded-xl border border-border bg-black shrink-0 overflow-hidden" style={{ aspectRatio: '16/9', maxHeight: '45vh' }}>
|
|
217
|
-
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-foreground-subtle">
|
|
218
|
-
<Film className="h-12 w-12 opacity-20" />
|
|
219
|
-
<p className="text-sm opacity-50">
|
|
220
|
-
{activeCameraId
|
|
221
|
-
? `${cameras.find((c) => c.id === activeCameraId)?.name ?? activeCameraId} — seek to play`
|
|
222
|
-
: 'Select a camera'}
|
|
223
|
-
</p>
|
|
224
|
-
</div>
|
|
225
|
-
|
|
226
|
-
{/* Playhead timestamp overlay */}
|
|
227
|
-
<div className="absolute top-3 left-3 rounded bg-black/60 px-2 py-1 text-[11px] text-white font-mono">
|
|
228
|
-
{new Date(playheadMs).toLocaleTimeString()}
|
|
229
|
-
</div>
|
|
230
|
-
</div>
|
|
231
|
-
|
|
232
|
-
{/* ── Timeline scrub bar ── */}
|
|
233
|
-
<div className="rounded-xl border border-border bg-surface px-4 pt-3 pb-4 shrink-0">
|
|
234
|
-
{/* Legend */}
|
|
235
|
-
<div className="flex items-center gap-4 mb-3">
|
|
236
|
-
<span className="text-[10px] font-semibold text-foreground-subtle uppercase tracking-wider">Timeline</span>
|
|
237
|
-
<div className="flex items-center gap-1.5">
|
|
238
|
-
<span className="inline-block h-2 w-4 rounded-sm bg-success/60" />
|
|
239
|
-
<span className="text-[10px] text-foreground-subtle">Recording</span>
|
|
240
|
-
</div>
|
|
241
|
-
{Array.from(new Set(timelineEvents.map((e) => e.label))).slice(0, 5).map((cls) => (
|
|
242
|
-
<div key={cls} className="flex items-center gap-1.5">
|
|
243
|
-
<span
|
|
244
|
-
className="inline-block h-2.5 w-2.5 rounded-full"
|
|
245
|
-
style={{ backgroundColor: colorForClass(cls) }}
|
|
246
|
-
/>
|
|
247
|
-
<span className="text-[10px] text-foreground-subtle capitalize">{cls}</span>
|
|
248
|
-
</div>
|
|
249
|
-
))}
|
|
250
|
-
</div>
|
|
251
|
-
|
|
252
|
-
{activeCameraId ? (
|
|
253
|
-
<TimelineBar
|
|
254
|
-
startMs={dayStartMs}
|
|
255
|
-
endMs={dayEndMs}
|
|
256
|
-
segments={segments}
|
|
257
|
-
events={timelineEvents}
|
|
258
|
-
playheadMs={playheadMs}
|
|
259
|
-
onSeek={setPlayheadMs}
|
|
260
|
-
/>
|
|
261
|
-
) : (
|
|
262
|
-
<div className="h-8 rounded bg-surface-hover flex items-center justify-center text-xs text-foreground-subtle">
|
|
263
|
-
Select a camera to view timeline
|
|
264
|
-
</div>
|
|
265
|
-
)}
|
|
266
|
-
</div>
|
|
267
|
-
</div>
|
|
268
|
-
)
|
|
269
|
-
}
|
|
@@ -1,396 +0,0 @@
|
|
|
1
|
-
import type { BackendClient } from '@camstack/sdk'
|
|
2
|
-
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
3
|
-
import { Check, ChevronDown, ChevronUp, Download, Loader2, Package, RefreshCw, Search } from 'lucide-react'
|
|
4
|
-
import { useDeferredValue, useMemo, useState } from 'react'
|
|
5
|
-
import type { AddonListItem, AgentInfo } from '../../components/addons/AddonCard'
|
|
6
|
-
import { AddonCard } from '../../components/addons/AddonCard'
|
|
7
|
-
import { VersionBadge } from '@camstack/ui'
|
|
8
|
-
import { AddonUploadZone } from '../../components/addons/AddonUploadZone'
|
|
9
|
-
import { useBackendClient } from '../../hooks/useBackendClient'
|
|
10
|
-
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// Types — inferred from tRPC addons.list return type
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
|
|
15
|
-
type RawAddonListItem = Awaited<ReturnType<BackendClient['listAddons']>>[number]
|
|
16
|
-
type RawAddonManifest = RawAddonListItem['manifest']
|
|
17
|
-
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
// Normalise raw addon to typed AddonListItem, deriving group from ID
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
|
|
22
|
-
function deriveGroup(manifest: RawAddonManifest, source?: string): AddonListItem['group'] {
|
|
23
|
-
if (source === 'core' || manifest.packageName === '@camstack/core') return 'core'
|
|
24
|
-
const pkg = manifest.packageName.toLowerCase()
|
|
25
|
-
if (pkg.includes('provider')) return 'provider'
|
|
26
|
-
if (pkg.includes('page') || pkg.includes('benchmark')) return 'page'
|
|
27
|
-
return 'addon'
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function normaliseAddon(raw: RawAddonListItem): AddonListItem | null {
|
|
31
|
-
if (!raw?.manifest) return null
|
|
32
|
-
return {
|
|
33
|
-
manifest: {
|
|
34
|
-
...raw.manifest,
|
|
35
|
-
capabilities: raw.manifest.capabilities ?? [],
|
|
36
|
-
},
|
|
37
|
-
enabled: true,
|
|
38
|
-
hasConfigSchema: raw.hasConfigSchema ?? false,
|
|
39
|
-
group: deriveGroup(raw.manifest, raw.source),
|
|
40
|
-
source: raw.source,
|
|
41
|
-
installedOn: [],
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
// Group definitions
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
// Groups removed — flat list with package grouping only
|
|
50
|
-
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// Installed tab
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
// Package group — collapsible header for addons from the same npm package
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
|
|
59
|
-
interface PackageGroup {
|
|
60
|
-
packageName: string
|
|
61
|
-
displayName: string
|
|
62
|
-
version: string
|
|
63
|
-
addons: AddonListItem[]
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
function groupByPackage(addons: AddonListItem[]): Array<PackageGroup | AddonListItem> {
|
|
68
|
-
const packageMap = new Map<string, PackageGroup>()
|
|
69
|
-
const ungrouped: AddonListItem[] = []
|
|
70
|
-
|
|
71
|
-
for (const addon of addons) {
|
|
72
|
-
const pkgName = addon.manifest.packageName
|
|
73
|
-
if (!pkgName) {
|
|
74
|
-
ungrouped.push(addon)
|
|
75
|
-
continue
|
|
76
|
-
}
|
|
77
|
-
const existing = packageMap.get(pkgName)
|
|
78
|
-
if (existing) {
|
|
79
|
-
existing.addons.push(addon)
|
|
80
|
-
} else {
|
|
81
|
-
packageMap.set(pkgName, {
|
|
82
|
-
packageName: pkgName,
|
|
83
|
-
displayName: (addon.manifest as any).packageDisplayName ?? addon.manifest.name,
|
|
84
|
-
version: addon.manifest.packageVersion,
|
|
85
|
-
addons: [addon],
|
|
86
|
-
})
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const result: Array<PackageGroup | AddonListItem> = []
|
|
91
|
-
for (const group of packageMap.values()) {
|
|
92
|
-
// Only create a collapsible group if the package contains 2+ addons
|
|
93
|
-
if (group.addons.length >= 2) {
|
|
94
|
-
result.push(group)
|
|
95
|
-
} else {
|
|
96
|
-
result.push(group.addons[0]!)
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
result.push(...ungrouped)
|
|
100
|
-
return result
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function isPackageGroup(item: PackageGroup | AddonListItem): item is PackageGroup {
|
|
104
|
-
return 'packageName' in item && 'addons' in item
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function PackageGroupHeader({ group, agents }: { group: PackageGroup; agents: AgentInfo[] }) {
|
|
108
|
-
const [expanded, setExpanded] = useState(false)
|
|
109
|
-
|
|
110
|
-
return (
|
|
111
|
-
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
|
112
|
-
{/* Header — same layout as AddonCard */}
|
|
113
|
-
<div className="flex items-start gap-3 px-4 py-3">
|
|
114
|
-
<div className="flex-shrink-0 h-9 w-9 rounded-lg flex items-center justify-center bg-purple-500/20 text-purple-300">
|
|
115
|
-
<Package className="h-4 w-4" />
|
|
116
|
-
</div>
|
|
117
|
-
|
|
118
|
-
<div className="flex-1 min-w-0">
|
|
119
|
-
<div className="flex items-center gap-2">
|
|
120
|
-
<span className="text-sm font-semibold text-foreground truncate">{group.displayName}</span>
|
|
121
|
-
<span className="text-[10px] text-foreground-subtle font-mono shrink-0">({group.packageName})</span>
|
|
122
|
-
<span className="text-[10px] rounded-full bg-primary/10 text-primary px-2 py-0.5 font-medium shrink-0 inline-flex items-center gap-1">
|
|
123
|
-
<Package className="h-3 w-3" />
|
|
124
|
-
{group.addons.length}
|
|
125
|
-
</span>
|
|
126
|
-
<span className="ml-auto shrink-0">
|
|
127
|
-
<VersionBadge version={group.version} />
|
|
128
|
-
</span>
|
|
129
|
-
</div>
|
|
130
|
-
{group.addons[0]?.manifest.description && (
|
|
131
|
-
<p className="text-[10px] text-foreground-subtle mt-0.5 line-clamp-1">{group.addons[0].manifest.description}</p>
|
|
132
|
-
)}
|
|
133
|
-
</div>
|
|
134
|
-
</div>
|
|
135
|
-
|
|
136
|
-
{/* Expand handle — bottom edge tab */}
|
|
137
|
-
<button
|
|
138
|
-
type="button"
|
|
139
|
-
onClick={() => setExpanded((e) => !e)}
|
|
140
|
-
className="w-full flex items-center justify-center border-t border-border py-1 hover:bg-surface-hover/50 transition-colors"
|
|
141
|
-
>
|
|
142
|
-
{expanded ? (
|
|
143
|
-
<ChevronUp className="h-3.5 w-3.5 text-foreground-subtle" />
|
|
144
|
-
) : (
|
|
145
|
-
<ChevronDown className="h-3.5 w-3.5 text-foreground-subtle" />
|
|
146
|
-
)}
|
|
147
|
-
</button>
|
|
148
|
-
|
|
149
|
-
{/* Expanded content */}
|
|
150
|
-
{expanded && (
|
|
151
|
-
<div className="border-t border-border px-3 py-2 space-y-2">
|
|
152
|
-
{group.addons.map((addon) => (
|
|
153
|
-
<AddonCard key={addon.manifest.id} addon={addon} agents={agents} hideVersion />
|
|
154
|
-
))}
|
|
155
|
-
</div>
|
|
156
|
-
)}
|
|
157
|
-
</div>
|
|
158
|
-
)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
|
-
// Installed tab
|
|
163
|
-
// ---------------------------------------------------------------------------
|
|
164
|
-
|
|
165
|
-
interface InstalledTabProps {
|
|
166
|
-
addons: AddonListItem[]
|
|
167
|
-
agents: AgentInfo[]
|
|
168
|
-
onRefresh: () => void
|
|
169
|
-
isRefreshing: boolean
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function InstalledTab({ addons, agents, onRefresh, isRefreshing }: InstalledTabProps) {
|
|
173
|
-
const client = useBackendClient()
|
|
174
|
-
const queryClient = useQueryClient()
|
|
175
|
-
const [searchInput, setSearchInput] = useState('')
|
|
176
|
-
const deferredSearch = useDeferredValue(searchInput)
|
|
177
|
-
const [showSearch, setShowSearch] = useState(false)
|
|
178
|
-
|
|
179
|
-
// Search npm for available addons — uses deferred (debounced) value
|
|
180
|
-
const { data: searchResults, isFetching: isSearching } = useQuery({
|
|
181
|
-
queryKey: ['addon-search', deferredSearch],
|
|
182
|
-
queryFn: () => client.trpc.bridgeAddons.searchAvailable.query({ query: deferredSearch || undefined }),
|
|
183
|
-
enabled: showSearch,
|
|
184
|
-
staleTime: 60_000,
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
// Install addon mutation
|
|
188
|
-
const installMutation = useMutation({
|
|
189
|
-
mutationFn: (packageName: string) => client.trpc.bridgeAddons.installPackage.mutate({ packageName }),
|
|
190
|
-
onSuccess: (_data, packageName) => {
|
|
191
|
-
queryClient.invalidateQueries({ queryKey: ['addons'] })
|
|
192
|
-
queryClient.invalidateQueries({ queryKey: ['addon-search'] })
|
|
193
|
-
queryClient.invalidateQueries({ queryKey: ['addon-pages'] })
|
|
194
|
-
queryClient.invalidateQueries({ queryKey: ['capabilities'] })
|
|
195
|
-
console.log(`[Addons] Installed ${packageName}`)
|
|
196
|
-
},
|
|
197
|
-
onError: (err, packageName) => {
|
|
198
|
-
console.error(`[Addons] Install failed for ${packageName}:`, err)
|
|
199
|
-
},
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
const packageGrouped = groupByPackage(addons)
|
|
203
|
-
|
|
204
|
-
// Filter search results: hide already installed
|
|
205
|
-
const availableAddons = (searchResults ?? []).filter((r) => !r.installed)
|
|
206
|
-
|
|
207
|
-
return (
|
|
208
|
-
<div className="space-y-6">
|
|
209
|
-
{/* Search bar + actions */}
|
|
210
|
-
<div className="flex gap-2">
|
|
211
|
-
<div className="relative flex-1">
|
|
212
|
-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-foreground-subtle" />
|
|
213
|
-
<input
|
|
214
|
-
type="text"
|
|
215
|
-
value={searchInput}
|
|
216
|
-
onChange={(e) => { setSearchInput(e.target.value); setShowSearch(true) }}
|
|
217
|
-
onFocus={() => setShowSearch(true)}
|
|
218
|
-
placeholder="Search addons by name or capability..."
|
|
219
|
-
className="w-full rounded-md border border-border bg-background pl-9 pr-3 py-2 text-xs text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-1 focus:ring-primary"
|
|
220
|
-
/>
|
|
221
|
-
{isSearching && (
|
|
222
|
-
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-foreground-subtle animate-spin" />
|
|
223
|
-
)}
|
|
224
|
-
</div>
|
|
225
|
-
<AddonUploadZone
|
|
226
|
-
onUploadSuccess={() => {
|
|
227
|
-
queryClient.invalidateQueries({ queryKey: ['addons'] })
|
|
228
|
-
onRefresh()
|
|
229
|
-
}}
|
|
230
|
-
/>
|
|
231
|
-
<button
|
|
232
|
-
type="button"
|
|
233
|
-
onClick={onRefresh}
|
|
234
|
-
disabled={isRefreshing}
|
|
235
|
-
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-surface hover:bg-surface-hover border border-border disabled:opacity-50 transition-colors"
|
|
236
|
-
>
|
|
237
|
-
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
|
238
|
-
Refresh
|
|
239
|
-
</button>
|
|
240
|
-
</div>
|
|
241
|
-
|
|
242
|
-
{/* Search results dropdown */}
|
|
243
|
-
{showSearch && availableAddons.length > 0 && (
|
|
244
|
-
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
|
245
|
-
<div className="px-3 py-2 text-[10px] uppercase tracking-wide text-foreground-subtle border-b border-border flex justify-between">
|
|
246
|
-
<span>Available on npm ({availableAddons.length})</span>
|
|
247
|
-
<button type="button" onClick={() => setShowSearch(false)} className="text-foreground-subtle hover:text-foreground">
|
|
248
|
-
close
|
|
249
|
-
</button>
|
|
250
|
-
</div>
|
|
251
|
-
<div className="max-h-64 overflow-auto divide-y divide-border">
|
|
252
|
-
{availableAddons.map((addon) => (
|
|
253
|
-
<div key={addon.name} className="flex items-center gap-3 px-3 py-2.5 hover:bg-surface-hover transition-colors">
|
|
254
|
-
<div className="w-7 h-7 rounded-md bg-primary/10 flex items-center justify-center text-primary text-[10px] font-bold shrink-0">
|
|
255
|
-
{addon.name.replace('@camstack/addon-', '').charAt(0).toUpperCase()}
|
|
256
|
-
</div>
|
|
257
|
-
<div className="flex-1 min-w-0">
|
|
258
|
-
<div className="flex items-center gap-2">
|
|
259
|
-
<span className="text-xs font-semibold truncate">{addon.name}</span>
|
|
260
|
-
<span className="text-[10px] text-foreground-subtle">v{addon.version}</span>
|
|
261
|
-
</div>
|
|
262
|
-
<div className="text-[10px] text-foreground-subtle truncate">{addon.description}</div>
|
|
263
|
-
{addon.keywords && addon.keywords.length > 0 && (
|
|
264
|
-
<div className="flex gap-1 mt-0.5 flex-wrap">
|
|
265
|
-
{addon.keywords.filter((k) => k !== 'camstack' && k !== 'addon' && k !== 'camstack-addon').slice(0, 5).map((k) => (
|
|
266
|
-
<span key={k} className="text-[9px] px-1.5 py-0 rounded bg-primary/5 text-foreground-subtle">{k}</span>
|
|
267
|
-
))}
|
|
268
|
-
</div>
|
|
269
|
-
)}
|
|
270
|
-
</div>
|
|
271
|
-
<button
|
|
272
|
-
type="button"
|
|
273
|
-
onClick={() => installMutation.mutate(addon.name)}
|
|
274
|
-
disabled={installMutation.isPending}
|
|
275
|
-
className="flex items-center gap-1 px-2.5 py-1 text-[10px] font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors shrink-0"
|
|
276
|
-
>
|
|
277
|
-
{installMutation.isPending ? (
|
|
278
|
-
<Loader2 className="h-3 w-3 animate-spin" />
|
|
279
|
-
) : (
|
|
280
|
-
<Download className="h-3 w-3" />
|
|
281
|
-
)}
|
|
282
|
-
Install
|
|
283
|
-
</button>
|
|
284
|
-
</div>
|
|
285
|
-
))}
|
|
286
|
-
</div>
|
|
287
|
-
</div>
|
|
288
|
-
)}
|
|
289
|
-
|
|
290
|
-
{/* Show "all installed" if search returned only installed results */}
|
|
291
|
-
{showSearch && searchResults && availableAddons.length === 0 && searchResults.length > 0 && (
|
|
292
|
-
<div className="text-xs text-foreground-subtle flex items-center gap-1.5 px-1">
|
|
293
|
-
<Check className="h-3.5 w-3.5 text-green-500" />
|
|
294
|
-
All matching addons are already installed
|
|
295
|
-
</div>
|
|
296
|
-
)}
|
|
297
|
-
|
|
298
|
-
{/* Installed addons grouped */}
|
|
299
|
-
{addons.length === 0 && (
|
|
300
|
-
<div className="text-xs text-foreground-subtle">No addons installed</div>
|
|
301
|
-
)}
|
|
302
|
-
|
|
303
|
-
<div className="space-y-2">
|
|
304
|
-
{packageGrouped.map((item) =>
|
|
305
|
-
isPackageGroup(item) ? (
|
|
306
|
-
<PackageGroupHeader key={item.packageName} group={item} agents={agents} />
|
|
307
|
-
) : (
|
|
308
|
-
<AddonCard key={item.manifest.id} addon={item} agents={agents} />
|
|
309
|
-
),
|
|
310
|
-
)}
|
|
311
|
-
</div>
|
|
312
|
-
</div>
|
|
313
|
-
)
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// ---------------------------------------------------------------------------
|
|
317
|
-
// Main page
|
|
318
|
-
// ---------------------------------------------------------------------------
|
|
319
|
-
|
|
320
|
-
export function AddonsPage() {
|
|
321
|
-
const client = useBackendClient()
|
|
322
|
-
const queryClient = useQueryClient()
|
|
323
|
-
|
|
324
|
-
const { data: rawAddons, isLoading, isError } = useQuery({
|
|
325
|
-
queryKey: ['addons', 'list'],
|
|
326
|
-
queryFn: () => client.listAddons(),
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
// Fetch agents for the "Add agent" dropdown in AddonCard
|
|
330
|
-
const { data: rawAgents = [] } = useQuery({
|
|
331
|
-
queryKey: ['agents', 'list'],
|
|
332
|
-
queryFn: () => client.listAgents(),
|
|
333
|
-
staleTime: 30_000,
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
const agents: AgentInfo[] = useMemo(
|
|
337
|
-
() =>
|
|
338
|
-
rawAgents.map((raw) => ({
|
|
339
|
-
id: raw.info.id,
|
|
340
|
-
name: raw.info.name ?? raw.info.id,
|
|
341
|
-
isHub: raw.isHub,
|
|
342
|
-
})),
|
|
343
|
-
[rawAgents],
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
const reloadMutation = useMutation({
|
|
347
|
-
mutationFn: async () => {
|
|
348
|
-
// Force refresh update cache from npm + reload addon packages
|
|
349
|
-
await client.trpc.update.forceRefresh.mutate()
|
|
350
|
-
await client.trpc.bridgeAddons.reloadPackages.mutate()
|
|
351
|
-
},
|
|
352
|
-
onSuccess: () => {
|
|
353
|
-
queryClient.invalidateQueries({ queryKey: ['addons'] })
|
|
354
|
-
queryClient.invalidateQueries({ queryKey: ['updates'] })
|
|
355
|
-
},
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
const addonList: AddonListItem[] = (rawAddons ?? []).map(normaliseAddon).filter((a): a is AddonListItem => a !== null)
|
|
359
|
-
|
|
360
|
-
return (
|
|
361
|
-
<div className="p-6 space-y-5">
|
|
362
|
-
{/* Header */}
|
|
363
|
-
<div className="flex items-center justify-between">
|
|
364
|
-
<div>
|
|
365
|
-
<h1 className="text-lg font-semibold text-foreground">Addons</h1>
|
|
366
|
-
<p className="text-xs text-foreground-subtle mt-0.5">
|
|
367
|
-
Manage installed addons and explore capability providers.
|
|
368
|
-
</p>
|
|
369
|
-
</div>
|
|
370
|
-
{!isLoading && !isError && (
|
|
371
|
-
<span className="text-[10px] rounded-full bg-primary/10 text-primary px-2 py-0.5 font-medium">
|
|
372
|
-
{addonList.length} installed
|
|
373
|
-
</span>
|
|
374
|
-
)}
|
|
375
|
-
</div>
|
|
376
|
-
|
|
377
|
-
{/* Loading / error states */}
|
|
378
|
-
{isLoading && (
|
|
379
|
-
<div className="text-xs text-foreground-subtle animate-pulse">Loading…</div>
|
|
380
|
-
)}
|
|
381
|
-
{isError && (
|
|
382
|
-
<div className="text-xs text-danger">Failed to load addons</div>
|
|
383
|
-
)}
|
|
384
|
-
|
|
385
|
-
{/* Installed addons */}
|
|
386
|
-
{!isLoading && !isError && (
|
|
387
|
-
<InstalledTab
|
|
388
|
-
addons={addonList}
|
|
389
|
-
agents={agents}
|
|
390
|
-
onRefresh={() => reloadMutation.mutate()}
|
|
391
|
-
isRefreshing={reloadMutation.isPending}
|
|
392
|
-
/>
|
|
393
|
-
)}
|
|
394
|
-
</div>
|
|
395
|
-
)
|
|
396
|
-
}
|