Skip to content

Commit 0d35445

Browse files
authored
Merge pull request #104 from Iamlovingit/delinstance
fix: make instance deletion asynchronous
2 parents bd2aa8c + 55e9edf commit 0d35445

4 files changed

Lines changed: 80 additions & 34 deletions

File tree

backend/internal/handlers/instance_handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ func (h *InstanceHandler) DeleteInstance(c *gin.Context) {
342342
return
343343
}
344344

345-
utils.Success(c, http.StatusOK, "Instance deleted successfully", nil)
345+
utils.Success(c, http.StatusAccepted, "Instance deletion started", nil)
346346
}
347347

348348
// StartInstance starts an instance

backend/internal/services/instance_service.go

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -766,40 +766,53 @@ func (s *instanceService) Restart(instanceID int) error {
766766
return nil
767767
}
768768

769-
// Delete deletes an instance and all associated K8s resources
769+
// Delete starts deleting an instance and all associated K8s resources.
770770
func (s *instanceService) Delete(instanceID int) error {
771-
ctx := context.Background()
772-
773771
instance, err := s.instanceRepo.GetByID(instanceID)
774772
if err != nil {
775773
return fmt.Errorf("failed to get instance: %w", err)
776774
}
777775

778776
if instance == nil {
779-
// Instance not in DB, but we should still try to clean up K8s resources
780-
fmt.Printf("Instance %d not found in database, attempting to clean up any orphaned K8s resources\n", instanceID)
781-
// Try to clean up with userID=0 (will need to scan all namespaces)
782-
cleanupService := k8s.NewCleanupService()
783-
cleanupService.DeleteAllInstanceResources(ctx, 0, instanceID)
784777
return fmt.Errorf("instance not found")
785778
}
786779

787-
fmt.Printf("Starting deletion of instance %d (user %d)\n", instanceID, instance.UserID)
780+
if instance.Status != "deleting" {
781+
now := time.Now()
782+
instance.Status = "deleting"
783+
instance.UpdatedAt = now
784+
785+
if err := s.instanceRepo.Update(instance); err != nil {
786+
return fmt.Errorf("failed to mark instance as deleting: %w", err)
787+
}
788+
789+
GetHub().BroadcastInstanceStatus(instance.UserID, instance)
790+
}
791+
792+
go s.completeDeletion(instance.UserID, instance.ID)
793+
794+
return nil
795+
}
796+
797+
func (s *instanceService) completeDeletion(userID, instanceID int) {
798+
ctx := context.Background()
799+
800+
fmt.Printf("Starting background deletion of instance %d (user %d)\n", instanceID, userID)
788801

789802
// Use CleanupService to delete ALL resources for this instance (including duplicates)
790803
cleanupService := k8s.NewCleanupService()
791-
if err := cleanupService.DeleteAllInstanceResources(ctx, instance.UserID, instance.ID); err != nil {
804+
if err := cleanupService.DeleteAllInstanceResources(ctx, userID, instanceID); err != nil {
792805
fmt.Printf("Warning: error during resource cleanup for instance %d: %v\n", instanceID, err)
793806
}
794807

795-
// 4. Delete instance record from database
808+
// Delete instance record from database after background cleanup finishes.
796809
fmt.Printf("Deleting instance %d from database...\n", instanceID)
797810
if err := s.instanceRepo.Delete(instanceID); err != nil {
798-
return fmt.Errorf("failed to delete instance record: %w", err)
811+
fmt.Printf("Error: failed to delete instance %d record: %v\n", instanceID, err)
812+
return
799813
}
800814

801815
fmt.Printf("Instance %d deleted successfully\n", instanceID)
802-
return nil
803816
}
804817

805818
// cleanupOrphanedResources cleans up any orphaned K8s resources for an instance

frontend/src/pages/admin/InstanceManagementPage.tsx

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useMemo, useState } from 'react';
1+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
22
import AdminLayout from '../../components/AdminLayout';
33
import ConfirmDialog from '../../components/ConfirmDialog';
44
import { useI18n } from '../../contexts/I18nContext';
@@ -20,13 +20,11 @@ const InstanceManagementPage: React.FC = () => {
2020
const [actionLoading, setActionLoading] = useState<string | null>(null);
2121
const [pendingDeleteInstance, setPendingDeleteInstance] = useState<Instance | null>(null);
2222

23-
useEffect(() => {
24-
loadData();
25-
}, []);
26-
27-
const loadData = async () => {
23+
const loadData = useCallback(async (options?: { silent?: boolean }) => {
2824
try {
29-
setLoading(true);
25+
if (!options?.silent) {
26+
setLoading(true);
27+
}
3028
setError(null);
3129
const [instancesData, usersData] = await Promise.all([
3230
adminInstanceService.getInstances(1, 1000),
@@ -37,9 +35,29 @@ const InstanceManagementPage: React.FC = () => {
3735
} catch (err: any) {
3836
setError(err.response?.data?.error || t('admin.failedToLoadInstances'));
3937
} finally {
40-
setLoading(false);
38+
if (!options?.silent) {
39+
setLoading(false);
40+
}
4141
}
42-
};
42+
}, [t]);
43+
44+
useEffect(() => {
45+
void loadData();
46+
}, [loadData]);
47+
48+
useEffect(() => {
49+
if (!instances.some((instance) => instance.status === 'creating' || instance.status === 'deleting')) {
50+
return;
51+
}
52+
53+
const intervalId = window.setInterval(() => {
54+
void loadData({ silent: true });
55+
}, 5000);
56+
57+
return () => {
58+
window.clearInterval(intervalId);
59+
};
60+
}, [instances, loadData]);
4361

4462
const userMap = useMemo(() => {
4563
return new Map(users.map((user) => [user.id, user.username]));
@@ -196,7 +214,7 @@ const InstanceManagementPage: React.FC = () => {
196214
))}
197215
</select>
198216
<button
199-
onClick={loadData}
217+
onClick={() => void loadData()}
200218
className="app-button-secondary"
201219
>
202220
{t('common.refresh')}
@@ -299,14 +317,14 @@ const InstanceManagementPage: React.FC = () => {
299317
)}
300318
<button
301319
onClick={() => handleAction(instance, 'sync')}
302-
disabled={actionLoading === `sync-${instance.id}`}
320+
disabled={actionLoading === `sync-${instance.id}` || instance.status === 'deleting'}
303321
className="rounded-md bg-[#f3f0ed] px-3 py-1.5 text-xs font-medium text-[#5f5957] hover:bg-[#ebe3dd] disabled:opacity-50"
304322
>
305323
{t('common.refresh')}
306324
</button>
307325
<button
308326
onClick={() => setPendingDeleteInstance(instance)}
309-
disabled={actionLoading === `delete-${instance.id}`}
327+
disabled={actionLoading === `delete-${instance.id}` || instance.status === 'deleting'}
310328
className="rounded-md bg-red-50 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-100 disabled:opacity-50"
311329
>
312330
{t('common.delete')}

frontend/src/pages/instances/InstanceListPage.tsx

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { Instance } from '../../types/instance';
88
import { useI18n } from '../../contexts/I18nContext';
99

1010
type ViewMode = 'list' | 'card';
11-
type StatusFilter = 'all' | 'running' | 'stopped' | 'creating' | 'error';
11+
type StatusFilter = 'all' | 'running' | 'stopped' | 'creating' | 'deleting' | 'error';
1212

1313
const INSTANCE_FIELDS_TO_COMPARE: Array<keyof Instance> = [
1414
'id',
@@ -149,7 +149,7 @@ const InstanceCardItem = React.memo(({
149149
</Link>
150150
<button
151151
onClick={() => onRequestDelete(instance.id)}
152-
disabled={deletingIds.includes(instance.id)}
152+
disabled={deletingIds.includes(instance.id) || instance.status === 'deleting'}
153153
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none disabled:opacity-50"
154154
>
155155
{deletingIds.includes(instance.id) ? `${t('common.delete')}...` : t('common.delete')}
@@ -223,7 +223,7 @@ const InstanceListItem = React.memo(({
223223

224224
<button
225225
onClick={() => onRequestDelete(instance.id)}
226-
disabled={deletingIds.includes(instance.id)}
226+
disabled={deletingIds.includes(instance.id) || instance.status === 'deleting'}
227227
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none disabled:opacity-50"
228228
>
229229
{deletingIds.includes(instance.id) ? `${t('common.delete')}...` : t('common.delete')}
@@ -269,7 +269,7 @@ const InstanceListPage: React.FC = () => {
269269
}, [loadInstances]);
270270

271271
useEffect(() => {
272-
if (!instances.some((instance) => instance.status === 'creating')) {
272+
if (!instances.some((instance) => instance.status === 'creating' || instance.status === 'deleting')) {
273273
return;
274274
}
275275

@@ -280,7 +280,7 @@ const InstanceListPage: React.FC = () => {
280280
return () => {
281281
window.clearInterval(intervalId);
282282
};
283-
}, [instances]);
283+
}, [instances, loadInstances]);
284284

285285
// Handle WebSocket status updates
286286
const handleStatusUpdate = useCallback((update: { instance_id: number; status: string; pod_name?: string; pod_ip?: string }) => {
@@ -330,14 +330,19 @@ const InstanceListPage: React.FC = () => {
330330
try {
331331
setDeletingIds((prevIds) => [...prevIds, id]);
332332
await instanceService.deleteInstance(id);
333-
setInstances((prevInstances) => prevInstances.filter((instance) => instance.id !== id));
333+
setInstances((prevInstances) =>
334+
prevInstances.map((instance) =>
335+
instance.id === id ? { ...instance, status: 'deleting' } : instance,
336+
),
337+
);
334338
setPendingDeleteId(null);
339+
await loadInstances({ silent: true });
335340
} catch (err: any) {
336341
alert(err.response?.data?.error || t('instances.failedToDelete'));
337342
} finally {
338343
setDeletingIds((prevIds) => prevIds.filter((deletingId) => deletingId !== id));
339344
}
340-
}, [t]);
345+
}, [loadInstances, t]);
341346

342347
const handleStart = useCallback(async (id: number) => {
343348
try {
@@ -375,10 +380,12 @@ const InstanceListPage: React.FC = () => {
375380
return 'bg-gray-100 text-gray-800';
376381
case 'creating':
377382
return 'bg-yellow-100 text-yellow-800';
383+
case 'deleting':
384+
return 'bg-orange-100 text-orange-800';
378385
case 'error':
379386
return 'bg-red-100 text-red-800';
380387
default:
381-
return 'VM';
388+
return 'bg-gray-100 text-gray-800';
382389
}
383390
};
384391

@@ -403,6 +410,13 @@ const InstanceListPage: React.FC = () => {
403410
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
404411
</svg>
405412
);
413+
case 'deleting':
414+
return (
415+
<svg className="animate-spin w-3 h-3 mr-1.5 text-orange-600" fill="none" viewBox="0 0 24 24">
416+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
417+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
418+
</svg>
419+
);
406420
default:
407421
return null;
408422
}
@@ -555,6 +569,7 @@ const InstanceListPage: React.FC = () => {
555569
<option value="running">{t('status.running')}</option>
556570
<option value="stopped">{t('status.stopped')}</option>
557571
<option value="creating">{t('status.creating')}</option>
572+
<option value="deleting">{t('status.deleting')}</option>
558573
<option value="error">{t('status.error')}</option>
559574
</select>
560575

0 commit comments

Comments
 (0)