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.
This commit is contained in:
Anso
2026-04-08 09:46:27 -04:00
committed by GitHub
parent 662bc1a210
commit f6d2199978
5 changed files with 42 additions and 12 deletions
+4
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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);
}
};
+5 -2
View File
@@ -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),
};
+26 -9
View File
@@ -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<ToastType, number> = {
error: 6000,
warning: 5000,
info: 4000,
loading: Infinity,
};
const MAX_VISIBLE = 5;
@@ -73,6 +75,11 @@ const notificationConfig: Record<ToastType, {
icon: <ErrorIcon className="h-6 w-6" />,
gradient: 'from-destructive-muted to-transparent',
},
loading: {
iconColor: 'text-brand',
icon: <Loader2 className="h-6 w-6 animate-spin" strokeWidth={1.5} />,
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 (
<motion.div
@@ -137,14 +145,23 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
</button>
</div>
{/* Progress bar — Sera UI style with Framer Motion */}
{/* Progress bar */}
<div className="absolute bottom-0 left-0 h-1 w-full bg-glass-border rounded-b-xl overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: hovered ? undefined : '100%' }}
transition={{ duration: duration / 1000, ease: 'linear' }}
className="h-full bg-gradient-to-r from-success via-info to-brand"
/>
{type === 'loading' ? (
<motion.div
initial={{ x: '-100%' }}
animate={{ x: '200%' }}
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
className="h-full w-1/3 bg-gradient-to-r from-transparent via-brand to-transparent"
/>
) : (
<motion.div
initial={{ width: 0 }}
animate={{ width: hovered ? undefined : '100%' }}
transition={{ duration: duration / 1000, ease: 'linear' }}
className="h-full bg-gradient-to-r from-success via-info to-brand"
/>
)}
</div>
</motion.div>
);