@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-admin-ui",
3
- "version": "0.1.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 { CapabilityBadge } from './CapabilityBadge'
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 font-bold text-sm uppercase ${color}`}>
51
- {name.charAt(0)}
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.removable !== false && addon.source !== 'core'
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 flex-wrap">
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] rounded-full bg-primary/10 text-primary px-2 py-0.5 font-medium shrink-0">
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 { 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
- }
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 [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)
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
- 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
-
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 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)
22
+ const res = await fetch('/api/addons/upload', {
23
+ method: 'POST',
24
+ headers: { Authorization: `Bearer ${token}` },
25
+ body: formData,
111
26
  })
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')
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
- <div className="space-y-2">
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
- {/* 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(' ')}
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
- {/* 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
- {' '}&middot; .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
- </div>
305
- </div>
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
- export interface UpdateEntry {
8
- id: string
9
- name: string
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: (id: string) => void
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.id}
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.newVersion}</span>
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.id)}
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 = String((device as any).externalId ?? (device as any).id ?? '')
58
- const name = String((device as any).name ?? id)
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 any}
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
- interface AgentStatus {
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
- const agents = (data ?? []) as unknown as AgentStatus[]
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
- interface ProviderEntry {
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: ProviderEntry['status']): string {
15
- if (typeof status === 'string') return status
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 = (data ?? []) as unknown as ProviderEntry[]
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>