fix: per-item loading animations in instance list

- Replace full-screen loading with per-instance busy state
- Add busy overlay with spinner to instance cards
- Disable action buttons while instance is busy
- Add CSS for visual dimming and overlay positioning

Fixes add-config-profiles: instance UI polish
This commit is contained in:
2026-05-24 19:20:21 +00:00
parent c8da0ab6c4
commit 869efda214
2 changed files with 56 additions and 1 deletions
+37 -1
View File
@@ -43,6 +43,9 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<string | null>(null); const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<string | null>(null);
const [selectedProfileForAction, setSelectedProfileForAction] = useState(""); const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
// Per-instance busy state for actions
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
const loadInstances = useCallback(async () => { const loadInstances = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -104,6 +107,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
}, [projectId]); }, [projectId]);
const handleStart = async (instanceId: string, configProfileId?: string) => { const handleStart = async (instanceId: string, configProfileId?: string) => {
setBusyInstanceId(instanceId);
try { try {
await startInstance(projectId, repoId, instanceId, configProfileId); await startInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null); setProfileSelectInstanceId(null);
@@ -111,20 +115,26 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
await loadInstances(); await loadInstances();
} catch { } catch {
setError("Failed to start instance"); setError("Failed to start instance");
} finally {
setBusyInstanceId(null);
} }
}; };
const handleStop = async (instanceId: string) => { const handleStop = async (instanceId: string) => {
setBusyInstanceId(instanceId);
try { try {
await stopInstance(projectId, repoId, instanceId); await stopInstance(projectId, repoId, instanceId);
setStopConfirmId(null); setStopConfirmId(null);
await loadInstances(); await loadInstances();
} catch { } catch {
setError("Failed to stop instance"); setError("Failed to stop instance");
} finally {
setBusyInstanceId(null);
} }
}; };
const handleRestart = async (instanceId: string, configProfileId?: string) => { const handleRestart = async (instanceId: string, configProfileId?: string) => {
setBusyInstanceId(instanceId);
try { try {
await restartInstance(projectId, repoId, instanceId, configProfileId); await restartInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null); setProfileSelectInstanceId(null);
@@ -132,26 +142,34 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
await loadInstances(); await loadInstances();
} catch { } catch {
setError("Failed to restart instance"); setError("Failed to restart instance");
} finally {
setBusyInstanceId(null);
} }
}; };
const handleDelete = async (instanceId: string) => { const handleDelete = async (instanceId: string) => {
if (!confirm("Are you sure you want to delete this instance?")) return; if (!confirm("Are you sure you want to delete this instance?")) return;
setBusyInstanceId(instanceId);
try { try {
await deleteInstance(projectId, repoId, instanceId); await deleteInstance(projectId, repoId, instanceId);
// Update state immediately instead of reloading // Update state immediately instead of reloading
setInstances(prev => prev.filter(i => i.id !== instanceId)); setInstances(prev => prev.filter(i => i.id !== instanceId));
} catch { } catch {
setError("Failed to delete instance"); setError("Failed to delete instance");
} finally {
setBusyInstanceId(null);
} }
}; };
const handleRecreateTunnel = async (instanceId: string) => { const handleRecreateTunnel = async (instanceId: string) => {
setBusyInstanceId(instanceId);
try { try {
await recreateInstanceTunnel(projectId, repoId, instanceId); await recreateInstanceTunnel(projectId, repoId, instanceId);
await loadInstances(); await loadInstances();
} catch { } catch {
setError("Failed to recreate tunnel"); setError("Failed to recreate tunnel");
} finally {
setBusyInstanceId(null);
} }
}; };
@@ -202,7 +220,13 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
) : ( ) : (
<div className="instance-grid"> <div className="instance-grid">
{instances.map((instance) => ( {instances.map((instance) => (
<div key={instance.id} className="instance-card"> <div key={instance.id} className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}>
{busyInstanceId === instance.id && (
<div className="instance-busy-overlay">
<Icon name="loading" size="md" />
<span>Working...</span>
</div>
)}
<div className="instance-info"> <div className="instance-info">
<div className="instance-name">{instance.display_name}</div> <div className="instance-name">{instance.display_name}</div>
<div className="instance-meta"> <div className="instance-meta">
@@ -244,6 +268,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
onClick={() => void handleRecreateTunnel(instance.id)} onClick={() => void handleRecreateTunnel(instance.id)}
type="button" type="button"
title="Recreate tunnel" title="Recreate tunnel"
disabled={busyInstanceId === instance.id}
> >
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
Fix Tunnel Fix Tunnel
@@ -256,6 +281,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="secondary-button small" className="secondary-button small"
onClick={() => navigate(`/instances/${instance.id}/terminal`)} onClick={() => navigate(`/instances/${instance.id}/terminal`)}
type="button" type="button"
disabled={busyInstanceId === instance.id}
> >
<Icon name="terminal" size="sm" /> <Icon name="terminal" size="sm" />
Terminal Terminal
@@ -280,6 +306,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="primary-button small" className="primary-button small"
onClick={() => void handleStart(instance.id, selectedProfileForAction || undefined)} onClick={() => void handleStart(instance.id, selectedProfileForAction || undefined)}
type="button" type="button"
disabled={busyInstanceId === instance.id}
> >
<Icon name="play" size="sm" /> <Icon name="play" size="sm" />
Start Start
@@ -291,6 +318,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
setSelectedProfileForAction(""); setSelectedProfileForAction("");
}} }}
type="button" type="button"
disabled={busyInstanceId === instance.id}
> >
Cancel Cancel
</button> </button>
@@ -307,6 +335,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
setSelectedProfileForAction(instance.selected_config_profile_id || ""); setSelectedProfileForAction(instance.selected_config_profile_id || "");
}} }}
type="button" type="button"
disabled={busyInstanceId === instance.id}
> >
<Icon name="play" size="sm" /> <Icon name="play" size="sm" />
Start Start
@@ -323,6 +352,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="ghost-button small danger-text" className="ghost-button small danger-text"
onClick={() => void handleStop(instance.id)} onClick={() => void handleStop(instance.id)}
type="button" type="button"
disabled={busyInstanceId === instance.id}
> >
Yes Yes
</button> </button>
@@ -330,6 +360,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="ghost-button small" className="ghost-button small"
onClick={() => setStopConfirmId(null)} onClick={() => setStopConfirmId(null)}
type="button" type="button"
disabled={busyInstanceId === instance.id}
> >
No No
</button> </button>
@@ -339,6 +370,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="ghost-button small" className="ghost-button small"
onClick={() => setStopConfirmId(instance.id)} onClick={() => setStopConfirmId(instance.id)}
type="button" type="button"
disabled={busyInstanceId === instance.id}
> >
<Icon name="stop" size="sm" /> <Icon name="stop" size="sm" />
</button> </button>
@@ -360,6 +392,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="primary-button small" className="primary-button small"
onClick={() => void handleRestart(instance.id, selectedProfileForAction || undefined)} onClick={() => void handleRestart(instance.id, selectedProfileForAction || undefined)}
type="button" type="button"
disabled={busyInstanceId === instance.id}
> >
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
Restart Restart
@@ -371,6 +404,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
setSelectedProfileForAction(""); setSelectedProfileForAction("");
}} }}
type="button" type="button"
disabled={busyInstanceId === instance.id}
> >
Cancel Cancel
</button> </button>
@@ -387,6 +421,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
setSelectedProfileForAction(instance.selected_config_profile_id || ""); setSelectedProfileForAction(instance.selected_config_profile_id || "");
}} }}
type="button" type="button"
disabled={busyInstanceId === instance.id}
> >
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
</button> </button>
@@ -397,6 +432,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="ghost-button small danger-text" className="ghost-button small danger-text"
onClick={() => void handleDelete(instance.id)} onClick={() => void handleDelete(instance.id)}
type="button" type="button"
disabled={busyInstanceId === instance.id}
> >
<Icon name="delete" size="sm" /> <Icon name="delete" size="sm" />
</button> </button>
+19
View File
@@ -2449,6 +2449,7 @@ a.nav-item,
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
position: relative;
} }
.instance-info { .instance-info {
@@ -2481,6 +2482,24 @@ a.nav-item,
align-items: center; align-items: center;
} }
.instance-card.busy {
opacity: 0.7;
}
.instance-busy-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
background: rgba(var(--bg-rgb, 255, 255, 255), 0.8);
border-radius: 10px;
z-index: 1;
font-size: var(--text-sm);
color: var(--muted);
}
/* ============================================ /* ============================================
Terminal Styles Terminal Styles
============================================ */ ============================================ */