@camstack/addon-admin-ui 0.1.1 → 0.1.2
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/package.json +2 -1
- package/src/components/addons/AddonCard.tsx +36 -20
- package/src/components/addons/AddonUploadZone.tsx +26 -264
- package/src/components/addons/UpdatesList.tsx +9 -20
- package/src/components/integrations/DeviceDiscoveryStep.tsx +2 -2
- package/src/components/integrations/IntegrationWizard.tsx +2 -1
- package/src/components/metrics/AgentLoad.tsx +4 -12
- package/src/components/metrics/IntegrationUsage.tsx +4 -21
- package/src/components/metrics/PipelineStatus.tsx +25 -56
- package/src/components/metrics/ProcessResources.tsx +3 -19
- package/src/layouts/AppLayout.tsx +17 -1
- package/src/pages/Cameras.tsx +14 -14
- package/src/pages/IntegrationDetail.tsx +10 -12
- package/src/pages/Integrations.tsx +21 -18
- package/src/pages/system/Addons.tsx +89 -218
- package/src/types/config-ui.ts +28 -210
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-admin-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "CamStack Admin UI — Vite frontend build and server-side addon",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"./package.json": "./package.json"
|
|
14
14
|
},
|
|
15
15
|
"camstack": {
|
|
16
|
+
"displayName": "Admin UI",
|
|
16
17
|
"addons": [
|
|
17
18
|
{
|
|
18
19
|
"id": "admin-ui",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
|
-
import { ChevronDown, ChevronUp, Plus, Trash2, Save, Loader2 } from 'lucide-react'
|
|
2
|
+
import { ChevronDown, ChevronUp, Plus, Trash2, Save, Loader2, Package, Puzzle, Download } from 'lucide-react'
|
|
3
3
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
4
|
-
import {
|
|
4
|
+
import { VersionBadge } from '@camstack/ui'
|
|
5
5
|
import { useBackendClient } from '../../hooks/useBackendClient'
|
|
6
6
|
import { useConfirm } from '../ui/ConfirmDialog'
|
|
7
7
|
import { FormBuilder } from '../form-builder/FormBuilder'
|
|
@@ -19,6 +19,7 @@ export interface AddonManifest {
|
|
|
19
19
|
capabilities: readonly (string | { name: string; mode?: string })[]
|
|
20
20
|
requiredFeatures?: readonly string[]
|
|
21
21
|
removable?: boolean
|
|
22
|
+
protected?: boolean
|
|
22
23
|
components?: string[]
|
|
23
24
|
packageName: string
|
|
24
25
|
packageVersion: string
|
|
@@ -44,11 +45,15 @@ const GROUP_COLORS: Record<string, string> = {
|
|
|
44
45
|
page: 'bg-orange-500/20 text-orange-300',
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
function AddonIcon({ name, group }: { name: string; group?: string }) {
|
|
48
|
+
function AddonIcon({ name, group, isBundle }: { name: string; group?: string; isBundle?: boolean }) {
|
|
48
49
|
const color = GROUP_COLORS[group ?? 'addon'] ?? GROUP_COLORS['addon']!
|
|
49
50
|
return (
|
|
50
|
-
<div className={`flex-shrink-0 h-9 w-9 rounded-lg flex items-center justify-center
|
|
51
|
-
{
|
|
51
|
+
<div className={`flex-shrink-0 h-9 w-9 rounded-lg flex items-center justify-center ${color}`}>
|
|
52
|
+
{isBundle ? (
|
|
53
|
+
<Package className="h-4 w-4" />
|
|
54
|
+
) : (
|
|
55
|
+
<Puzzle className="h-4 w-4" />
|
|
56
|
+
)}
|
|
52
57
|
</div>
|
|
53
58
|
)
|
|
54
59
|
}
|
|
@@ -66,9 +71,15 @@ export interface AgentInfo {
|
|
|
66
71
|
interface AddonCardProps {
|
|
67
72
|
addon: AddonListItem
|
|
68
73
|
agents?: AgentInfo[]
|
|
74
|
+
/** Hide version when addon is part of a package bundle */
|
|
75
|
+
hideVersion?: boolean
|
|
76
|
+
/** Latest available version from npm (if update available) */
|
|
77
|
+
availableUpdate?: string
|
|
78
|
+
/** Callback when user clicks the update button */
|
|
79
|
+
onUpdate?: () => void
|
|
69
80
|
}
|
|
70
81
|
|
|
71
|
-
export function AddonCard({ addon, agents = [] }: AddonCardProps) {
|
|
82
|
+
export function AddonCard({ addon, agents = [], hideVersion, availableUpdate, onUpdate }: AddonCardProps) {
|
|
72
83
|
const [expanded, setExpanded] = useState(false)
|
|
73
84
|
const [agentDropdownOpen, setAgentDropdownOpen] = useState(false)
|
|
74
85
|
const client = useBackendClient()
|
|
@@ -77,7 +88,7 @@ export function AddonCard({ addon, agents = [] }: AddonCardProps) {
|
|
|
77
88
|
|
|
78
89
|
const { manifest } = addon
|
|
79
90
|
const installedOn = addon.installedOn ?? []
|
|
80
|
-
const removable = manifest.
|
|
91
|
+
const removable = !manifest.protected && manifest.removable !== false
|
|
81
92
|
// Only expandable if addon has config, components, or agents to show
|
|
82
93
|
const hasExpandableContent = addon.hasConfigSchema || (manifest.components && manifest.components.length > 0) || (agents.length >= 2)
|
|
83
94
|
|
|
@@ -149,33 +160,38 @@ export function AddonCard({ addon, agents = [] }: AddonCardProps) {
|
|
|
149
160
|
].join(' ')}
|
|
150
161
|
onClick={() => hasExpandableContent && setExpanded((e) => !e)}
|
|
151
162
|
>
|
|
152
|
-
<AddonIcon name={manifest.name} group={addon.group} />
|
|
163
|
+
<AddonIcon name={manifest.name} group={addon.group} isBundle={!!manifest.components && manifest.components.length > 1} />
|
|
153
164
|
|
|
154
165
|
<div className="flex-1 min-w-0">
|
|
155
|
-
<div className="flex items-center gap-2
|
|
166
|
+
<div className="flex items-center gap-2">
|
|
156
167
|
<span className="text-sm font-semibold text-foreground truncate">{manifest.name}</span>
|
|
157
|
-
<span className="text-[10px]
|
|
158
|
-
v{manifest.version}
|
|
159
|
-
</span>
|
|
168
|
+
<span className="text-[10px] text-foreground-subtle font-mono shrink-0">({manifest.packageName})</span>
|
|
160
169
|
{addon.source === 'workspace' && (
|
|
161
170
|
<span className="text-[10px] rounded-full bg-orange-500/15 text-orange-400 px-2 py-0.5 font-medium shrink-0">
|
|
162
171
|
DEV
|
|
163
172
|
</span>
|
|
164
173
|
)}
|
|
174
|
+
{!hideVersion && (
|
|
175
|
+
<span className="ml-auto shrink-0 inline-flex items-center gap-1.5">
|
|
176
|
+
{availableUpdate && onUpdate && (
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={(e) => { e.stopPropagation(); onUpdate() }}
|
|
180
|
+
className="rounded-full bg-primary/10 text-primary hover:bg-primary/20 p-1 transition-colors"
|
|
181
|
+
title={`Update to v${availableUpdate}`}
|
|
182
|
+
>
|
|
183
|
+
<Download className="h-3 w-3" />
|
|
184
|
+
</button>
|
|
185
|
+
)}
|
|
186
|
+
<VersionBadge version={manifest.version} />
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
165
189
|
</div>
|
|
166
190
|
|
|
167
191
|
{manifest.description && (
|
|
168
192
|
<p className="text-[10px] text-foreground-subtle mt-0.5 line-clamp-1">{manifest.description}</p>
|
|
169
193
|
)}
|
|
170
194
|
|
|
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
195
|
</div>
|
|
180
196
|
|
|
181
197
|
{/* Actions: uninstall + expand chevron */}
|
|
@@ -1,189 +1,49 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Upload,
|
|
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
|
-
}
|
|
1
|
+
import { useRef, useCallback, useState } from 'react'
|
|
2
|
+
import { Upload, Loader2 } from 'lucide-react'
|
|
16
3
|
|
|
17
4
|
interface AddonUploadZoneProps {
|
|
18
5
|
onUploadSuccess: () => void
|
|
19
6
|
}
|
|
20
7
|
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
// Accepted file extensions
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
const ACCEPTED_EXTENSIONS = ['.tgz', '.tar.gz', '.zip']
|
|
26
8
|
const ACCEPT_MIME = '.tgz,.tar.gz,.zip'
|
|
27
9
|
|
|
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
10
|
export function AddonUploadZone({ onUploadSuccess }: AddonUploadZoneProps) {
|
|
38
|
-
const [
|
|
39
|
-
const [progress, setProgress] = useState(0)
|
|
40
|
-
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
|
41
|
-
const [result, setResult] = useState<UploadResult | null>(null)
|
|
11
|
+
const [uploading, setUploading] = useState(false)
|
|
42
12
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
43
13
|
|
|
44
|
-
const resetState = useCallback(() => {
|
|
45
|
-
setStatus('idle')
|
|
46
|
-
setProgress(0)
|
|
47
|
-
setSelectedFile(null)
|
|
48
|
-
setResult(null)
|
|
49
|
-
}, [])
|
|
50
|
-
|
|
51
14
|
const uploadFile = useCallback(
|
|
52
15
|
async (file: File) => {
|
|
53
|
-
|
|
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
|
-
|
|
16
|
+
setUploading(true)
|
|
64
17
|
const formData = new FormData()
|
|
65
18
|
formData.append('file', file)
|
|
66
|
-
|
|
67
19
|
const token = localStorage.getItem('camstack_admin_token') ?? ''
|
|
68
20
|
|
|
69
21
|
try {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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)
|
|
22
|
+
const res = await fetch('/api/addons/upload', {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
25
|
+
body: formData,
|
|
111
26
|
})
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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')
|
|
27
|
+
const body = await res.json()
|
|
28
|
+
if (body.success) onUploadSuccess()
|
|
29
|
+
} finally {
|
|
30
|
+
setUploading(false)
|
|
123
31
|
}
|
|
124
32
|
},
|
|
125
33
|
[onUploadSuccess],
|
|
126
34
|
)
|
|
127
35
|
|
|
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
36
|
const handleFileSelect = useCallback(
|
|
164
37
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
165
38
|
const file = e.target.files?.[0]
|
|
166
|
-
if (file)
|
|
167
|
-
uploadFile(file)
|
|
168
|
-
}
|
|
169
|
-
// Reset input so the same file can be re-selected
|
|
39
|
+
if (file) uploadFile(file)
|
|
170
40
|
e.target.value = ''
|
|
171
41
|
},
|
|
172
42
|
[uploadFile],
|
|
173
43
|
)
|
|
174
44
|
|
|
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
45
|
return (
|
|
185
|
-
|
|
186
|
-
{/* Hidden file input */}
|
|
46
|
+
<>
|
|
187
47
|
<input
|
|
188
48
|
ref={fileInputRef}
|
|
189
49
|
type="file"
|
|
@@ -191,117 +51,19 @@ export function AddonUploadZone({ onUploadSuccess }: AddonUploadZoneProps) {
|
|
|
191
51
|
onChange={handleFileSelect}
|
|
192
52
|
className="hidden"
|
|
193
53
|
/>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(' ')}
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => fileInputRef.current?.click()}
|
|
57
|
+
disabled={uploading}
|
|
58
|
+
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"
|
|
215
59
|
>
|
|
216
|
-
{
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
</>
|
|
60
|
+
{uploading ? (
|
|
61
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
62
|
+
) : (
|
|
63
|
+
<Upload className="w-3.5 h-3.5" />
|
|
303
64
|
)}
|
|
304
|
-
|
|
305
|
-
|
|
65
|
+
{uploading ? 'Uploading...' : 'Upload'}
|
|
66
|
+
</button>
|
|
67
|
+
</>
|
|
306
68
|
)
|
|
307
69
|
}
|
|
@@ -1,25 +1,20 @@
|
|
|
1
1
|
import { ArrowRight, RotateCcw, Zap } from 'lucide-react'
|
|
2
2
|
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
|
-
// Types
|
|
4
|
+
// Types — inferred from tRPC update.listUpdates return type
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
currentVersion: string
|
|
11
|
-
newVersion: string
|
|
12
|
-
changelog?: string
|
|
13
|
-
requiresRestart: boolean
|
|
14
|
-
}
|
|
7
|
+
import type { BackendClient } from '@camstack/sdk'
|
|
8
|
+
|
|
9
|
+
export type UpdateEntry = Awaited<ReturnType<BackendClient['trpc']['update']['listUpdates']['query']>>[number]
|
|
15
10
|
|
|
16
11
|
// ---------------------------------------------------------------------------
|
|
17
12
|
// UpdatesList
|
|
18
13
|
// ---------------------------------------------------------------------------
|
|
19
14
|
|
|
20
15
|
interface UpdatesListProps {
|
|
21
|
-
updates: UpdateEntry[]
|
|
22
|
-
onUpdate: (
|
|
16
|
+
updates: readonly UpdateEntry[]
|
|
17
|
+
onUpdate: (name: string) => void
|
|
23
18
|
onUpdateAll: () => void
|
|
24
19
|
onCheckUpdates: () => void
|
|
25
20
|
isChecking: boolean
|
|
@@ -61,7 +56,7 @@ export function UpdatesList({ updates, onUpdate, onUpdateAll, onCheckUpdates, is
|
|
|
61
56
|
<div className="space-y-2">
|
|
62
57
|
{updates.map((update) => (
|
|
63
58
|
<div
|
|
64
|
-
key={update.
|
|
59
|
+
key={update.name}
|
|
65
60
|
className="rounded-lg border border-border bg-surface px-4 py-3 flex items-start justify-between gap-4"
|
|
66
61
|
>
|
|
67
62
|
<div className="flex items-start gap-3 min-w-0">
|
|
@@ -92,20 +87,14 @@ export function UpdatesList({ updates, onUpdate, onUpdateAll, onCheckUpdates, is
|
|
|
92
87
|
<div className="flex items-center gap-1.5 mt-1 text-[10px]">
|
|
93
88
|
<span className="text-foreground-subtle font-mono">v{update.currentVersion}</span>
|
|
94
89
|
<ArrowRight className="h-3 w-3 text-foreground-subtle" />
|
|
95
|
-
<span className="text-green-400 font-mono font-medium">v{update.
|
|
90
|
+
<span className="text-green-400 font-mono font-medium">v{update.latestVersion}</span>
|
|
96
91
|
</div>
|
|
97
|
-
|
|
98
|
-
{update.changelog && (
|
|
99
|
-
<p className="mt-1.5 text-[10px] text-foreground-subtle leading-relaxed line-clamp-2">
|
|
100
|
-
{update.changelog}
|
|
101
|
-
</p>
|
|
102
|
-
)}
|
|
103
92
|
</div>
|
|
104
93
|
</div>
|
|
105
94
|
|
|
106
95
|
<button
|
|
107
96
|
type="button"
|
|
108
|
-
onClick={() => onUpdate(update.
|
|
97
|
+
onClick={() => onUpdate(update.name)}
|
|
109
98
|
className="flex-shrink-0 rounded-md bg-primary/10 text-primary hover:bg-primary/20 px-3 py-1.5 text-xs font-medium transition-colors"
|
|
110
99
|
>
|
|
111
100
|
Update
|
|
@@ -54,8 +54,8 @@ export function DeviceDiscoveryStep({ providerId, onImport, onSkip }: DeviceDisc
|
|
|
54
54
|
{devices.length > 0 && (
|
|
55
55
|
<div className="space-y-1.5 max-h-64 overflow-y-auto">
|
|
56
56
|
{devices.map((device) => {
|
|
57
|
-
const id =
|
|
58
|
-
const name =
|
|
57
|
+
const id = device.externalId
|
|
58
|
+
const name = device.name ?? id
|
|
59
59
|
const isSelected = selected.has(id)
|
|
60
60
|
|
|
61
61
|
return (
|
|
@@ -7,6 +7,7 @@ import { useBackendClient } from '../../hooks/useBackendClient'
|
|
|
7
7
|
import { ProviderPicker } from './ProviderPicker'
|
|
8
8
|
import { ProviderConfigForm } from './ProviderConfigForm'
|
|
9
9
|
import { DeviceDiscoveryStep } from './DeviceDiscoveryStep'
|
|
10
|
+
import type { ConfigUISchema } from '@camstack/types'
|
|
10
11
|
|
|
11
12
|
type WizardStep = 'picker' | 'config' | 'discovery'
|
|
12
13
|
|
|
@@ -152,7 +153,7 @@ export function IntegrationWizard({ open, onClose }: IntegrationWizardProps) {
|
|
|
152
153
|
{step === 'config' && selectedAddonId && (
|
|
153
154
|
<ProviderConfigForm
|
|
154
155
|
addonId={selectedAddonId}
|
|
155
|
-
configSchema={(configSchema ?? null) as
|
|
156
|
+
configSchema={(configSchema ?? null) as ConfigUISchema | null}
|
|
156
157
|
onSave={handleConfigSave}
|
|
157
158
|
onBack={() => setStep('picker')}
|
|
158
159
|
/>
|
|
@@ -2,22 +2,13 @@ import { useQuery } from '@tanstack/react-query'
|
|
|
2
2
|
import { useBackendClient } from '../../hooks/useBackendClient'
|
|
3
3
|
import { CapabilityBadges } from '../shared/CapabilityBadges'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// agents.listAgents returns Record<string, unknown>[] — typed view for rendering
|
|
6
|
+
interface AgentView {
|
|
6
7
|
id: string
|
|
7
8
|
name: string
|
|
8
9
|
state: 'online' | 'offline' | 'degraded'
|
|
9
10
|
capabilities: string[]
|
|
10
|
-
lastHeartbeat: number
|
|
11
|
-
connectedSince?: number
|
|
12
11
|
activeTaskCount: number
|
|
13
|
-
completedTaskCount: number
|
|
14
|
-
failedTaskCount: number
|
|
15
|
-
resources?: {
|
|
16
|
-
cpuCores?: number
|
|
17
|
-
memoryMB?: number
|
|
18
|
-
gpuAvailable?: boolean
|
|
19
|
-
gpuModel?: string
|
|
20
|
-
}
|
|
21
12
|
}
|
|
22
13
|
|
|
23
14
|
function stateStyle(state: string): { dot: string; label: string } {
|
|
@@ -38,7 +29,8 @@ export function AgentLoad() {
|
|
|
38
29
|
refetchInterval: 5_000,
|
|
39
30
|
})
|
|
40
31
|
|
|
41
|
-
|
|
32
|
+
// agents.listAgents returns Record<string, unknown>[] — cast needed until tRPC router is strongly typed
|
|
33
|
+
const agents = (data ?? []) as unknown as readonly AgentView[]
|
|
42
34
|
const onlineCount = agents.filter((a) => a.state === 'online').length
|
|
43
35
|
const totalActive = agents.reduce((sum, a) => sum + (a.activeTaskCount ?? 0), 0)
|
|
44
36
|
|
|
@@ -3,20 +3,10 @@ import { useBackendClient } from '../../hooks/useBackendClient'
|
|
|
3
3
|
import { ProviderIcon } from '../shared/ProviderIcon'
|
|
4
4
|
import { StatusBadge } from '../shared/StatusBadge'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
id: string
|
|
8
|
-
name: string
|
|
9
|
-
type: string
|
|
10
|
-
status: string | { state?: string }
|
|
11
|
-
deviceCount: number
|
|
12
|
-
}
|
|
6
|
+
import type { ProviderStatus } from '@camstack/types'
|
|
13
7
|
|
|
14
|
-
function resolveStatus(status:
|
|
15
|
-
|
|
16
|
-
if (typeof status === 'object' && status !== null) {
|
|
17
|
-
return String((status as Record<string, unknown>).state ?? 'stopped')
|
|
18
|
-
}
|
|
19
|
-
return 'stopped'
|
|
8
|
+
function resolveStatus(status: ProviderStatus): string {
|
|
9
|
+
return status.connected ? 'running' : 'stopped'
|
|
20
10
|
}
|
|
21
11
|
|
|
22
12
|
export function IntegrationUsage() {
|
|
@@ -28,8 +18,7 @@ export function IntegrationUsage() {
|
|
|
28
18
|
refetchInterval: 10_000,
|
|
29
19
|
})
|
|
30
20
|
|
|
31
|
-
const providers =
|
|
32
|
-
const totalDevices = providers.reduce((sum, p) => sum + (p.deviceCount ?? 0), 0)
|
|
21
|
+
const providers = data ?? []
|
|
33
22
|
|
|
34
23
|
return (
|
|
35
24
|
<div className="rounded-lg border border-border bg-surface p-4">
|
|
@@ -56,9 +45,6 @@ export function IntegrationUsage() {
|
|
|
56
45
|
<span className="text-xs text-foreground-subtle">
|
|
57
46
|
<span className="text-foreground font-semibold">{providers.length}</span> providers
|
|
58
47
|
</span>
|
|
59
|
-
<span className="text-xs text-foreground-subtle">
|
|
60
|
-
<span className="text-foreground font-semibold">{totalDevices}</span> devices total
|
|
61
|
-
</span>
|
|
62
48
|
</div>
|
|
63
49
|
|
|
64
50
|
{/* Provider list */}
|
|
@@ -75,9 +61,6 @@ export function IntegrationUsage() {
|
|
|
75
61
|
</span>
|
|
76
62
|
</div>
|
|
77
63
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
78
|
-
<span className="text-xs text-foreground-subtle tabular-nums">
|
|
79
|
-
{p.deviceCount ?? 0}d
|
|
80
|
-
</span>
|
|
81
64
|
<StatusBadge status={resolveStatus(p.status)} />
|
|
82
65
|
</div>
|
|
83
66
|
</div>
|