mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user