feat: block self-stack lifecycle ops with UI and preflight guardrails (#1569)

* feat: block self-stack lifecycle ops with UI and preflight guardrails

Refuse update, deploy, down, stop, and delete when the stack matches Sencho's compose project.

Return 409 self_stack_protected. Expose isSelf on /statuses and disable guarded UI actions.

Add SelfStackProtectedDialog and self-managed-stack preflight warning.

Closes #1564

* fix: add missing stackSelfFlags mock to useSidebarContextMenu test

The production hook now reads stackListState.stackSelfFlags[file], but the
test mock did not include it, causing 6 tests to fail with TypeError:
Cannot read properties of undefined (reading 'web.yml').

* fix: harden self-stack protection during startup

Add a global environment preflight warning when Sencho is managed inside COMPOSE_DIR.

Align status decoration and route guards on Docker label fallback detection.

Block rollback and service-level stop on the protected self stack.

* fix: add self_stack_location to diagnostics-route expected check IDs
This commit is contained in:
Anso
2026-07-06 02:08:16 -04:00
committed by GitHub
parent f30a65ee08
commit 0f9925e04f
30 changed files with 905 additions and 31 deletions
@@ -189,6 +189,8 @@ export interface EditorViewProps {
// Composed action: wraps setStackToDelete + setDeleteDialogOpen
requestDeleteStack: () => void;
/** True when this stack is the running Sencho instance on the active node. */
isSelfStack?: boolean;
// Recovery surface for a failed/stalled operation on this stack (undefined
// when the last op succeeded or none has run). onRefreshState re-syncs
@@ -270,6 +272,7 @@ export function EditorView(props: EditorViewProps) {
setGitSourceOpen,
setCopiedDigest,
requestDeleteStack,
isSelfStack,
recoveryResult,
onRefreshState,
onDismissRecovery,
@@ -381,6 +384,7 @@ export function EditorView(props: EditorViewProps) {
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
isSelfStack={isSelfStack}
stackMuteActions={stackMuteActions}
/>
</div>
@@ -70,6 +70,7 @@ export function MobileStackDetail(props: EditorViewProps) {
setGitSourceOpen,
setCopiedDigest,
requestDeleteStack,
isSelfStack = false,
onMobileBack,
onCloseEditor,
hasUnsavedChanges,
@@ -153,6 +154,7 @@ export function MobileStackDetail(props: EditorViewProps) {
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
isSelfStack={isSelfStack}
stackMuteActions={stackMuteActions}
/>
</div>
@@ -2,6 +2,7 @@ import BashExecModal from '../BashExecModal';
import { PolicyBlockDialog } from '../stack/PolicyBlockDialog';
import { PreDeployScanDialog } from '../stack/PreDeployScanDialog';
import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog';
import { SelfStackProtectedDialog } from '../stack/SelfStackProtectedDialog';
import { DeleteStackDialog } from './DeleteStackDialog';
import { UnsavedChangesDialog } from './UnsavedChangesDialog';
import { StackAlertSheet } from '../StackAlertSheet';
@@ -23,6 +24,8 @@ interface ShellOverlaysProps {
stackName: string;
gitSourceOpen: boolean;
setGitSourceOpen: (open: boolean) => void;
canSelfUpdate: boolean;
onOpenFleetNodeUpdates: () => void;
}
export function ShellOverlays({
@@ -35,6 +38,8 @@ export function ShellOverlays({
stackName,
gitSourceOpen,
setGitSourceOpen,
canSelfUpdate,
onOpenFleetNodeUpdates,
}: ShellOverlaysProps) {
const {
deleteDialogOpen, closeDeleteDialog, stackToDelete,
@@ -45,6 +50,7 @@ export function ShellOverlays({
policyBlock, setPolicyBlock, policyBypassing,
updateReadiness, setUpdateReadiness,
preDeployAdvisory,
selfStackProtectedOpen, setSelfStackProtectedOpen,
stackMisconfigScanId, setStackMisconfigScanId,
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
} = overlayState;
@@ -58,6 +64,13 @@ export function ShellOverlays({
onConfirm={stackActions.deleteStack}
/>
<SelfStackProtectedDialog
open={selfStackProtectedOpen}
onOpenChange={setSelfStackProtectedOpen}
canOpenFleetUpdates={canSelfUpdate}
onOpenFleetUpdates={onOpenFleetNodeUpdates}
/>
<UnsavedChangesDialog
open={!!pendingUnsavedLoad || !!pendingLeaveAction}
onCancel={stackActions.cancelPendingUnsavedLoad}
@@ -133,6 +133,8 @@ export interface StackIdentityHeaderProps {
rollbackStack: () => Promise<void>;
scanStackConfig: () => Promise<void>;
requestDeleteStack: () => void;
/** True when this stack is the running Sencho instance on the active node. */
isSelfStack?: boolean;
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
}
@@ -159,8 +161,10 @@ export function StackIdentityHeader({
rollbackStack,
scanStackConfig,
requestDeleteStack,
isSelfStack = false,
stackMuteActions,
}: StackIdentityHeaderProps) {
const selfProtected = isSelfStack;
return (
<div className="flex flex-col gap-3">
{/* Identity block */}
@@ -248,18 +252,18 @@ export function StackIdentityHeader({
{loadingAction === 'restart' ? 'Restarting...' : 'Restart'}
</Button>
) : (
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg max-md:h-11 bg-brand text-brand-foreground hover:bg-brand/90" onClick={deployStack} disabled={loadingAction !== null}>
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg max-md:h-11 bg-brand text-brand-foreground hover:bg-brand/90" onClick={deployStack} disabled={loadingAction !== null || selfProtected}>
<Play className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'deploy' ? 'Starting...' : 'Start'}
</Button>
)}
{isRunning && (
<Button type="button" size="sm" variant="outline" className="rounded-lg max-md:h-11" onClick={stopStack} disabled={loadingAction !== null}>
<Button type="button" size="sm" variant="outline" className="rounded-lg max-md:h-11" onClick={stopStack} disabled={loadingAction !== null || selfProtected}>
<Square className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'stop' ? 'Stopping...' : 'Stop'}
</Button>
)}
<Button type="button" size="sm" variant="outline" className="rounded-lg max-md:h-11" onClick={updateStack} disabled={loadingAction !== null}>
<Button type="button" size="sm" variant="outline" className="rounded-lg max-md:h-11" onClick={updateStack} disabled={loadingAction !== null || selfProtected}>
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'update' ? 'Updating...' : 'Update'}
</Button>
@@ -274,7 +278,7 @@ export function StackIdentityHeader({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{canRollback && (
<DropdownMenuItem onClick={rollbackStack} disabled={loadingAction !== null}>
<DropdownMenuItem onClick={rollbackStack} disabled={loadingAction !== null || selfProtected}>
<Undo2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
<div className="flex flex-col gap-0.5">
<span>{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'}</span>
@@ -299,7 +303,7 @@ export function StackIdentityHeader({
{canDelete && (
<DropdownMenuItem
className="text-destructive focus:text-destructive focus:bg-destructive/10"
disabled={loadingAction !== null}
disabled={loadingAction !== null || selfProtected}
onClick={requestDeleteStack}
>
<Trash2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
@@ -115,6 +115,10 @@ export function useOverlayState() {
cancel: () => void;
} | null>(null);
const [selfStackProtectedOpen, setSelfStackProtectedOpen] = useState(false);
const openSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(true), []);
const closeSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(false), []);
const [stackMisconfigScanId, setStackMisconfigScanId] = useState<number | null>(null);
const [diffPreview, setDiffPreview] = useState<DiffPreview | null>(null);
@@ -132,6 +136,7 @@ export function useOverlayState() {
policyBlock, setPolicyBlock, policyBypassing, setPolicyBypassing,
updateReadiness, setUpdateReadiness,
preDeployAdvisory, setPreDeployAdvisory,
selfStackProtectedOpen, setSelfStackProtectedOpen, openSelfStackProtected, closeSelfStackProtected,
stackMisconfigScanId, setStackMisconfigScanId,
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
} as const;
@@ -18,6 +18,7 @@ function makeOptions(
const stackListState = {
stackStatuses,
stackPorts,
stackSelfFlags: {},
isStackBusy: () => false,
isPinned: () => false,
labels: [],
@@ -55,6 +55,7 @@ export function useSidebarContextMenu({
const canMuteNotifications = isAdmin && hasCapability('notification-suppression');
return {
stackStatus,
isSelfStack: stackListState.stackSelfFlags[file] === true,
// Only offer "Open App" when a browser-reachable URL can actually be built
// (a remote node with no API host, e.g. a pilot agent, yields none).
canOpenApp: mainPort !== undefined && buildServiceUrl({ node: activeNode, publicPort: mainPort }) !== null,
@@ -78,7 +79,13 @@ export function useSidebarContextMenu({
stop: () => stackActions.executeStackActionByFile(file, 'stop', 'stop'),
restart: () => stackActions.executeStackActionByFile(file, 'restart', 'restart'),
update: () => stackActions.executeStackActionByFile(file, 'update', 'update'),
remove: () => overlayState.openDeleteDialog(sName),
remove: () => {
if (stackListState.stackSelfFlags[file]) {
overlayState.openSelfStackProtected();
return;
}
overlayState.openDeleteDialog(sName);
},
pin: () => stackListState.pin(file),
unpin: () => stackListState.unpin(file),
toggleLabel: async (labelId: number) => {
@@ -170,7 +177,7 @@ export function useSidebarContextMenu({
// deps would force a rebuild on every parent render and defeat the memo.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
stackListState.stackStatuses, stackListState.stackPorts, isAdmin,
stackListState.stackStatuses, stackListState.stackPorts, stackListState.stackSelfFlags, isAdmin,
stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap,
stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url, activeNode?.id,
hasCapability, navState.openMuteRulesWithPrefill,
@@ -56,6 +56,7 @@ function makeStackListState(over: Partial<StackListState> = {}): StackListState
files: ['web.yml'],
stackStatuses: { 'web.yml': 'running' },
stackPorts: {},
stackSelfFlags: {},
setSelectedFile: vi.fn(),
setOptimisticStatus: vi.fn(),
setStackAction: vi.fn(),
@@ -88,6 +89,7 @@ function makeOverlay(over: Partial<OverlayState> = {}): OverlayState {
setUpdateReadiness: vi.fn(),
preDeployAdvisory: null,
setPreDeployAdvisory: vi.fn(),
openSelfStackProtected: vi.fn(),
setDiffPreview: vi.fn(),
...over,
} as unknown as OverlayState;
@@ -876,6 +878,72 @@ describe('useStackActions.getStackMenuVisibility', () => {
showDeploy: true, showStop: false, showRestart: false, showUpdate: false,
});
});
it('hides guarded lifecycle actions for the self stack but keeps restart', () => {
const { result } = setup({
stackList: {
stackStatuses: { 'sencho.yml': 'running' } as never,
stackSelfFlags: { 'sencho.yml': true },
},
});
expect(result.current.getStackMenuVisibility('sencho.yml')).toEqual({
showDeploy: false, showStop: false, showRestart: true, showUpdate: false,
});
});
it('opens the self-stack modal instead of calling update on a protected stack', async () => {
const { result, overlayState, stackListState } = setup({
stackList: {
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
},
});
await act(async () => { await result.current.updateStack(); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
expect(stackListState.setStackAction).not.toHaveBeenCalled();
});
it('opens the self-stack modal instead of calling rollback on a protected stack', async () => {
vi.mocked(apiFetch).mockReset();
const { result, overlayState, stackListState } = setup({
stackList: {
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
},
});
await act(async () => { await result.current.rollbackStack(); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
expect(stackListState.setStackAction).not.toHaveBeenCalled();
expect(apiFetch).not.toHaveBeenCalled();
});
it('opens the self-stack modal for rollback 409 fallback', async () => {
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ code: 'self_stack_protected' }), { status: 409 }));
const { result, overlayState, stackListState } = setup();
await act(async () => { await result.current.rollbackStack(); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
});
it('opens the self-stack modal instead of calling service stop on a protected stack', async () => {
vi.mocked(apiFetch).mockReset();
const { result, overlayState } = setup({
stackList: {
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
},
});
await act(async () => { await result.current.serviceAction('stop', 'web'); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
expect(apiFetch).not.toHaveBeenCalled();
});
it('opens the self-stack modal for service stop 409 fallback', async () => {
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ code: 'self_stack_protected' }), { status: 409 }));
const { result, overlayState } = setup();
await act(async () => { await result.current.serviceAction('stop', 'web'); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
});
});
describe('useStackActions.openStackApp', () => {
@@ -52,6 +52,18 @@ const NODE_UNREACHABLE_FAILURE: FailureClassification = {
const UNREACHABLE_STATUSES: ReadonlySet<number> = new Set([502, 503, 504]);
const SELF_STACK_PROTECTED_CODE = 'self_stack_protected';
const isSelfStackProtectedResponse = (rawBody: string, status?: number): boolean => {
if (status !== 409) return false;
try {
const parsed: unknown = JSON.parse(rawBody);
return isRecord(parsed) && parsed.code === SELF_STACK_PROTECTED_CODE;
} catch {
return false;
}
};
const parseFailureClassification = (value: unknown): FailureClassification | undefined => {
if (
isRecord(value) &&
@@ -265,14 +277,24 @@ export function useStackActions(options: UseStackActionsOptions) {
// lifecycle actions (stop/restart/update) rather than deploy.
const raw = stackListState.stackStatuses[file];
const status = raw === 'partial' ? 'running' : raw;
const isSelf = stackListState.stackSelfFlags[file] === true;
return {
showDeploy: status !== 'running',
showStop: status === 'running',
showDeploy: !isSelf && status !== 'running',
showStop: !isSelf && status === 'running',
showRestart: status === 'running',
showUpdate: status === 'running',
showUpdate: !isSelf && status === 'running',
};
};
const isSelfStackFile = (file: string | null | undefined): boolean =>
!!file && stackListState.stackSelfFlags[file] === true;
const openSelfStackProtectedIfNeeded = (file: string | null | undefined): boolean => {
if (!isSelfStackFile(file)) return false;
overlayState.openSelfStackProtected();
return true;
};
const openStackApp = (file: string) => {
const port = stackListState.stackPorts[file];
if (!port) return;
@@ -706,6 +728,10 @@ export function useStackActions(options: UseStackActionsOptions) {
const response = await apiFetch(path, withDeploySession(deploySessionId ?? '', { method: 'POST', nodeId: opNodeId }));
if (!response.ok) {
const rawBody = await response.text();
if (isSelfStackProtectedResponse(rawBody, response.status)) {
overlayState.openSelfStackProtected();
return { ok: false, errorMessage: 'Sencho instance protected' };
}
if (response.status === 409) {
// Either 409 sub-case (op-in-progress or policy block) leaves the
// stack in its prior state; undo the optimistic "running" flip once.
@@ -764,16 +790,13 @@ export function useStackActions(options: UseStackActionsOptions) {
const deployStack = async (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
// deployPendingRef blocks a second deploy from the moment of the click
// through the async advisory phase, before setStackAction marks the stack
// busy. Without it, a double-click during the advisory fetch window could
// launch two deploys. Cleared on cancel and in the deploy's finally.
if (
!stackListState.selectedFile ||
stackListState.isStackBusy(stackListState.selectedFile) ||
deployPendingRef.current
)
return;
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
const stackFile = stackListState.selectedFile;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
// Snapshot the node once so the advisory fetch and the deploy stay bound to
@@ -868,6 +891,7 @@ export function useStackActions(options: UseStackActionsOptions) {
if (!stackListState.selectedFile || stackListState.isStackBusy(stackListState.selectedFile))
return;
const stackFile = stackListState.selectedFile;
if (openSelfStackProtectedIfNeeded(stackFile)) return;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
const startedAt = Date.now();
stackListState.setStackAction(stackFile, 'rollback');
@@ -879,6 +903,10 @@ export function useStackActions(options: UseStackActionsOptions) {
const res = await apiFetch(path, { method: 'POST', nodeId: opNodeId });
if (!res.ok) {
const rawBody = await res.text();
if (isSelfStackProtectedResponse(rawBody, res.status)) {
overlayState.openSelfStackProtected();
return;
}
if (res.status === 409) {
const inProgress = parseStackOpInProgress(rawBody);
if (inProgress) {
@@ -996,6 +1024,10 @@ export function useStackActions(options: UseStackActionsOptions) {
const response = await apiFetch(url, withDeploySession(ds, { method: 'POST', nodeId: opNodeId }));
if (!response.ok) {
const errText = await response.text();
if (isSelfStackProtectedResponse(errText, response.status)) {
overlayState.openSelfStackProtected();
return { ok: false as const, errorMessage: 'Sencho instance protected' };
}
if (response.status === 409) {
const inProgress = parseStackOpInProgress(errText);
if (inProgress) {
@@ -1056,6 +1088,7 @@ export function useStackActions(options: UseStackActionsOptions) {
e?.preventDefault();
e?.stopPropagation();
if (!stackListState.selectedFile) return;
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
await runStackAction(stackListState.selectedFile, 'stop', 'stop', 'exited', 'Stack stopped successfully!');
};
@@ -1071,13 +1104,21 @@ export function useStackActions(options: UseStackActionsOptions) {
serviceName: string,
) => {
if (!stackListState.selectedFile) return;
if (action === 'stop' && openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
const stackName = stackListState.selectedFile.replace(/\.(yml|yaml)$/, '');
try {
const r = await apiFetch(
`/stacks/${stackName}/services/${encodeURIComponent(serviceName)}/${action}`,
{ method: 'POST' },
);
if (!r.ok) throw new Error((await r.text()) || `${action} failed`);
if (!r.ok) {
const rawBody = await r.text();
if (isSelfStackProtectedResponse(rawBody, r.status)) {
overlayState.openSelfStackProtected();
return;
}
throw new Error(rawBody || `${action} failed`);
}
const label =
action === 'restart' ? 'restarted' : action === 'stop' ? 'stopped' : 'started';
toast.success(`Service "${serviceName}" ${label}`);
@@ -1122,6 +1163,7 @@ export function useStackActions(options: UseStackActionsOptions) {
e?.preventDefault();
e?.stopPropagation();
if (!stackListState.selectedFile) return;
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
await requestStackUpdate(stackListState.selectedFile);
};
@@ -1141,6 +1183,11 @@ export function useStackActions(options: UseStackActionsOptions) {
const response = await apiFetch(url, { method: 'DELETE' });
if (!response.ok) {
const errText = await response.text();
if (isSelfStackProtectedResponse(errText, response.status)) {
overlayState.openSelfStackProtected();
overlayState.closeDeleteDialog();
return;
}
throw new Error(errText || 'Failed to delete stack');
}
toast.success('Stack deleted successfully!');
@@ -1202,6 +1249,7 @@ export function useStackActions(options: UseStackActionsOptions) {
};
const requestDeleteStack = () => {
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
overlayState.openDeleteDialog(stackListState.selectedFile ?? '');
};
@@ -1211,6 +1259,12 @@ export function useStackActions(options: UseStackActionsOptions) {
endpoint: string,
) => {
if (stackListState.isStackBusy(stackFile)) return;
if (
(action === 'deploy' || action === 'update' || action === 'stop' || action === 'delete' || action === 'rollback') &&
openSelfStackProtectedIfNeeded(stackFile)
) {
return;
}
// Updates route through the shared update path so the sidebar gets the
// readiness dialog, the deploy-feedback modal, and the same failure
// handling as the toolbar.
@@ -1235,6 +1289,10 @@ export function useStackActions(options: UseStackActionsOptions) {
const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST', nodeId: opNodeId });
if (!response.ok) {
const errText = await response.text();
if (isSelfStackProtectedResponse(errText, response.status)) {
overlayState.openSelfStackProtected();
return;
}
if (response.status === 409) {
const inProgress = parseStackOpInProgress(errText);
if (inProgress) {
@@ -1371,6 +1429,8 @@ export function useStackActions(options: UseStackActionsOptions) {
closeBashModal,
openLogViewer,
closeLogViewer,
isSelfStackFile,
openSelfStackProtectedIfNeeded,
};
}
@@ -53,6 +53,7 @@ interface StackStatusInfo {
mainPort?: number;
running?: number;
total?: number;
isSelf?: boolean;
}
export interface RemoteResult {
@@ -92,6 +93,7 @@ export function useStackListState() {
const [searchQuery, setSearchQuery] = useState('');
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
const [stackSelfFlags, setStackSelfFlags] = useState<Record<string, boolean>>({});
const [stackCounts, setStackCounts] = useState<StackCounts>({});
const [labels, setLabels] = useState<StackLabel[]>([]);
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
@@ -206,6 +208,7 @@ export function useStackListState() {
if (stale()) return fileList;
let bulkStatuses: Record<string, StackRowStatus> = {};
const bulkPorts: Record<string, number | undefined> = {};
const bulkSelf: Record<string, boolean> = {};
const bulkCounts: StackCounts = {};
const raw: unknown = statusRes.ok ? await statusRes.json() : null;
@@ -213,6 +216,7 @@ export function useStackListState() {
for (const [key, val] of Object.entries(raw as Record<string, StackStatusInfo>)) {
bulkStatuses[key] = val.status;
if (val.mainPort) bulkPorts[key] = val.mainPort;
if (val.isSelf) bulkSelf[key] = true;
if (val.running !== undefined && val.total !== undefined) {
bulkCounts[key] = { running: val.running, total: val.total };
}
@@ -233,6 +237,7 @@ export function useStackListState() {
if (keys.length === Object.keys(prev).length && keys.every(k => prev[k] === bulkPorts[k])) return prev;
return bulkPorts;
});
setStackSelfFlags(bulkSelf);
setStackCounts(bulkCounts);
refreshLabels();
return fileList;
@@ -403,6 +408,7 @@ export function useStackListState() {
searchQuery, setSearchQuery,
stackStatuses, setStackStatuses,
stackPorts, setStackPorts,
stackSelfFlags,
stackCounts,
labels,
stackLabelMap,