Skip to content

Commit 133990d

Browse files
yasinBursaliclaude
andcommitted
feat(extensions): progress UI + error display during install
Show real-time install progress in the Extensions portal — polling progress endpoint every 3s during install to display phase labels (downloading, starting). Add status badges for installing, setting_up, and error states. Fix frontend timeout from 120s to 300s to match backend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d66cd32 commit 133990d

File tree

1 file changed

+61
-5
lines changed

1 file changed

+61
-5
lines changed

dream-server/extensions/services/dashboard/src/pages/Extensions.jsx

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ const STATUS_STYLES = {
4848
disabled: 'bg-zinc-700 text-zinc-400',
4949
not_installed: 'border border-zinc-700 text-zinc-500',
5050
incompatible: 'bg-orange-500/20 text-orange-400',
51+
installing: 'bg-blue-500/20 text-blue-400',
52+
setting_up: 'bg-blue-500/20 text-blue-400',
53+
error: 'bg-red-500/20 text-red-300',
5154
}
5255

5356
export default function Extensions() {
@@ -63,6 +66,8 @@ export default function Extensions() {
6366
const [toast, setToast] = useState(null)
6467
const [consoleExt, setConsoleExt] = useState(null)
6568
const [refreshing, setRefreshing] = useState(false)
69+
const [installProgress, setInstallProgress] = useState(null)
70+
const installProgressRef = useRef(null)
6671

6772
useEffect(() => {
6873
fetchCatalog()
@@ -102,13 +107,30 @@ export default function Extensions() {
102107
const handleMutation = async (serviceId, action) => {
103108
setMutating(serviceId)
104109
setConfirm(null)
110+
111+
let pollInterval = null
112+
if (action === 'install' || action === 'enable') {
113+
pollInterval = setInterval(async () => {
114+
try {
115+
const res = await fetchJson(`/api/extensions/${serviceId}/progress`)
116+
if (res.ok) {
117+
const data = await res.json()
118+
if (data.status !== 'idle') {
119+
installProgressRef.current = data
120+
setInstallProgress(data)
121+
}
122+
}
123+
} catch { /* ignore polling errors */ }
124+
}, 3000)
125+
}
126+
105127
try {
106128
const url = action === 'uninstall'
107129
? `/api/extensions/${serviceId}`
108130
: `/api/extensions/${serviceId}/${action}`
109131
const res = await fetch(url, {
110132
method: action === 'uninstall' ? 'DELETE' : 'POST',
111-
signal: AbortSignal.timeout(120000),
133+
signal: AbortSignal.timeout(300000),
112134
})
113135
if (!res.ok) {
114136
const err = await res.json().catch(() => ({}))
@@ -123,8 +145,14 @@ export default function Extensions() {
123145
}
124146
await fetchCatalog()
125147
} catch (err) {
126-
setToast({ type: 'error', text: friendlyError(err.message) || `Failed to ${action} extension` })
148+
const progressError = installProgressRef.current?.service_id === serviceId
149+
? installProgressRef.current.error : null
150+
const base = friendlyError(err.message) || `Failed to ${action} extension`
151+
setToast({ type: 'error', text: progressError ? `${base}${progressError}` : base })
127152
} finally {
153+
if (pollInterval) clearInterval(pollInterval)
154+
installProgressRef.current = null
155+
setInstallProgress(null)
128156
setMutating(null)
129157
}
130158
}
@@ -157,8 +185,8 @@ export default function Extensions() {
157185
.filter(Boolean)
158186
)]
159187

160-
const STATUS_FILTERS = ['all', 'enabled', 'stopped', 'disabled', 'not_installed', 'incompatible']
161-
const STATUS_LABELS = { all: 'All', enabled: 'Enabled', stopped: 'Stopped', disabled: 'Disabled', not_installed: 'Not Installed', incompatible: 'Incompatible' }
188+
const STATUS_FILTERS = ['all', 'enabled', 'stopped', 'disabled', 'installing', 'setting_up', 'error', 'not_installed', 'incompatible']
189+
const STATUS_LABELS = { all: 'All', enabled: 'Enabled', stopped: 'Stopped', disabled: 'Disabled', installing: 'Installing', setting_up: 'Setting Up', error: 'Error', not_installed: 'Not Installed', incompatible: 'Incompatible' }
162190

163191
// Filter extensions
164192
const query = search.toLowerCase()
@@ -212,6 +240,8 @@ export default function Extensions() {
212240
<SummaryItem label="Installed" value={summary.installed ?? 0} color="bg-green-500" />
213241
<SummaryItem label="Stopped" value={summary.stopped ?? 0} color="bg-red-500" />
214242
<SummaryItem label="Available" value={summary.not_installed ?? 0} color="bg-indigo-500" />
243+
<SummaryItem label="Installing" value={summary.installing ?? 0} color="bg-blue-500" />
244+
<SummaryItem label="Error" value={summary.error ?? 0} color="bg-red-500" />
215245
<SummaryItem label="Incompatible" value={summary.incompatible ?? 0} color="bg-orange-500" />
216246
</div>
217247
</div>
@@ -286,6 +316,7 @@ export default function Extensions() {
286316
onConsole={() => setConsoleExt(ext)}
287317
onAction={requestAction}
288318
mutating={mutating}
319+
installProgress={installProgress}
289320
/>
290321
))}
291322
</div>
@@ -352,7 +383,7 @@ function SummaryItem({ label, value, color }) {
352383
)
353384
}
354385

355-
function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole, onAction, mutating }) {
386+
function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole, onAction, mutating, installProgress }) {
356387
const iconName = ext.features?.[0]?.icon
357388
const Icon = (iconName && ICON_MAP[iconName]) || Package
358389
const status = ext.status || 'not_installed'
@@ -382,12 +413,16 @@ function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole,
382413
status === 'enabled' ? 'bg-green-500/10' :
383414
status === 'stopped' ? 'bg-red-500/10' :
384415
status === 'incompatible' ? 'bg-orange-500/10' :
416+
(status === 'installing' || status === 'setting_up') ? 'bg-blue-500/10' :
417+
status === 'error' ? 'bg-red-500/10' :
385418
'bg-zinc-800'
386419
}`}>
387420
<Icon size={16} className={
388421
status === 'enabled' ? 'text-green-400' :
389422
status === 'stopped' ? 'text-red-400' :
390423
status === 'incompatible' ? 'text-orange-400' :
424+
(status === 'installing' || status === 'setting_up') ? 'text-blue-400' :
425+
status === 'error' ? 'text-red-400' :
391426
'text-zinc-400'
392427
} />
393428
</div>
@@ -406,6 +441,19 @@ function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole,
406441
>
407442
core
408443
</span>
444+
) : (status === 'installing' || status === 'setting_up') ? (
445+
<span className="text-[10px] px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400 flex items-center gap-1">
446+
<Loader2 size={8} className="animate-spin" />
447+
{status === 'setting_up' ? 'setting up' : 'installing'}
448+
</span>
449+
) : status === 'error' ? (
450+
<span
451+
className="text-[10px] px-2 py-0.5 rounded-full bg-red-500/20 text-red-300 cursor-pointer"
452+
onClick={onConsole}
453+
title="View error details"
454+
>
455+
error
456+
</span>
409457
) : (
410458
<span
411459
className={`text-[10px] px-2 py-0.5 rounded-full uppercase tracking-wider ${statusStyle} ${(status === 'incompatible' || status === 'stopped') ? 'cursor-help' : ''}`}
@@ -437,6 +485,14 @@ function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole,
437485
<p className="text-xs text-zinc-500 line-clamp-2 leading-relaxed">{ext.description || 'No description available.'}</p>
438486
</div>
439487

488+
{/* Progress indicator */}
489+
{isMutating && installProgress?.service_id === ext.id && (
490+
<div className="px-4 py-2 border-t border-zinc-800/60 text-xs text-blue-300 flex items-center gap-2">
491+
<Loader2 size={12} className="animate-spin" />
492+
<span>{installProgress.phase_label || 'Working...'}</span>
493+
</div>
494+
)}
495+
440496
{/* Card footer */}
441497
<div className="border-t border-zinc-800/60 px-4 py-2.5 flex items-center justify-between bg-zinc-900/30">
442498
<div className="flex gap-1.5">

0 commit comments

Comments
 (0)