@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.
- package/index.html +22 -0
- package/package.json +69 -0
- package/public/brand/logo-dark.svg +16 -0
- package/public/brand/logo-horizontal-dark.svg +21 -0
- package/public/brand/logo-horizontal-light.svg +21 -0
- package/public/brand/logo-light.svg +16 -0
- package/public/brand/logo-wide-dark.svg +24 -0
- package/public/brand/logo-wide-light.svg +24 -0
- package/public/favicon.svg +8 -0
- package/public/vendor/react-jsx-runtime.mjs +24 -0
- package/public/vendor/react.mjs +16 -0
- package/src/App.tsx +71 -0
- package/src/components/addons/AddonCard.tsx +339 -0
- package/src/components/addons/AddonUploadZone.tsx +307 -0
- package/src/components/addons/CapabilityBadge.tsx +55 -0
- package/src/components/addons/CapabilityMap.tsx +133 -0
- package/src/components/addons/UpdatesList.tsx +119 -0
- package/src/components/agents/AgentCard.tsx +281 -0
- package/src/components/agents/AgentLogs.tsx +231 -0
- package/src/components/agents/ProcessList.tsx +127 -0
- package/src/components/agents/ProcessTree.tsx +369 -0
- package/src/components/agents/TaskList.tsx +68 -0
- package/src/components/cameras/CameraCard.tsx +60 -0
- package/src/components/cameras/LiveEventsPanel.tsx +91 -0
- package/src/components/cameras/ProviderSection.tsx +50 -0
- package/src/components/cameras/StreamArea.tsx +107 -0
- package/src/components/cameras/tabs/AddonsTab.tsx +113 -0
- package/src/components/cameras/tabs/CameraEventsTab.tsx +129 -0
- package/src/components/cameras/tabs/PipelineTab.tsx +118 -0
- package/src/components/cameras/tabs/StreamsTab.tsx +114 -0
- package/src/components/dashboard/BlockPicker.tsx +54 -0
- package/src/components/dashboard/BlockWrapper.tsx +97 -0
- package/src/components/dashboard/DashboardGrid.tsx +160 -0
- package/src/components/dashboard/block-registry.ts +15 -0
- package/src/components/dashboard/blocks/PipelineStagesBlock.tsx +39 -0
- package/src/components/dashboard/blocks/StorageBlock.tsx +66 -0
- package/src/components/dashboard/blocks/SystemStatusBlock.tsx +67 -0
- package/src/components/dashboard/blocks/index.ts +32 -0
- package/src/components/device/DeviceHeader.tsx +116 -0
- package/src/components/device/FloatingPanel.tsx +132 -0
- package/src/components/device/FloatingPanelManager.tsx +167 -0
- package/src/components/device/PanelContent.tsx +196 -0
- package/src/components/device/QuickConfigWizard.tsx +507 -0
- package/src/components/device/tabs/DetectionConfigTab.tsx +96 -0
- package/src/components/device/tabs/EventsTab.tsx +19 -0
- package/src/components/device/tabs/LogsTab.tsx +22 -0
- package/src/components/device/tabs/OverviewTab.tsx +104 -0
- package/src/components/device/tabs/ProviderSettingsTab.tsx +34 -0
- package/src/components/device/tabs/RecordingTab.tsx +47 -0
- package/src/components/device/tabs/ReplTab.tsx +153 -0
- package/src/components/device/tabs/TrackTrailTab.tsx +49 -0
- package/src/components/device/tabs/ZonesTab.tsx +98 -0
- package/src/components/device/zone-editor/ZoneCanvas.tsx +354 -0
- package/src/components/device/zone-editor/ZoneForm.tsx +128 -0
- package/src/components/device/zone-editor/ZoneList.tsx +150 -0
- package/src/components/form-builder/FormBuilder.tsx +135 -0
- package/src/components/form-builder/FormField.tsx +732 -0
- package/src/components/form-builder/ModelSelector.tsx +239 -0
- package/src/components/integrations/AddDeviceDialog.tsx +205 -0
- package/src/components/integrations/CompactDeviceCard.tsx +35 -0
- package/src/components/integrations/DeviceCard.tsx +29 -0
- package/src/components/integrations/DeviceDiscoveryStep.tsx +105 -0
- package/src/components/integrations/DeviceGrid.tsx +79 -0
- package/src/components/integrations/DeviceGroupHeader.tsx +17 -0
- package/src/components/integrations/DiscoveredDeviceCard.tsx +26 -0
- package/src/components/integrations/IntegrationCard.tsx +40 -0
- package/src/components/integrations/IntegrationWizard.tsx +171 -0
- package/src/components/integrations/ProviderConfigForm.tsx +89 -0
- package/src/components/integrations/ProviderPicker.tsx +91 -0
- package/src/components/integrations/SnapshotPopover.tsx +68 -0
- package/src/components/metrics/AgentLoad.tsx +113 -0
- package/src/components/metrics/IntegrationUsage.tsx +90 -0
- package/src/components/metrics/PipelineStatus.tsx +105 -0
- package/src/components/metrics/ProcessResources.tsx +139 -0
- package/src/components/pipeline/PhaseSettings.tsx +131 -0
- package/src/components/shared/CapabilityBadges.tsx +30 -0
- package/src/components/shared/ProviderIcon.tsx +42 -0
- package/src/components/shared/StatusBadge.tsx +23 -0
- package/src/components/shared/WebRtcPlayer.tsx +211 -0
- package/src/components/timeline/EventMarker.tsx +32 -0
- package/src/components/timeline/TimelineBar.tsx +131 -0
- package/src/components/ui/ConfirmDialog.tsx +115 -0
- package/src/components/ui/ToastContainer.tsx +92 -0
- package/src/contexts/auth-context.tsx +91 -0
- package/src/hooks/useBackendClient.ts +6 -0
- package/src/hooks/useTheme.ts +1 -0
- package/src/i18n/en.json +164 -0
- package/src/i18n/index.ts +29 -0
- package/src/i18n/it.json +164 -0
- package/src/index.css +63 -0
- package/src/layouts/AddonPageLoader.tsx +120 -0
- package/src/layouts/AppLayout.tsx +238 -0
- package/src/layouts/ProtectedRoute.tsx +25 -0
- package/src/lib/addon-page-context.ts +29 -0
- package/src/lib/backend.ts +16 -0
- package/src/main.tsx +21 -0
- package/src/pages/AccessDenied.tsx +22 -0
- package/src/pages/Cameras.tsx +127 -0
- package/src/pages/Dashboard.tsx +6 -0
- package/src/pages/DeviceDetail.tsx +175 -0
- package/src/pages/IntegrationDetail.tsx +224 -0
- package/src/pages/Integrations.tsx +330 -0
- package/src/pages/Login.tsx +106 -0
- package/src/pages/Metrics.tsx +18 -0
- package/src/pages/PipelineConfig.tsx +282 -0
- package/src/pages/Showroom.tsx +351 -0
- package/src/pages/Timeline.tsx +269 -0
- package/src/pages/system/Addons.tsx +525 -0
- package/src/pages/system/Agents.tsx +362 -0
- package/src/pages/system/Logs.tsx +131 -0
- package/src/pages/system/Models.tsx +102 -0
- package/src/pages/system/Processes.tsx +129 -0
- package/src/pages/system/Repl.tsx +148 -0
- package/src/pages/system/Settings.tsx +168 -0
- package/src/pages/system/Users.tsx +174 -0
- package/src/server/addon.ts +54 -0
- package/src/types/config-ui.ts +210 -0
- package/src/types/dashboard.ts +39 -0
- package/tsconfig.json +29 -0
- package/tsconfig.server.json +16 -0
- package/tsup.config.ts +20 -0
- package/vite.config.ts +68 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { ChevronDown, ChevronUp, Plus, Trash2, Save, Loader2 } from 'lucide-react'
|
|
3
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
4
|
+
import { CapabilityBadge } from './CapabilityBadge'
|
|
5
|
+
import { useBackendClient } from '../../hooks/useBackendClient'
|
|
6
|
+
import { useConfirm } from '../ui/ConfirmDialog'
|
|
7
|
+
import { FormBuilder } from '../form-builder/FormBuilder'
|
|
8
|
+
import type { ConfigUISchema } from '../../types/config-ui'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface AddonManifest {
|
|
15
|
+
id: string
|
|
16
|
+
name: string
|
|
17
|
+
version: string
|
|
18
|
+
description?: string
|
|
19
|
+
capabilities: readonly (string | { name: string; mode?: string })[]
|
|
20
|
+
requiredFeatures?: readonly string[]
|
|
21
|
+
removable?: boolean
|
|
22
|
+
components?: string[]
|
|
23
|
+
packageName: string
|
|
24
|
+
packageVersion: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AddonListItem {
|
|
28
|
+
manifest: AddonManifest
|
|
29
|
+
enabled: boolean
|
|
30
|
+
hasConfigSchema: boolean
|
|
31
|
+
group?: 'core' | 'addon' | 'provider' | 'page'
|
|
32
|
+
source?: 'core' | 'installed' | 'workspace'
|
|
33
|
+
installedOn?: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Icon letter
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
const GROUP_COLORS: Record<string, string> = {
|
|
41
|
+
core: 'bg-blue-500/20 text-blue-300',
|
|
42
|
+
addon: 'bg-purple-500/20 text-purple-300',
|
|
43
|
+
provider: 'bg-green-500/20 text-green-300',
|
|
44
|
+
page: 'bg-orange-500/20 text-orange-300',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function AddonIcon({ name, group }: { name: string; group?: string }) {
|
|
48
|
+
const color = GROUP_COLORS[group ?? 'addon'] ?? GROUP_COLORS['addon']!
|
|
49
|
+
return (
|
|
50
|
+
<div className={`flex-shrink-0 h-9 w-9 rounded-lg flex items-center justify-center font-bold text-sm uppercase ${color}`}>
|
|
51
|
+
{name.charAt(0)}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// AddonCard
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export interface AgentInfo {
|
|
61
|
+
id: string
|
|
62
|
+
name: string
|
|
63
|
+
isHub: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface AddonCardProps {
|
|
67
|
+
addon: AddonListItem
|
|
68
|
+
agents?: AgentInfo[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function AddonCard({ addon, agents = [] }: AddonCardProps) {
|
|
72
|
+
const [expanded, setExpanded] = useState(false)
|
|
73
|
+
const [agentDropdownOpen, setAgentDropdownOpen] = useState(false)
|
|
74
|
+
const client = useBackendClient()
|
|
75
|
+
const queryClient = useQueryClient()
|
|
76
|
+
const confirm = useConfirm()
|
|
77
|
+
|
|
78
|
+
const { manifest } = addon
|
|
79
|
+
const installedOn = addon.installedOn ?? []
|
|
80
|
+
const removable = manifest.removable !== false && addon.source !== 'core'
|
|
81
|
+
// Only expandable if addon has config, components, or agents to show
|
|
82
|
+
const hasExpandableContent = addon.hasConfigSchema || (manifest.components && manifest.components.length > 0) || (agents.length >= 2)
|
|
83
|
+
|
|
84
|
+
// Load config schema when expanded and addon has config
|
|
85
|
+
const { data: configSchema, isLoading: schemaLoading } = useQuery({
|
|
86
|
+
queryKey: ['addon-config-schema', manifest.id],
|
|
87
|
+
queryFn: () => client.trpc.addons.getConfigSchema.query({ addonId: manifest.id }) as Promise<ConfigUISchema | null>,
|
|
88
|
+
enabled: expanded && addon.hasConfigSchema,
|
|
89
|
+
staleTime: 60_000,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Load current config values
|
|
93
|
+
const { data: configValues } = useQuery({
|
|
94
|
+
queryKey: ['addon-config', manifest.id],
|
|
95
|
+
queryFn: () => client.trpc.addons.getConfig.query({ addonId: manifest.id }) as Promise<Record<string, unknown> | null>,
|
|
96
|
+
enabled: expanded && addon.hasConfigSchema,
|
|
97
|
+
staleTime: 30_000,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Local state for editing
|
|
101
|
+
const [editValues, setEditValues] = useState<Record<string, unknown>>({})
|
|
102
|
+
const [dirty, setDirty] = useState(false)
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (configValues) {
|
|
106
|
+
setEditValues(configValues)
|
|
107
|
+
setDirty(false)
|
|
108
|
+
}
|
|
109
|
+
}, [configValues])
|
|
110
|
+
|
|
111
|
+
const handleChange = (values: Record<string, unknown>) => {
|
|
112
|
+
setEditValues(values)
|
|
113
|
+
setDirty(true)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Save config
|
|
117
|
+
const saveMutation = useMutation({
|
|
118
|
+
mutationFn: () => client.trpc.addons.updateConfig.mutate({ addonId: manifest.id, config: editValues }),
|
|
119
|
+
onSuccess: () => {
|
|
120
|
+
setDirty(false)
|
|
121
|
+
queryClient.invalidateQueries({ queryKey: ['addon-config', manifest.id] })
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Uninstall
|
|
126
|
+
const uninstallMutation = useMutation({
|
|
127
|
+
mutationFn: () => {
|
|
128
|
+
console.log(`[AddonCard] Uninstalling ${manifest.packageName}`)
|
|
129
|
+
return client.trpc.bridgeAddons.uninstallPackage.mutate({ packageName: manifest.packageName })
|
|
130
|
+
},
|
|
131
|
+
onSuccess: () => {
|
|
132
|
+
console.log(`[AddonCard] Uninstalled ${manifest.packageName}`)
|
|
133
|
+
queryClient.invalidateQueries({ queryKey: ['addons'] })
|
|
134
|
+
queryClient.invalidateQueries({ queryKey: ['addon-pages'] })
|
|
135
|
+
queryClient.invalidateQueries({ queryKey: ['capabilities'] })
|
|
136
|
+
},
|
|
137
|
+
onError: (err) => {
|
|
138
|
+
console.error(`[AddonCard] Uninstall failed:`, err)
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
|
144
|
+
{/* Header row */}
|
|
145
|
+
<div
|
|
146
|
+
className={[
|
|
147
|
+
'flex items-start gap-3 px-4 py-3 transition-colors',
|
|
148
|
+
hasExpandableContent ? 'cursor-pointer hover:bg-surface-hover/50' : '',
|
|
149
|
+
].join(' ')}
|
|
150
|
+
onClick={() => hasExpandableContent && setExpanded((e) => !e)}
|
|
151
|
+
>
|
|
152
|
+
<AddonIcon name={manifest.name} group={addon.group} />
|
|
153
|
+
|
|
154
|
+
<div className="flex-1 min-w-0">
|
|
155
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
156
|
+
<span className="text-sm font-semibold text-foreground truncate">{manifest.name}</span>
|
|
157
|
+
<span className="text-[10px] rounded-full bg-primary/10 text-primary px-2 py-0.5 font-medium shrink-0">
|
|
158
|
+
v{manifest.version}
|
|
159
|
+
</span>
|
|
160
|
+
{addon.source === 'workspace' && (
|
|
161
|
+
<span className="text-[10px] rounded-full bg-orange-500/15 text-orange-400 px-2 py-0.5 font-medium shrink-0">
|
|
162
|
+
DEV
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{manifest.description && (
|
|
168
|
+
<p className="text-[10px] text-foreground-subtle mt-0.5 line-clamp-1">{manifest.description}</p>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{/* Capability badges */}
|
|
172
|
+
{manifest.capabilities.length > 0 && (
|
|
173
|
+
<div className="flex flex-wrap gap-1 mt-1.5">
|
|
174
|
+
{manifest.capabilities.map((cap, i) => (
|
|
175
|
+
<CapabilityBadge key={i} capability={cap} />
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{/* Actions: uninstall + expand chevron */}
|
|
182
|
+
<div className="flex items-center gap-2 shrink-0 pt-1">
|
|
183
|
+
{removable && (
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
onClick={async (e) => {
|
|
187
|
+
e.stopPropagation()
|
|
188
|
+
console.log('[AddonCard] Confirm dialog opening...')
|
|
189
|
+
const confirmed = await confirm({
|
|
190
|
+
title: `Uninstall ${manifest.name}?`,
|
|
191
|
+
message: `This will remove the addon package and all its data. This action cannot be undone.`,
|
|
192
|
+
confirmLabel: 'Uninstall',
|
|
193
|
+
variant: 'danger',
|
|
194
|
+
})
|
|
195
|
+
if (confirmed) uninstallMutation.mutate()
|
|
196
|
+
}}
|
|
197
|
+
disabled={uninstallMutation.isPending}
|
|
198
|
+
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition-colors"
|
|
199
|
+
title={`Uninstall ${manifest.name}`}
|
|
200
|
+
>
|
|
201
|
+
<Trash2 className="h-3 w-3" />
|
|
202
|
+
{uninstallMutation.isPending && 'Removing...'}
|
|
203
|
+
</button>
|
|
204
|
+
)}
|
|
205
|
+
{hasExpandableContent && (
|
|
206
|
+
expanded ? <ChevronUp className="h-4 w-4 text-foreground-subtle" /> : <ChevronDown className="h-4 w-4 text-foreground-subtle" />
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* Expanded: settings inline */}
|
|
212
|
+
{expanded && (
|
|
213
|
+
<div className="border-t border-border px-4 py-3 space-y-4">
|
|
214
|
+
|
|
215
|
+
{/* Config form — loaded from backend */}
|
|
216
|
+
{addon.hasConfigSchema && (
|
|
217
|
+
<div>
|
|
218
|
+
{schemaLoading && (
|
|
219
|
+
<div className="flex items-center gap-2 text-xs text-foreground-subtle py-2">
|
|
220
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
221
|
+
Loading configuration...
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
{configSchema && configSchema.sections?.some((s: any) => s.fields?.length > 0) && (
|
|
226
|
+
<div className="space-y-3">
|
|
227
|
+
<FormBuilder
|
|
228
|
+
schema={configSchema}
|
|
229
|
+
values={editValues}
|
|
230
|
+
onChange={handleChange}
|
|
231
|
+
/>
|
|
232
|
+
{dirty && (
|
|
233
|
+
<div className="flex items-center justify-between">
|
|
234
|
+
<span className="text-[10px] text-orange-400">Unsaved changes</span>
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
onClick={(e) => { e.stopPropagation(); saveMutation.mutate() }}
|
|
238
|
+
disabled={saveMutation.isPending}
|
|
239
|
+
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 disabled:opacity-50 transition-colors"
|
|
240
|
+
>
|
|
241
|
+
{saveMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
|
|
242
|
+
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
{saveMutation.isError && (
|
|
247
|
+
<div className="text-[10px] text-red-400">
|
|
248
|
+
Save failed: {saveMutation.error instanceof Error ? saveMutation.error.message : 'Unknown error'}
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
|
|
254
|
+
{/* No fallback message — addons without config don't show the expander */}
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{/* No message for addons without config — just show other sections */}
|
|
259
|
+
|
|
260
|
+
{/* Components list (for core) */}
|
|
261
|
+
{manifest.components && manifest.components.length > 0 && (
|
|
262
|
+
<div>
|
|
263
|
+
<div className="text-[10px] font-medium text-foreground-subtle uppercase tracking-wide mb-2">
|
|
264
|
+
Components ({manifest.components.length})
|
|
265
|
+
</div>
|
|
266
|
+
<div className="flex flex-wrap gap-1">
|
|
267
|
+
{manifest.components.map((comp) => (
|
|
268
|
+
<span key={comp} className="text-[10px] rounded bg-background border border-border px-1.5 py-0.5 text-foreground-subtle font-mono">
|
|
269
|
+
{comp}
|
|
270
|
+
</span>
|
|
271
|
+
))}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{/* Installed on agents — only show when remote agents exist (2+ total) */}
|
|
277
|
+
{agents.length >= 2 && (
|
|
278
|
+
<div>
|
|
279
|
+
<div className="text-[10px] font-medium text-foreground-subtle uppercase tracking-wide mb-2">
|
|
280
|
+
Installed on
|
|
281
|
+
</div>
|
|
282
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
283
|
+
{installedOn.length === 0 && (
|
|
284
|
+
<span className="text-[10px] text-foreground-subtle">Hub (local)</span>
|
|
285
|
+
)}
|
|
286
|
+
{installedOn.map((agentName) => (
|
|
287
|
+
<span
|
|
288
|
+
key={agentName}
|
|
289
|
+
className="rounded-full bg-surface-hover border border-border px-2 py-0.5 text-[10px] text-foreground-subtle"
|
|
290
|
+
>
|
|
291
|
+
{agentName}
|
|
292
|
+
</span>
|
|
293
|
+
))}
|
|
294
|
+
<div className="relative">
|
|
295
|
+
<button
|
|
296
|
+
type="button"
|
|
297
|
+
onClick={(e) => { e.stopPropagation(); setAgentDropdownOpen((o) => !o) }}
|
|
298
|
+
className="inline-flex items-center gap-1 rounded-full border border-dashed border-border px-2 py-0.5 text-[10px] text-foreground-subtle hover:text-foreground hover:border-primary transition-colors"
|
|
299
|
+
title="Install on more agents"
|
|
300
|
+
>
|
|
301
|
+
<Plus className="h-3 w-3" />
|
|
302
|
+
Add agent
|
|
303
|
+
</button>
|
|
304
|
+
{agentDropdownOpen && (
|
|
305
|
+
<div className="absolute left-0 top-full mt-1 z-10 min-w-[160px] rounded-md border border-border bg-surface shadow-lg overflow-hidden">
|
|
306
|
+
{agents
|
|
307
|
+
.filter((a) => !a.isHub && !installedOn.includes(a.name))
|
|
308
|
+
.map((agent) => (
|
|
309
|
+
<button
|
|
310
|
+
key={agent.id}
|
|
311
|
+
type="button"
|
|
312
|
+
onClick={(e) => {
|
|
313
|
+
e.stopPropagation()
|
|
314
|
+
setAgentDropdownOpen(false)
|
|
315
|
+
// TODO: wire up install-on-agent mutation
|
|
316
|
+
}}
|
|
317
|
+
className="w-full text-left px-3 py-1.5 text-[10px] text-foreground hover:bg-surface-hover transition-colors"
|
|
318
|
+
>
|
|
319
|
+
{agent.name}
|
|
320
|
+
</button>
|
|
321
|
+
))}
|
|
322
|
+
{agents.filter((a) => !a.isHub && !installedOn.includes(a.name)).length === 0 && (
|
|
323
|
+
<div className="px-3 py-1.5 text-[10px] text-foreground-subtle">
|
|
324
|
+
No available agents
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
|
|
334
|
+
{/* Uninstall button is now inline in the header */}
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
</div>
|
|
338
|
+
)
|
|
339
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { useState, useRef, useCallback } from 'react'
|
|
2
|
+
import { Upload, FileArchive, X, Loader2, CheckCircle, AlertCircle } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Types
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
type UploadStatus = 'idle' | 'dragging' | 'uploading' | 'success' | 'error'
|
|
9
|
+
|
|
10
|
+
interface UploadResult {
|
|
11
|
+
success: boolean
|
|
12
|
+
packageName?: string
|
|
13
|
+
version?: string
|
|
14
|
+
error?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface AddonUploadZoneProps {
|
|
18
|
+
onUploadSuccess: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Accepted file extensions
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const ACCEPTED_EXTENSIONS = ['.tgz', '.tar.gz', '.zip']
|
|
26
|
+
const ACCEPT_MIME = '.tgz,.tar.gz,.zip'
|
|
27
|
+
|
|
28
|
+
function isAcceptedFile(file: File): boolean {
|
|
29
|
+
const name = file.name.toLowerCase()
|
|
30
|
+
return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// AddonUploadZone
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export function AddonUploadZone({ onUploadSuccess }: AddonUploadZoneProps) {
|
|
38
|
+
const [status, setStatus] = useState<UploadStatus>('idle')
|
|
39
|
+
const [progress, setProgress] = useState(0)
|
|
40
|
+
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
|
41
|
+
const [result, setResult] = useState<UploadResult | null>(null)
|
|
42
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
43
|
+
|
|
44
|
+
const resetState = useCallback(() => {
|
|
45
|
+
setStatus('idle')
|
|
46
|
+
setProgress(0)
|
|
47
|
+
setSelectedFile(null)
|
|
48
|
+
setResult(null)
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
const uploadFile = useCallback(
|
|
52
|
+
async (file: File) => {
|
|
53
|
+
if (!isAcceptedFile(file)) {
|
|
54
|
+
setStatus('error')
|
|
55
|
+
setResult({ success: false, error: `Invalid file type. Accepted: ${ACCEPTED_EXTENSIONS.join(', ')}` })
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setSelectedFile(file)
|
|
60
|
+
setStatus('uploading')
|
|
61
|
+
setProgress(0)
|
|
62
|
+
setResult(null)
|
|
63
|
+
|
|
64
|
+
const formData = new FormData()
|
|
65
|
+
formData.append('file', file)
|
|
66
|
+
|
|
67
|
+
const token = localStorage.getItem('camstack_admin_token') ?? ''
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const xhr = new XMLHttpRequest()
|
|
71
|
+
|
|
72
|
+
const uploadResult = await new Promise<UploadResult>((resolve, reject) => {
|
|
73
|
+
xhr.upload.addEventListener('progress', (event) => {
|
|
74
|
+
if (event.lengthComputable) {
|
|
75
|
+
const pct = Math.round((event.loaded / event.total) * 100)
|
|
76
|
+
setProgress(pct)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
xhr.addEventListener('load', () => {
|
|
81
|
+
try {
|
|
82
|
+
const body = JSON.parse(xhr.responseText)
|
|
83
|
+
if (xhr.status >= 200 && xhr.status < 300 && body.success) {
|
|
84
|
+
resolve({
|
|
85
|
+
success: true,
|
|
86
|
+
packageName: body.packageName,
|
|
87
|
+
version: body.version,
|
|
88
|
+
})
|
|
89
|
+
} else {
|
|
90
|
+
resolve({
|
|
91
|
+
success: false,
|
|
92
|
+
error: body.error ?? `Upload failed (HTTP ${xhr.status})`,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
resolve({ success: false, error: `Unexpected response (HTTP ${xhr.status})` })
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
xhr.addEventListener('error', () => {
|
|
101
|
+
reject(new Error('Network error'))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
xhr.addEventListener('abort', () => {
|
|
105
|
+
reject(new Error('Upload aborted'))
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
xhr.open('POST', '/api/addons/upload')
|
|
109
|
+
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
|
110
|
+
xhr.send(formData)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
setResult(uploadResult)
|
|
114
|
+
setStatus(uploadResult.success ? 'success' : 'error')
|
|
115
|
+
|
|
116
|
+
if (uploadResult.success) {
|
|
117
|
+
onUploadSuccess()
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const message = err instanceof Error ? err.message : 'Upload failed'
|
|
121
|
+
setResult({ success: false, error: message })
|
|
122
|
+
setStatus('error')
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
[onUploadSuccess],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
// --- Drag event handlers ---
|
|
129
|
+
|
|
130
|
+
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
|
131
|
+
e.preventDefault()
|
|
132
|
+
e.stopPropagation()
|
|
133
|
+
setStatus((prev) => (prev === 'uploading' ? prev : 'dragging'))
|
|
134
|
+
}, [])
|
|
135
|
+
|
|
136
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
137
|
+
e.preventDefault()
|
|
138
|
+
e.stopPropagation()
|
|
139
|
+
// Only leave when exiting the drop zone itself
|
|
140
|
+
if (e.currentTarget.contains(e.relatedTarget as Node)) return
|
|
141
|
+
setStatus((prev) => (prev === 'uploading' ? prev : 'idle'))
|
|
142
|
+
}, [])
|
|
143
|
+
|
|
144
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
145
|
+
e.preventDefault()
|
|
146
|
+
e.stopPropagation()
|
|
147
|
+
}, [])
|
|
148
|
+
|
|
149
|
+
const handleDrop = useCallback(
|
|
150
|
+
(e: React.DragEvent) => {
|
|
151
|
+
e.preventDefault()
|
|
152
|
+
e.stopPropagation()
|
|
153
|
+
setStatus('idle')
|
|
154
|
+
|
|
155
|
+
const file = e.dataTransfer.files[0]
|
|
156
|
+
if (file) {
|
|
157
|
+
uploadFile(file)
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
[uploadFile],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const handleFileSelect = useCallback(
|
|
164
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
165
|
+
const file = e.target.files?.[0]
|
|
166
|
+
if (file) {
|
|
167
|
+
uploadFile(file)
|
|
168
|
+
}
|
|
169
|
+
// Reset input so the same file can be re-selected
|
|
170
|
+
e.target.value = ''
|
|
171
|
+
},
|
|
172
|
+
[uploadFile],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const handleBrowseClick = useCallback(() => {
|
|
176
|
+
fileInputRef.current?.click()
|
|
177
|
+
}, [])
|
|
178
|
+
|
|
179
|
+
// --- Render helpers ---
|
|
180
|
+
|
|
181
|
+
const isIdle = status === 'idle' || status === 'dragging'
|
|
182
|
+
const isDragging = status === 'dragging'
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className="space-y-2">
|
|
186
|
+
{/* Hidden file input */}
|
|
187
|
+
<input
|
|
188
|
+
ref={fileInputRef}
|
|
189
|
+
type="file"
|
|
190
|
+
accept={ACCEPT_MIME}
|
|
191
|
+
onChange={handleFileSelect}
|
|
192
|
+
className="hidden"
|
|
193
|
+
/>
|
|
194
|
+
|
|
195
|
+
{/* Drop zone */}
|
|
196
|
+
<div
|
|
197
|
+
onDragEnter={handleDragEnter}
|
|
198
|
+
onDragLeave={handleDragLeave}
|
|
199
|
+
onDragOver={handleDragOver}
|
|
200
|
+
onDrop={handleDrop}
|
|
201
|
+
onClick={isIdle ? handleBrowseClick : undefined}
|
|
202
|
+
className={[
|
|
203
|
+
'relative rounded-lg border-2 border-dashed transition-all cursor-pointer',
|
|
204
|
+
'flex flex-col items-center justify-center gap-2 px-6 py-6',
|
|
205
|
+
isDragging
|
|
206
|
+
? 'border-primary bg-primary/5 scale-[1.01]'
|
|
207
|
+
: status === 'uploading'
|
|
208
|
+
? 'border-border bg-surface cursor-default'
|
|
209
|
+
: status === 'success'
|
|
210
|
+
? 'border-green-500/40 bg-green-500/5 cursor-default'
|
|
211
|
+
: status === 'error'
|
|
212
|
+
? 'border-red-500/40 bg-red-500/5 cursor-default'
|
|
213
|
+
: 'border-border bg-surface hover:border-primary/50 hover:bg-surface-hover/50',
|
|
214
|
+
].join(' ')}
|
|
215
|
+
>
|
|
216
|
+
{/* Idle / dragging state */}
|
|
217
|
+
{isIdle && (
|
|
218
|
+
<>
|
|
219
|
+
<Upload className={`h-6 w-6 ${isDragging ? 'text-primary' : 'text-foreground-subtle'}`} />
|
|
220
|
+
<div className="text-center">
|
|
221
|
+
<p className="text-xs font-medium text-foreground">
|
|
222
|
+
{isDragging ? 'Drop addon file here' : 'Drag & drop addon file'}
|
|
223
|
+
</p>
|
|
224
|
+
<p className="text-[10px] text-foreground-subtle mt-0.5">
|
|
225
|
+
or{' '}
|
|
226
|
+
<span className="text-primary underline underline-offset-2">browse files</span>
|
|
227
|
+
{' '}· .tgz, .tar.gz, .zip
|
|
228
|
+
</p>
|
|
229
|
+
</div>
|
|
230
|
+
</>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
{/* Uploading state */}
|
|
234
|
+
{status === 'uploading' && selectedFile && (
|
|
235
|
+
<>
|
|
236
|
+
<div className="flex items-center gap-2">
|
|
237
|
+
<FileArchive className="h-5 w-5 text-primary shrink-0" />
|
|
238
|
+
<div className="min-w-0">
|
|
239
|
+
<p className="text-xs font-medium text-foreground truncate">{selectedFile.name}</p>
|
|
240
|
+
<p className="text-[10px] text-foreground-subtle">
|
|
241
|
+
{(selectedFile.size / 1024).toFixed(1)} KB
|
|
242
|
+
</p>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{/* Progress bar */}
|
|
247
|
+
<div className="w-full max-w-xs">
|
|
248
|
+
<div className="h-1.5 w-full rounded-full bg-border overflow-hidden">
|
|
249
|
+
<div
|
|
250
|
+
className="h-full rounded-full bg-primary transition-all duration-300 ease-out"
|
|
251
|
+
style={{ width: `${progress}%` }}
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
<div className="flex items-center justify-between mt-1">
|
|
255
|
+
<span className="text-[10px] text-foreground-subtle flex items-center gap-1">
|
|
256
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
257
|
+
Uploading...
|
|
258
|
+
</span>
|
|
259
|
+
<span className="text-[10px] text-foreground-subtle font-mono">{progress}%</span>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</>
|
|
263
|
+
)}
|
|
264
|
+
|
|
265
|
+
{/* Success state */}
|
|
266
|
+
{status === 'success' && result && (
|
|
267
|
+
<>
|
|
268
|
+
<CheckCircle className="h-6 w-6 text-green-500" />
|
|
269
|
+
<div className="text-center">
|
|
270
|
+
<p className="text-xs font-medium text-foreground">Addon installed</p>
|
|
271
|
+
<p className="text-[10px] text-foreground-subtle mt-0.5">
|
|
272
|
+
{result.packageName} v{result.version}
|
|
273
|
+
</p>
|
|
274
|
+
</div>
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
onClick={(e) => { e.stopPropagation(); resetState() }}
|
|
278
|
+
className="absolute top-2 right-2 p-1 rounded-md text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors"
|
|
279
|
+
title="Dismiss"
|
|
280
|
+
>
|
|
281
|
+
<X className="h-3.5 w-3.5" />
|
|
282
|
+
</button>
|
|
283
|
+
</>
|
|
284
|
+
)}
|
|
285
|
+
|
|
286
|
+
{/* Error state */}
|
|
287
|
+
{status === 'error' && result && (
|
|
288
|
+
<>
|
|
289
|
+
<AlertCircle className="h-6 w-6 text-red-400" />
|
|
290
|
+
<div className="text-center">
|
|
291
|
+
<p className="text-xs font-medium text-red-400">Upload failed</p>
|
|
292
|
+
<p className="text-[10px] text-foreground-subtle mt-0.5">{result.error}</p>
|
|
293
|
+
</div>
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
onClick={(e) => { e.stopPropagation(); resetState() }}
|
|
297
|
+
className="absolute top-2 right-2 p-1 rounded-md text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors"
|
|
298
|
+
title="Dismiss"
|
|
299
|
+
>
|
|
300
|
+
<X className="h-3.5 w-3.5" />
|
|
301
|
+
</button>
|
|
302
|
+
</>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
)
|
|
307
|
+
}
|