fix: purge deleted-stack notifications from panel and ticker (#1674)

* fix: purge deleted-stack notifications from panel and ticker

Stack delete already cascaded scans and drift but left notification_history rows, so the bell and Activity ticker kept showing the deleted stack. Purge those rows in the shared deletion lifecycle, invalidate connected clients, and drop node-scoped in-memory rows immediately.

* fix: target remote notification purge by hub node id

Remote stack-deleted invalidations always reconcile with hub rn.id, and
notification refetch preserves failed node slices instead of wiping them.
This commit is contained in:
Anso
2026-07-22 20:00:45 -04:00
committed by GitHub
parent 394e68671d
commit 698b7d0713
9 changed files with 644 additions and 14 deletions
@@ -22,6 +22,9 @@ const makeNotif = (overrides: Partial<NotificationItem> = {}): NotificationItem
id: 1, level: 'info', message: 'test', timestamp: 1000, is_read: 0, ...overrides,
});
const nodeMessageKeys = (items: NotificationItem[]): string[] =>
items.map((n) => `${n.nodeId}:${n.message}`).sort();
class MockWS {
static instances: MockWS[] = [];
static readonly CONNECTING = 0;
@@ -251,4 +254,362 @@ describe('useNotifications', () => {
expect(MockWS.instances).toHaveLength(2);
await waitFor(() => expect(fetchForNode).toHaveBeenCalledWith('/notifications', 2));
});
it('removeNotificationsForStack drops only the matching node and stack', async () => {
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => [] });
const remote = makeRemoteNode('online', { id: 2, name: 'Remote-B' });
const { result } = renderHook(() =>
useNotifications({ nodes: [localNode, remote], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
);
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
act(() => {
MockWS.instances[0]?.onmessage?.({
data: JSON.stringify({
type: 'notification',
payload: makeNotif({ id: 1, stack_name: 'web', message: 'a-web' }),
}),
});
MockWS.instances[0]?.onmessage?.({
data: JSON.stringify({
type: 'notification',
payload: makeNotif({ id: 2, stack_name: 'db', message: 'a-db' }),
}),
});
MockWS.instances[1]?.onmessage?.({
data: JSON.stringify({
type: 'notification',
payload: makeNotif({ id: 3, stack_name: 'web', message: 'b-web' }),
}),
});
MockWS.instances[0]?.onmessage?.({
data: JSON.stringify({
type: 'notification',
payload: makeNotif({ id: 4, message: 'a-unattached' }),
}),
});
});
expect(result.current.notifications).toHaveLength(4);
act(() => {
result.current.removeNotificationsForStack(localNode.id, 'web');
});
expect(result.current.notifications.map((n) => `${n.nodeId}:${n.stack_name ?? ''}:${n.message}`).sort()).toEqual([
'1::a-unattached',
'1:db:a-db',
'2:web:b-web',
]);
act(() => {
result.current.removeNotificationsForStack(localNode.id, 'web');
});
expect(result.current.notifications).toHaveLength(3);
});
it('refetches notifications on scope=notifications invalidate after mount fetch', async () => {
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => [] });
const nodes = [localNode];
const onStateInvalidate = vi.fn();
const onImageUpdatesChange = vi.fn();
renderHook(() =>
useNotifications({ nodes, onStateInvalidate, onImageUpdatesChange }),
);
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
const afterMount = (apiFetch as ReturnType<typeof vi.fn>).mock.calls.filter(
(c) => c[0] === '/notifications',
).length;
act(() => {
MockWS.instances[0]?.onmessage?.({
data: JSON.stringify({
type: 'state-invalidate',
scope: 'notifications',
// No stack-deleted action: this case only asserts refetch delta.
nodeId: 1,
ts: 1000,
}),
});
});
await waitFor(() => {
const after = (apiFetch as ReturnType<typeof vi.fn>).mock.calls.filter(
(c) => c[0] === '/notifications',
).length;
expect(after).toBe(afterMount + 1);
});
});
it('remote notifications invalidate triggers one additional remote fetch', async () => {
const remote = makeRemoteNode('online', { id: 2, name: 'sencho-sat-qa' });
const nodes = [localNode, remote];
const onStateInvalidate = vi.fn();
const onImageUpdatesChange = vi.fn();
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => [] });
(fetchForNode as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => [{ id: 9, level: 'error', message: 'remote row', timestamp: 2000, is_read: 0, stack_name: 'db' }],
});
const { result } = renderHook(() =>
useNotifications({ nodes, onStateInvalidate, onImageUpdatesChange }),
);
await waitFor(() => expect(fetchForNode).toHaveBeenCalledWith('/notifications', 2));
const afterMount = (fetchForNode as ReturnType<typeof vi.fn>).mock.calls.filter(
(c) => c[0] === '/notifications' && c[1] === 2,
).length;
act(() => {
MockWS.instances[1]?.onmessage?.({
data: JSON.stringify({
type: 'state-invalidate',
scope: 'notifications',
nodeId: 2,
ts: 1000,
}),
});
});
await waitFor(() => {
const after = (fetchForNode as ReturnType<typeof vi.fn>).mock.calls.filter(
(c) => c[0] === '/notifications' && c[1] === 2,
).length;
expect(after).toBe(afterMount + 1);
});
await waitFor(() => expect(result.current.notifications.some((n) => n.nodeId === 2 && n.nodeName === 'sencho-sat-qa')).toBe(true));
});
it('unrelated state-invalidate scopes do not refetch notifications', async () => {
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => [] });
const nodes = [localNode];
renderHook(() =>
useNotifications({ nodes, onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
);
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
const afterMount = (apiFetch as ReturnType<typeof vi.fn>).mock.calls.filter(
(c) => c[0] === '/notifications',
).length;
act(() => {
MockWS.instances[0]?.onmessage?.({
data: JSON.stringify({
type: 'state-invalidate',
scope: 'stack',
nodeId: 1,
stackName: 'web',
action: 'start',
ts: 1000,
}),
});
});
expect(
(apiFetch as ReturnType<typeof vi.fn>).mock.calls.filter((c) => c[0] === '/notifications').length,
).toBe(afterMount);
});
it('remote stack-deleted purge uses hub rn.id when payload nodeId collides with local', async () => {
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => [] });
(fetchForNode as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => [] });
const remote = makeRemoteNode('online', { id: 7, name: 'Remote-7' });
const nodes = [localNode, remote];
const { result } = renderHook(() =>
useNotifications({ nodes, onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
);
await waitFor(() => expect(fetchForNode).toHaveBeenCalledWith('/notifications', 7));
act(() => {
MockWS.instances[0]?.onmessage?.({
data: JSON.stringify({
type: 'notification',
payload: makeNotif({ id: 1, stack_name: 'web', message: 'local-web' }),
}),
});
MockWS.instances[1]?.onmessage?.({
data: JSON.stringify({
type: 'notification',
payload: makeNotif({ id: 2, stack_name: 'web', message: 'remote-web' }),
}),
});
});
expect(nodeMessageKeys(result.current.notifications)).toEqual(['1:local-web', '7:remote-web']);
// Production remotes broadcast their own local DB id (often 1), which collides
// with the hub local node. The remote socket must purge hub rn.id=7 only.
act(() => {
MockWS.instances[1]?.onmessage?.({
data: JSON.stringify({
type: 'state-invalidate',
scope: 'notifications',
action: 'stack-deleted',
nodeId: 1,
stackName: 'web',
ts: Date.now(),
}),
});
});
expect(nodeMessageKeys(result.current.notifications)).toEqual(['1:local-web']);
});
it('preserves cached notifications for a failed refetch leg after invalidate', async () => {
const remote = makeRemoteNode('online', { id: 7, name: 'Remote-7' });
// Stable identity so the nodes effect does not re-fetch on every render.
const nodes = [localNode, remote];
let remoteFail = false;
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => [
{ id: 10, level: 'info', message: 'local-kept', timestamp: 5000, is_read: 0, stack_name: 'other' },
],
});
(fetchForNode as ReturnType<typeof vi.fn>).mockImplementation(() => {
if (remoteFail) {
return Promise.resolve({ ok: false, status: 502, json: async () => [] });
}
return Promise.resolve({
ok: true,
json: async () => [
{ id: 20, level: 'error', message: 'remote-web', timestamp: 4000, is_read: 0, stack_name: 'web' },
{ id: 21, level: 'info', message: 'remote-db', timestamp: 3000, is_read: 0, stack_name: 'db' },
],
});
});
const { result } = renderHook(() =>
useNotifications({ nodes, onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
);
await waitFor(() => expect(result.current.notifications.some((n) => n.message === 'remote-web')).toBe(true));
expect(nodeMessageKeys(result.current.notifications)).toEqual([
'1:local-kept',
'7:remote-db',
'7:remote-web',
]);
remoteFail = true;
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => [
{ id: 11, level: 'info', message: 'local-kept-v2', timestamp: 6000, is_read: 0, stack_name: 'other' },
],
});
act(() => {
MockWS.instances[1]?.onmessage?.({
data: JSON.stringify({
type: 'state-invalidate',
scope: 'notifications',
action: 'stack-deleted',
nodeId: 1,
stackName: 'web',
ts: Date.now(),
}),
});
});
// Wait for post-invalidate merge (local slice replaced), not only optimistic purge.
await waitFor(() => {
expect(result.current.notifications.some((n) => n.message === 'local-kept-v2')).toBe(true);
});
expect(result.current.notifications.some((n) => n.message === 'remote-web')).toBe(false);
expect(nodeMessageKeys(result.current.notifications)).toEqual([
'1:local-kept-v2',
'7:remote-db',
]);
});
it('does not restore a deleted stack notification from a stale pre-removal fetch', async () => {
let resolveStale!: (value: { ok: boolean; status: number; json: () => Promise<unknown[]> }) => void;
const stalePromise = new Promise<{ ok: boolean; status: number; json: () => Promise<unknown[]> }>((resolve) => {
resolveStale = resolve;
});
let call = 0;
let releaseStale = false;
(apiFetch as ReturnType<typeof vi.fn>).mockImplementation(() => {
call += 1;
if (!releaseStale && call >= 2) return stalePromise;
if (releaseStale) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => [
{ id: 2, level: 'info', message: 'fresh-db', timestamp: 4000, is_read: 0, stack_name: 'db' },
],
});
}
return Promise.resolve({ ok: true, status: 200, json: async () => [] });
});
// Stable identity so the nodes effect does not re-fetch on every render.
const nodes = [localNode];
const onStateInvalidate = vi.fn();
const onImageUpdatesChange = vi.fn();
const { result } = renderHook(() =>
useNotifications({ nodes, onStateInvalidate, onImageUpdatesChange }),
);
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
const afterMount = call;
act(() => {
MockWS.instances[0]?.onmessage?.({
data: JSON.stringify({
type: 'notification',
payload: makeNotif({ id: 1, stack_name: 'web', message: 'keep-until-remove' }),
}),
});
});
expect(result.current.notifications.some((n) => n.stack_name === 'web')).toBe(true);
// Start an in-flight fetch without act: React's act waits on the deferred promise.
MockWS.instances[0]?.onmessage?.({
data: JSON.stringify({
type: 'state-invalidate',
scope: 'notifications',
action: 'stack-deleted',
nodeId: 1,
stackName: 'other',
ts: Date.now(),
}),
});
await waitFor(() => expect(call).toBeGreaterThan(afterMount));
act(() => {
result.current.removeNotificationsForStack(localNode.id, 'web');
});
expect(result.current.notifications.some((n) => n.stack_name === 'web')).toBe(false);
resolveStale({
ok: true,
status: 200,
json: async () => [
{ id: 1, level: 'error', message: 'stale-web', timestamp: 3000, is_read: 0, stack_name: 'web' },
],
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.notifications.some((n) => n.stack_name === 'web')).toBe(false);
releaseStale = true;
act(() => {
MockWS.instances[0]?.onmessage?.({
data: JSON.stringify({
type: 'state-invalidate',
scope: 'notifications',
action: 'stack-deleted',
nodeId: 1,
stackName: 'web',
ts: Date.now(),
}),
});
});
await waitFor(() => {
expect(result.current.notifications.some((n) => n.stack_name === 'db' && n.message === 'fresh-db')).toBe(true);
});
expect(result.current.notifications.some((n) => n.stack_name === 'web')).toBe(false);
});
});
@@ -28,8 +28,12 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
// Its spans instrument only that first fetch so later polls do not pollute the
// report.
const notificationsReadyRef = useRef(false);
// Monotonic generation so a slow pre-removal fetch cannot overwrite state
// after removeNotificationsForStack (or a newer fetch) has already run.
const notificationFetchGeneration = useRef(0);
const fetchNotifications = async () => {
const generation = ++notificationFetchGeneration.current;
try {
const currentNodes = nodesRef.current;
const localNode = currentNodes.find(n => n.type === 'local');
@@ -41,7 +45,10 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
const localPromise = apiFetch('/notifications', { localOnly: true } as Parameters<typeof apiFetch>[1]);
const remotePromises = remoteNodes.map(n => fetchForNode('/notifications', n.id));
const all: NotificationItem[] = [];
// Only successful legs replace their node slice. A failed/non-OK/decode-failed
// leg is not treated as an empty list; that node's cached rows stay until a
// later successful response (including an intentional empty list).
const successfulSlices = new Map<number, NotificationItem[]>();
const instrument = !notificationsReadyRef.current;
const headersSpan = instrument ? beginSpan('fetch_headers', { background: true }) : null;
@@ -55,8 +62,15 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
if (bodySpan !== null) endSpan(bodySpan);
bodySpan = null;
const dispatchSpan = instrument ? beginSpan('state_dispatch', { background: true }) : null;
data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' }));
const localId = localNode?.id ?? -1;
const localName = localNode?.name ?? 'Local';
successfulSlices.set(
localId,
data.map(n => ({ ...n, nodeId: localId, nodeName: localName })),
);
if (dispatchSpan !== null) endSpan(dispatchSpan);
} else {
console.error(`[Notifications] local fetch HTTP ${localRes.status}`);
}
} catch (e) {
if (headersSpan !== null) endSpan(headersSpan, { outcome: 'error' });
@@ -71,17 +85,38 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
// Remotes settle in the background; they never gate the milestone above.
const remoteNodeResults = await Promise.allSettled(remotePromises);
for (let i = 0; i < remoteNodes.length; i++) {
for (const [i, rn] of remoteNodes.entries()) {
const result = remoteNodeResults[i];
if (result?.status === 'fulfilled' && result.value.ok) {
if (result.status !== 'fulfilled') {
console.error(`[Notifications] remote fetch error (${rn.name}):`, result.reason);
continue;
}
if (!result.value.ok) {
console.error(`[Notifications] remote fetch HTTP ${result.value.status} (${rn.name})`);
continue;
}
try {
const data = await result.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
const rn = remoteNodes[i];
data.forEach(n => all.push({ ...n, nodeId: rn.id, nodeName: rn.name }));
successfulSlices.set(
rn.id,
data.map(n => ({ ...n, nodeId: rn.id, nodeName: rn.name })),
);
} catch (e) {
console.error(`[Notifications] remote fetch decode error (${rn.name}):`, e);
}
}
all.sort((a, b) => b.timestamp - a.timestamp);
setNotifications(all);
if (generation !== notificationFetchGeneration.current) return;
// Keep failed/offline node slices; drop rows for nodes no longer in the roster.
const activeNodeIds = new Set(currentNodes.map(n => n.id));
setNotifications(prev => {
const preserved = prev.filter(n =>
n.nodeId == null
|| (activeNodeIds.has(n.nodeId) && !successfulSlices.has(n.nodeId)),
);
const replaced = [...successfulSlices.values()].flat();
return [...preserved, ...replaced].sort((a, b) => b.timestamp - a.timestamp);
});
} catch (e) {
console.error('[Notifications] fetch error:', e);
}
@@ -90,6 +125,33 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
const fetchNotificationsRef = useRef(fetchNotifications);
fetchNotificationsRef.current = fetchNotifications;
const purgeStackNotifications = (nodeId: number, stackName: string) => {
notificationFetchGeneration.current += 1;
setNotifications(prev =>
prev.filter(n => n.nodeId !== nodeId || n.stack_name !== stackName),
);
};
const tryPurgeDeletedStackFromInvalidate = (
msg: { action?: unknown; nodeId?: unknown; stackName?: unknown },
fallbackNodeId?: number,
) => {
if (msg.action !== 'stack-deleted' || typeof msg.stackName !== 'string') return;
const nodeId = typeof msg.nodeId === 'number' ? msg.nodeId : fallbackNodeId;
if (nodeId === undefined) return;
purgeStackNotifications(nodeId, msg.stackName);
};
const reconcileNotificationsInvalidate = (
msg: { action?: unknown; nodeId?: unknown; stackName?: unknown },
fallbackNodeId?: number,
) => {
tryPurgeDeletedStackFromInvalidate(msg, fallbackNodeId);
void fetchNotificationsRef.current();
};
const reconcileNotificationsInvalidateRef = useRef(reconcileNotificationsInvalidate);
reconcileNotificationsInvalidateRef.current = reconcileNotificationsInvalidate;
// Local notification WebSocket with exponential-backoff reconnect.
useEffect(() => {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -125,7 +187,9 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
} else if (msg.type === 'state-invalidate') {
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: msg }));
onStateInvalidateRef.current();
if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
if (msg.scope === 'notifications') {
reconcileNotificationsInvalidateRef.current(msg);
} else if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
onImageUpdatesChangeRef.current();
}
}
@@ -203,7 +267,11 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
} else if (msg.type === 'state-invalidate') {
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: { ...msg, nodeId: rn.id } }));
onStateInvalidateRef.current();
if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
if (msg.scope === 'notifications') {
// Remote payloads use the remote's local DB node ID. Hub UI state
// is keyed by rn.id, so always reconcile with the hub node ID.
reconcileNotificationsInvalidateRef.current({ ...msg, nodeId: rn.id });
} else if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
onImageUpdatesChangeRef.current();
}
}
@@ -324,5 +392,6 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
markAllRead,
deleteNotification,
clearAllNotifications,
removeNotificationsForStack: purgeStackNotifications,
} as const;
}
@@ -130,6 +130,7 @@ function setup(over: {
activeNode?: Parameters<typeof useStackActions>[0]['activeNode'];
setActiveNode?: Parameters<typeof useStackActions>[0]['setActiveNode'];
onDeletedOpenStack?: () => void;
removeNotificationsForStack?: (nodeId: number, stackName: string) => void;
} = {}) {
const editorState = makeEditorState(over.editorState);
const stackListState = makeStackListState(over.stackList);
@@ -141,6 +142,7 @@ function setup(over: {
const overlayState = makeOverlay(over.overlay);
const setActiveNode = over.setActiveNode ?? vi.fn();
const onDeletedOpenStack = over.onDeletedOpenStack ?? vi.fn();
const removeNotificationsForStack = over.removeNotificationsForStack ?? vi.fn();
const { result } = renderHook(() =>
useStackActions({
@@ -157,9 +159,10 @@ function setup(over: {
hasUpdateGuard: over.hasUpdateGuard ?? false,
canEditStack: over.canEditStack ?? (() => true),
onDeletedOpenStack,
removeNotificationsForStack,
}),
);
return { result, editorState, stackListState, overlayState, navState, setActiveNode, onDeletedOpenStack };
return { result, editorState, stackListState, overlayState, navState, setActiveNode, onDeletedOpenStack, removeNotificationsForStack };
}
describe('useStackActions.saveFile', () => {
@@ -1617,4 +1620,40 @@ describe('useStackActions.deleteStack', () => {
expect(onDeletedOpenStack).not.toHaveBeenCalled();
expect(stackListState.refreshStacks).not.toHaveBeenCalled();
});
it('calls removeNotificationsForStack with node id and canonical name on success', async () => {
vi.mocked(apiFetch).mockResolvedValue(new Response(null, { status: 200 }));
const removeNotificationsForStack = vi.fn();
const { result } = setup({
overlay: { stackToDelete: 'web.yml' },
stackList: { selectedFile: 'web.yml', files: ['web.yml'] },
navState: { activeView: 'editor' },
activeNode: { id: 7, type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode'],
removeNotificationsForStack,
});
await act(async () => {
await result.current.deleteStack(false);
});
expect(removeNotificationsForStack).toHaveBeenCalledTimes(1);
expect(removeNotificationsForStack).toHaveBeenCalledWith(7, 'web');
});
it('does not call removeNotificationsForStack on a non-OK delete', async () => {
vi.mocked(apiFetch).mockResolvedValue(new Response('boom', { status: 500 }));
const removeNotificationsForStack = vi.fn();
const { result } = setup({
overlay: { stackToDelete: 'web.yml' },
stackList: { selectedFile: 'web.yml', files: ['web.yml'] },
navState: { activeView: 'editor' },
removeNotificationsForStack,
});
await act(async () => {
await result.current.deleteStack(false);
});
expect(removeNotificationsForStack).not.toHaveBeenCalled();
});
});
@@ -176,6 +176,12 @@ interface UseStackActionsOptions {
* owns that state, and an optional callback would silently skip it.
*/
onDeletedOpenStack: () => void;
/**
* Drop in-memory notifications for a deleted stack on the active node.
* Caller must pass the node id and canonical stack basename (no .yml/.yaml).
* Optional so unit tests that do not exercise delete can omit it.
*/
removeNotificationsForStack?: (nodeId: number, stackName: string) => void;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -399,6 +405,7 @@ export function useStackActions(options: UseStackActionsOptions) {
canEditStack,
canOfferVolumeRemoval = false,
onDeletedOpenStack,
removeNotificationsForStack,
} = options;
const pendingStackLoadRef = useRef<string | null>(null);
@@ -1895,6 +1902,7 @@ export function useStackActions(options: UseStackActionsOptions) {
stackListState.files.find(
f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete,
) ?? stackToDelete;
const canonicalName = deleteKey.replace(/\.(yml|yaml)$/, '');
if (stackListState.isStackBusy(deleteKey)) return;
stackListState.setStackAction(deleteKey, 'delete');
try {
@@ -1914,11 +1922,14 @@ export function useStackActions(options: UseStackActionsOptions) {
toast.success('Stack deleted successfully!');
overlayState.closeDeleteDialog();
const selected = stackListState.selectedFile;
const stripExt = (name: string) => name.replace(/\.(yml|yaml)$/, '');
const nodeId = activeNode?.id;
if (nodeId != null && removeNotificationsForStack) {
removeNotificationsForStack(nodeId, canonicalName);
}
// Always clear a deleted selection, even when another top-level view is
// visible (Resources, Networking, etc.). Leaving that view is gated on
// the editor being the active surface below.
if (selected != null && stripExt(selected) === stripExt(deleteKey)) {
if (selected != null && selected.replace(/\.(yml|yaml)$/, '') === canonicalName) {
resetEditorState();
if (navState.activeView === 'editor') {
navState.setActiveView('dashboard');