From f6d219997875be46c5c207a7960dd13dad118f3e Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 8 Apr 2026 09:46:27 -0400 Subject: [PATCH] feat(resources): add loading toast for prune, delete, and purge operations (#426) Show a loading notification with spinner and indeterminate progress bar while Resource Hub operations are in progress, replacing the dead moment between confirmation and result. --- CHANGELOG.md | 4 +++ docs/features/resources.mdx | 2 +- frontend/src/components/ResourcesView.tsx | 6 ++++ frontend/src/components/ui/toast-store.ts | 7 +++-- frontend/src/components/ui/toast.tsx | 35 +++++++++++++++++------ 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dbd35c6..7a4b2e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* **resources:** loading toast notification during prune, delete, and purge operations in the Resources Hub. A spinning indicator with an indeterminate progress bar now appears while the operation runs, replacing the previous dead moment between confirmation and result. + ### Fixed * **console:** fix remote node Host Console failing with "Connection error" / 502. The gateway's console-token request to the remote node was missing license tier headers, causing the remote's Admiral license gate to reject the request. Both the HTTP fetch and the WS upgrade handler now correctly propagate proxy tier headers for console_session tokens. diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index fad2338c..4a52a8c0 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -34,7 +34,7 @@ Four prune buttons let you reclaim disk space immediately. By default they opera The first three buttons have a **More options** dropdown that lets you target **all Docker resources** instead of Sencho-only. Use this carefully, as it can affect other Compose projects running on the same host. The "Purge Unmanaged Containers" button always targets unmanaged containers only. -A confirmation dialog appears before any destructive operation, showing a summary of what will be removed. +A confirmation dialog appears before any destructive operation, showing a summary of what will be removed. While the operation runs, a loading notification appears so you know Sencho is working. Once complete, a success or error notification replaces it with details on how much space was reclaimed. ## Resource tabs diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 562efdee..bea89b79 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -411,6 +411,7 @@ export default function ResourcesView() { const handlePrune = async () => { if (!confirmPrune) return; setIsActioning(true); + const loadingId = toast.loading(`Pruning ${confirmPrune.target}...`); try { const res = await apiFetch('/system/prune/system', { method: 'POST', @@ -427,6 +428,7 @@ export default function ResourcesView() { } catch { toast.error(confirmPrune ? `Failed to prune ${confirmPrune.target}` : 'Prune failed'); } finally { + toast.dismiss(loadingId); setIsActioning(false); setConfirmPrune(null); } @@ -435,6 +437,7 @@ export default function ResourcesView() { const handleDelete = async () => { if (!confirmDelete) return; setIsActioning(true); + const loadingId = toast.loading(`Deleting ${confirmDelete.type.slice(0, -1)}...`); try { const res = await apiFetch(`/system/${confirmDelete.type}/delete`, { method: 'POST', @@ -446,6 +449,7 @@ export default function ResourcesView() { } catch { toast.error(`Failed to delete ${confirmDelete.type.slice(0, -1)}`); } finally { + toast.dismiss(loadingId); setIsActioning(false); setConfirmDelete(null); } @@ -462,6 +466,7 @@ export default function ResourcesView() { const handlePurgeOrphans = async () => { setIsActioning(true); + const loadingId = toast.loading('Purging unmanaged containers...'); try { const res = await apiFetch('/system/prune/orphans', { method: 'POST', @@ -474,6 +479,7 @@ export default function ResourcesView() { } catch { toast.error('Failed to purge selected containers.'); } finally { + toast.dismiss(loadingId); setIsActioning(false); } }; diff --git a/frontend/src/components/ui/toast-store.ts b/frontend/src/components/ui/toast-store.ts index 63795724..e0c66328 100644 --- a/frontend/src/components/ui/toast-store.ts +++ b/frontend/src/components/ui/toast-store.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from 'react'; -export type ToastType = 'success' | 'error' | 'warning' | 'info'; +export type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading'; export interface Toast { id: string; @@ -17,10 +17,11 @@ function notify() { listeners.forEach((fn) => fn()); } -function addToast(type: ToastType, message: string) { +function addToast(type: ToastType, message: string): string { const id = `toast-${++idCounter}-${Date.now()}`; toasts = [...toasts, { id, type, message, createdAt: Date.now() }]; notify(); + return id; } export function removeToast(id: string) { @@ -48,4 +49,6 @@ export const toast = { error: (message: string) => addToast('error', message), warning: (message: string) => addToast('warning', message), info: (message: string) => addToast('info', message), + loading: (message: string) => addToast('loading', message), + dismiss: (id: string) => removeToast(id), }; diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx index 811f577d..692699d3 100644 --- a/frontend/src/components/ui/toast.tsx +++ b/frontend/src/components/ui/toast.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { AnimatePresence, motion } from 'motion/react'; +import { Loader2 } from 'lucide-react'; import { useToasts, removeToast, type ToastType } from './toast-store'; /* ── Durations & Config ── */ @@ -10,6 +11,7 @@ const DURATIONS: Record = { error: 6000, warning: 5000, info: 4000, + loading: Infinity, }; const MAX_VISIBLE = 5; @@ -73,6 +75,11 @@ const notificationConfig: Record, gradient: 'from-destructive-muted to-transparent', }, + loading: { + iconColor: 'text-brand', + icon: , + gradient: 'from-info-muted to-transparent', + }, }; /* ── ToastItem — faithful Sera UI Notification replica ── */ @@ -89,8 +96,9 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message removeToast(id); }, [id]); - // Auto-dismiss timer with hover pause + // Auto-dismiss timer with hover pause (loading toasts never auto-dismiss) useEffect(() => { + if (type === 'loading') return; if (hovered) { if (timerRef.current) { clearTimeout(timerRef.current); @@ -104,7 +112,7 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message return () => { if (timerRef.current) clearTimeout(timerRef.current); }; - }, [hovered, dismiss]); + }, [hovered, dismiss, type]); return ( - {/* Progress bar — Sera UI style with Framer Motion */} + {/* Progress bar */}
- + {type === 'loading' ? ( + + ) : ( + + )}
);