mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
e2001fed72
* feat: open compose editor in edit mode with one click Remove the desktop double edit gate so Anatomy "Edit compose" and empty-stack create land in an immediately editable workspace. Close discards both compose and env buffers; deferred loads keep startInComposeEdit through unsaved confirm. * docs: align compose editor close behavior and Edit compose labels Update stack-management and editor docs for one-click edit mode. Close discards unsaved compose/env edits. Anatomy shortcuts use Edit compose. * fix(e2e): update routing test to use anatomy-edit-compose-btn test id The compose editor env tab routing test was written against the old 'edit' button name. The single-compose-edit-gate branch renamed that button to 'Edit compose' with test id 'anatomy-edit-compose-btn'. Update the selector to match.
1625 lines
64 KiB
TypeScript
1625 lines
64 KiB
TypeScript
import { useRef, useCallback, useEffect } from 'react';
|
|
import { apiFetch, withDeploySession } from '@/lib/api';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { buildServiceUrl, openServiceUrl } from '@/lib/serviceUrl';
|
|
import type { useEditorViewState } from './useEditorViewState';
|
|
import type { useStackListState } from './useStackListState';
|
|
import type { useViewNavigationState } from './useViewNavigationState';
|
|
import type { OverlayState, LoadFileOptions } from './useOverlayState';
|
|
import type { Node } from '@/context/NodeContext';
|
|
import type { RunWithLogParams } from '@/context/DeployFeedbackContext';
|
|
import { parsePath } from '@/lib/router/senchoRoute';
|
|
import { resolveEnvFilePath } from '@/lib/router/envRoute';
|
|
import type { EditorTab, RouteStackLoadResult } from '@/lib/router/routeTypes';
|
|
import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView';
|
|
import type { NotificationItem } from '../../dashboard/types';
|
|
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
|
|
import type { PreDeployScanImage } from '@/types/security';
|
|
|
|
interface RunResult {
|
|
ok: boolean;
|
|
errorMessage?: string;
|
|
rolledBack?: boolean;
|
|
/** Health gate run id from the success body, when the backend started one. */
|
|
healthGateId?: string | null;
|
|
}
|
|
|
|
/** healthGateId from a success body, or null when absent or unreadable. */
|
|
const parseHealthGateId = async (response: Response): Promise<string | null> => {
|
|
try {
|
|
const body: unknown = await response.json();
|
|
if (isRecord(body) && typeof body.healthGateId === 'string') return body.healthGateId;
|
|
} catch (e) {
|
|
// A success body should always parse; the warn surfaces a future
|
|
// double-read bug instead of silently disabling the gate UI.
|
|
console.warn('[HealthGate] could not read the success body:', e);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Sentinel stored in overlayState.pendingUnsavedLoad to mark that the pending
|
|
// confirmation is a node switch (not a stack load). When the user confirms the
|
|
// discard, discardAndLoadPending calls setActiveNode(targetNode) and skips the
|
|
// stack-load branch.
|
|
export const NODE_SWITCH_PENDING_TOKEN = '__node-switch-pending__';
|
|
|
|
type StackActionError = Error & { rolledBack?: boolean; failure?: FailureClassification };
|
|
|
|
// Fallback classification when the response never reached a Sencho backend
|
|
// (proxy 502/504 for a dead remote, or a 503 with no classified body).
|
|
const NODE_UNREACHABLE_FAILURE: FailureClassification = {
|
|
reason: 'node_unreachable',
|
|
label: 'Node or Docker unreachable',
|
|
suggestion: 'Check that the node is online and Docker is running, then retry.',
|
|
};
|
|
|
|
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) &&
|
|
typeof value.reason === 'string' &&
|
|
typeof value.label === 'string' && value.label.trim() &&
|
|
typeof value.suggestion === 'string' && value.suggestion.trim()
|
|
) {
|
|
return { reason: value.reason, label: value.label, suggestion: value.suggestion };
|
|
}
|
|
if (value !== undefined) {
|
|
// Likely hub/node version skew or a mangled proxy body; the raw error
|
|
// message still renders, only the classification panel is degraded.
|
|
console.warn('Unrecognized failure classification shape in error response:', value);
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
type StackOpAction = 'deploy' | 'down' | 'restart' | 'stop' | 'start' | 'update';
|
|
|
|
interface StackOpInProgressInfo {
|
|
action: StackOpAction;
|
|
startedAt: number;
|
|
user: string;
|
|
}
|
|
|
|
const STACK_OP_PRESENT_PARTICIPLE: Record<StackOpAction, string> = {
|
|
deploy: 'deploying',
|
|
down: 'taking down',
|
|
restart: 'restarting',
|
|
stop: 'stopping',
|
|
start: 'starting',
|
|
update: 'updating',
|
|
};
|
|
|
|
const VALID_STACK_OP_ACTIONS: ReadonlySet<string> = new Set(
|
|
Object.keys(STACK_OP_PRESENT_PARTICIPLE),
|
|
);
|
|
|
|
type EditorState = ReturnType<typeof useEditorViewState>;
|
|
type StackListState = ReturnType<typeof useStackListState>;
|
|
type NavState = ReturnType<typeof useViewNavigationState>;
|
|
|
|
interface UseStackActionsOptions {
|
|
editorState: EditorState;
|
|
stackListState: StackListState;
|
|
navState: NavState;
|
|
overlayState: OverlayState;
|
|
activeNode: Node | null | undefined;
|
|
setActiveNode: (node: Node) => void;
|
|
nodes: Node[];
|
|
runWithLog: (
|
|
params: RunWithLogParams,
|
|
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>,
|
|
) => Promise<RunResult>;
|
|
// Last live output line for a stack, but only while a deploy-feedback session
|
|
// is streaming that exact stack; used to enrich failure diagnostics safely.
|
|
getLastDeployOutputLine: (stackName: string) => string | undefined;
|
|
diffPreviewEnabled: boolean;
|
|
// Active node advertises the update-guard capability, so manual updates show
|
|
// the pre-update readiness dialog. Defaults to false: without the
|
|
// capability, updates run directly with no dialog.
|
|
hasUpdateGuard?: boolean;
|
|
// Target-aware stack:edit check. Pass the loaded stack identity (folder name
|
|
// or compose path); callers strip extensions when comparing to RBAC stack
|
|
// names. Evaluated against the load target so post-load auto-edit is not
|
|
// gated by a stale previously-selected stack.
|
|
canEditStack: (stackNameOrFilename: string) => boolean;
|
|
/** Fail-closed: true only when active node meta explicitly lists stack-down-remove-volumes. */
|
|
canOfferVolumeRemoval?: boolean;
|
|
}
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === 'object' && value !== null;
|
|
|
|
const PRE_DEPLOY_SUMMARY_TIMEOUT_MS = 5000;
|
|
|
|
/**
|
|
* Fetch the pre-deploy scan advisory for a manual deploy. Returns the image
|
|
* list when the advisory is enabled and the backend answers in time, or null to
|
|
* mean "no advisory, deploy normally" for every other case (setting off,
|
|
* timeout, an older node without the route, or any error). Failing open is
|
|
* deliberate: the advisory is visibility, it must never block a deploy. Bound to
|
|
* the captured node so it targets the same node the deploy will hit.
|
|
*/
|
|
export async function fetchPreDeployAdvisory(
|
|
stackName: string,
|
|
opNodeId: number | null,
|
|
): Promise<PreDeployScanImage[] | null> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), PRE_DEPLOY_SUMMARY_TIMEOUT_MS);
|
|
try {
|
|
const res = await apiFetch(
|
|
`/security/stacks/${encodeURIComponent(stackName)}/pre-deploy-summary`,
|
|
{ nodeId: opNodeId, signal: controller.signal },
|
|
);
|
|
if (!res.ok) return null;
|
|
const data: unknown = await res.json();
|
|
if (!isRecord(data) || data.enabled !== true || !Array.isArray(data.images)) return null;
|
|
return data.images as PreDeployScanImage[];
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
const parseStackOpInProgress = (rawBody: string): StackOpInProgressInfo | null => {
|
|
try {
|
|
const parsed: unknown = JSON.parse(rawBody);
|
|
if (!isRecord(parsed) || parsed.code !== 'stack_op_in_progress') return null;
|
|
const inProgress = parsed.inProgress;
|
|
if (
|
|
!isRecord(inProgress) ||
|
|
typeof inProgress.action !== 'string' ||
|
|
typeof inProgress.startedAt !== 'number' ||
|
|
!VALID_STACK_OP_ACTIONS.has(inProgress.action)
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
action: inProgress.action as StackOpAction,
|
|
startedAt: inProgress.startedAt,
|
|
user: typeof inProgress.user === 'string' ? inProgress.user : '',
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const stackOpInProgressMessage = (stackName: string, info: StackOpInProgressInfo): string => {
|
|
const verb = STACK_OP_PRESENT_PARTICIPLE[info.action] ?? 'busy';
|
|
const actor = info.user && info.user !== 'system' ? ` (started by ${info.user})` : '';
|
|
return `${stackName} is already ${verb}${actor}.`;
|
|
};
|
|
|
|
const parseStackActionError = (rawBody: string, fallback: string, status?: number): StackActionError => {
|
|
let message = rawBody || fallback;
|
|
let rolledBack = false;
|
|
let failure: FailureClassification | undefined;
|
|
let parsedCode: string | undefined;
|
|
let bodyWasJson = false;
|
|
|
|
try {
|
|
const parsed: unknown = JSON.parse(rawBody);
|
|
bodyWasJson = true;
|
|
if (isRecord(parsed)) {
|
|
if (typeof parsed.error === 'string' && parsed.error.trim()) {
|
|
message = parsed.error;
|
|
}
|
|
rolledBack = parsed.rolledBack === true;
|
|
failure = parseFailureClassification(parsed.failure);
|
|
if (typeof parsed.code === 'string') parsedCode = parsed.code;
|
|
}
|
|
} catch {
|
|
/* not JSON */
|
|
}
|
|
|
|
// A gateway-style status with no classified body means the request likely
|
|
// never reached the owning node's backend; surface that as the cause. A 503
|
|
// qualifies only when it is body-less (proxy generated) or the backend's own
|
|
// docker_unavailable shape, so an unrelated future 503 is not mislabeled.
|
|
if (!failure && status !== undefined && UNREACHABLE_STATUSES.has(status)) {
|
|
const qualifies = status !== 503 || !bodyWasJson || parsedCode === 'docker_unavailable';
|
|
if (qualifies) failure = { ...NODE_UNREACHABLE_FAILURE };
|
|
}
|
|
|
|
const error = new Error(message) as StackActionError;
|
|
error.rolledBack = rolledBack;
|
|
error.failure = failure;
|
|
return error;
|
|
};
|
|
|
|
export function useStackActions(options: UseStackActionsOptions) {
|
|
const {
|
|
editorState,
|
|
stackListState,
|
|
navState,
|
|
overlayState,
|
|
activeNode,
|
|
setActiveNode,
|
|
nodes,
|
|
runWithLog,
|
|
getLastDeployOutputLine,
|
|
diffPreviewEnabled,
|
|
hasUpdateGuard = false,
|
|
canEditStack,
|
|
canOfferVolumeRemoval = false,
|
|
} = options;
|
|
|
|
const pendingStackLoadRef = useRef<string | null>(null);
|
|
const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null);
|
|
const checkUpdatesIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
// True from a deploy click through the async pre-deploy advisory phase until
|
|
// the deploy starts or is cancelled, so a double-click cannot start two deploys.
|
|
const deployPendingRef = useRef(false);
|
|
// Aborts the most recent loadFile sequence (compose GET, envs GET, env content
|
|
// GET, containers GET, backup GET). A node switch, an unmount, or a second
|
|
// loadFile call before the first finishes all cancel the in-flight fetches so
|
|
// late responses never overwrite freshly-loaded state.
|
|
const loadFileAbortRef = useRef<AbortController | null>(null);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (checkUpdatesIntervalRef.current !== null) {
|
|
clearInterval(checkUpdatesIntervalRef.current);
|
|
}
|
|
loadFileAbortRef.current?.abort();
|
|
};
|
|
}, []);
|
|
|
|
const isAbortError = (err: unknown): boolean =>
|
|
err instanceof Error && err.name === 'AbortError';
|
|
|
|
const hasUnsavedChanges = () =>
|
|
editorState.content !== editorState.originalContent ||
|
|
editorState.envContent !== editorState.originalEnvContent;
|
|
|
|
const isComposeDirty = () => editorState.content !== editorState.originalContent;
|
|
const isEnvDirty = () => editorState.envContent !== editorState.originalEnvContent;
|
|
|
|
const getStackMenuVisibility = (file: string) => {
|
|
// A partial stack has running containers, so it shows the running-stack
|
|
// 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: !isSelf && status !== 'running',
|
|
showStop: !isSelf && status === 'running',
|
|
showRestart: status === 'running',
|
|
showUpdate: !isSelf && status === 'running',
|
|
showTakeDown: !isSelf && (raw === 'running' || raw === 'partial' || raw === 'exited'),
|
|
};
|
|
};
|
|
|
|
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;
|
|
const url = buildServiceUrl({ node: activeNode, publicPort: port });
|
|
if (url) openServiceUrl(url);
|
|
};
|
|
|
|
const resetEditorState = () => {
|
|
// Cancel any in-flight loadFile chain before wiping state; a late response
|
|
// arriving after the reset would otherwise repopulate the editor with the
|
|
// previous node's data.
|
|
loadFileAbortRef.current?.abort();
|
|
loadFileAbortRef.current = null;
|
|
stackListState.setSelectedFile(null);
|
|
editorState.setContent('');
|
|
editorState.setOriginalContent('');
|
|
editorState.setEnvContent('');
|
|
editorState.setOriginalEnvContent('');
|
|
editorState.setEnvFiles([]);
|
|
editorState.setSelectedEnvFile('');
|
|
editorState.setEnvExists(false);
|
|
editorState.setContainers([]);
|
|
editorState.setIsEditing(false);
|
|
};
|
|
|
|
// Re-sync the open stack's container list. Used after both successful and
|
|
// failed/stalled operations so the detail never shows containers that no
|
|
// longer reflect reality. Returns true only when the live list was fetched;
|
|
// false on a non-applicable stack, a non-ok response, or a network error, so
|
|
// callers (e.g. the recovery panel's Refresh) can report the real outcome.
|
|
const refreshSelectedContainers = async (stackName: string, stackFile: string): Promise<boolean> => {
|
|
if (stackListState.selectedFile !== stackFile) return false;
|
|
try {
|
|
const res = await apiFetch(`/stacks/${stackName}/containers`);
|
|
if (!res.ok) return false;
|
|
const conts = await res.json();
|
|
editorState.setContainers(Array.isArray(conts) ? conts : []);
|
|
return true;
|
|
} catch {
|
|
// Non-critical when called from an action's failure path: refreshStacks(true)
|
|
// in the caller's finally still reconciles the sidebar status.
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Stack operations whose failure produces a recovery panel. A failed
|
|
// stop/start/delete is not recoverable through retry/restart/rollback.
|
|
const RECOVERABLE_ACTIONS: readonly StackAction[] = ['deploy', 'update', 'restart', 'rollback'];
|
|
const isRecoverableAction = (action: StackAction): action is RecoverableAction =>
|
|
RECOVERABLE_ACTIONS.includes(action);
|
|
|
|
// Store a terminal failure so the in-detail recovery panel can offer next
|
|
// steps. Non-recoverable actions are skipped. Snapshots the last output line
|
|
// only when the deploy-feedback panel is streaming this stack at failure time
|
|
// (see getLastDeployOutputLine); undefined otherwise.
|
|
const recordActionFailureFor = (
|
|
stackFile: string,
|
|
stackName: string,
|
|
action: StackAction,
|
|
startedAt: number,
|
|
errorMessage: string | undefined,
|
|
rolledBack: boolean,
|
|
failure?: FailureClassification,
|
|
) => {
|
|
if (!isRecoverableAction(action)) return;
|
|
stackListState.recordActionFailure(stackFile, {
|
|
action,
|
|
errorMessage,
|
|
rolledBack,
|
|
startedAt,
|
|
endedAt: Date.now(),
|
|
lastOutputLine: getLastDeployOutputLine(stackName),
|
|
failure,
|
|
});
|
|
};
|
|
|
|
const refreshGitSourcePending = async () => {
|
|
try {
|
|
const res = await apiFetch('/git-sources');
|
|
if (!res.ok) return;
|
|
const sources: Array<{ stack_name: string; pending_commit_sha: string | null }> =
|
|
await res.json();
|
|
const map: Record<string, boolean> = {};
|
|
for (const s of sources) {
|
|
if (s.pending_commit_sha) map[s.stack_name] = true;
|
|
}
|
|
editorState.setGitSourcePendingMap(map);
|
|
} catch {
|
|
// Non-critical; leave prior state.
|
|
}
|
|
};
|
|
|
|
// loadFile and loadFileOnNode call each other (loadFileOnNode -> loadFile, navigateToNotification
|
|
// -> loadFileOnNode or loadFile). A ref breaks the mutual-recursion hoisting constraint without
|
|
// needing to hoist both functions or restructure the call graph.
|
|
const loadFileRef = useRef<(filename: string, options?: LoadFileOptions) => Promise<void>>(async () => {});
|
|
|
|
const loadFileOnNode = async (node: Node, filename: string, options?: LoadFileOptions) => {
|
|
if (!filename) return;
|
|
if (
|
|
!options?.skipUnsavedCheck &&
|
|
stackListState.selectedFile &&
|
|
filename !== stackListState.selectedFile &&
|
|
hasUnsavedChanges()
|
|
) {
|
|
overlayState.setPendingUnsavedNode(node);
|
|
overlayState.setPendingUnsavedLoad(filename);
|
|
overlayState.setPendingLoadOptions(options ?? null);
|
|
return;
|
|
}
|
|
setActiveNode(node);
|
|
stackListState.setSearchQuery('');
|
|
await loadFileRef.current(filename, options);
|
|
};
|
|
|
|
const clearEnvState = () => {
|
|
editorState.setEnvFiles([]);
|
|
editorState.setSelectedEnvFile('');
|
|
editorState.setEnvContent('');
|
|
editorState.setOriginalEnvContent('');
|
|
editorState.setEnvExists(false);
|
|
editorState.setEnvEtag(null);
|
|
};
|
|
|
|
const loadEnvState = async (filename: string, signal?: AbortSignal): Promise<string[]> => {
|
|
try {
|
|
const envsRes = await apiFetch(`/stacks/${filename}/envs`, { signal });
|
|
if (signal?.aborted) return [];
|
|
if (!envsRes.ok) {
|
|
clearEnvState();
|
|
return [];
|
|
}
|
|
const { envFiles } = await envsRes.json();
|
|
if (signal?.aborted) return [];
|
|
if (envFiles && envFiles.length > 0) {
|
|
editorState.setEnvFiles(envFiles);
|
|
const firstFile = envFiles[0];
|
|
editorState.setSelectedEnvFile(firstFile);
|
|
editorState.setEnvExists(true);
|
|
const envContentRes = await apiFetch(
|
|
`/stacks/${filename}/env?file=${encodeURIComponent(firstFile)}`,
|
|
{ signal },
|
|
);
|
|
if (signal?.aborted) return envFiles;
|
|
if (envContentRes.ok) {
|
|
const envText = await envContentRes.text();
|
|
editorState.setEnvContent(envText || '');
|
|
editorState.setOriginalEnvContent(envText || '');
|
|
editorState.setEnvEtag(envContentRes.headers.get('etag'));
|
|
} else {
|
|
editorState.setEnvContent('');
|
|
editorState.setOriginalEnvContent('');
|
|
editorState.setEnvEtag(null);
|
|
}
|
|
return envFiles;
|
|
}
|
|
clearEnvState();
|
|
return [];
|
|
} catch (err) {
|
|
if (isAbortError(err)) return [];
|
|
clearEnvState();
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const loadContainerState = async (filename: string, signal?: AbortSignal) => {
|
|
try {
|
|
const containersRes = await apiFetch(`/stacks/${filename}/containers`, { signal });
|
|
if (signal?.aborted) return;
|
|
const conts = await containersRes.json();
|
|
editorState.setContainers(Array.isArray(conts) ? conts : []);
|
|
} catch (error) {
|
|
if (isAbortError(error)) return;
|
|
console.error('Failed to load containers:', error);
|
|
editorState.setContainers([]);
|
|
}
|
|
};
|
|
|
|
const loadBackupState = async (filename: string, signal?: AbortSignal) => {
|
|
try {
|
|
const backupRes = await apiFetch(`/stacks/${filename}/backup`, { signal });
|
|
if (signal?.aborted) return;
|
|
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
|
else editorState.setBackupInfo({ exists: false, timestamp: null });
|
|
} catch (err) {
|
|
if (isAbortError(err)) return;
|
|
editorState.setBackupInfo({ exists: false, timestamp: null });
|
|
}
|
|
};
|
|
|
|
const applyEditorRouteState = (tab: EditorTab) => {
|
|
editorState.setActiveTab(tab);
|
|
editorState.setEditingCompose(true);
|
|
editorState.setIsEditing(false);
|
|
};
|
|
|
|
const loadFileCore = async (filename: string, options?: LoadFileOptions): Promise<RouteStackLoadResult> => {
|
|
if (!filename) return { ok: false };
|
|
if (
|
|
!options?.skipUnsavedCheck &&
|
|
stackListState.selectedFile &&
|
|
filename !== stackListState.selectedFile &&
|
|
hasUnsavedChanges()
|
|
) {
|
|
overlayState.setPendingUnsavedLoad(filename);
|
|
overlayState.setPendingLoadOptions(options ?? null);
|
|
return { ok: false };
|
|
}
|
|
loadFileAbortRef.current?.abort();
|
|
const controller = new AbortController();
|
|
loadFileAbortRef.current = controller;
|
|
const { signal } = controller;
|
|
|
|
editorState.setIsFileLoading(true);
|
|
editorState.setIsEditing(false);
|
|
editorState.setEditingCompose(false);
|
|
editorState.setActiveTab('compose');
|
|
try {
|
|
const res = await apiFetch(`/stacks/${filename}`, { signal });
|
|
if (signal.aborted) return { ok: false };
|
|
const text = await res.text();
|
|
if (signal.aborted) return { ok: false };
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to load stack: ${res.status}`);
|
|
}
|
|
stackListState.setSelectedFile(filename);
|
|
navState.setActiveView('editor');
|
|
editorState.setContent(text || '');
|
|
editorState.setOriginalContent(text || '');
|
|
editorState.setComposeEtag(res.headers.get('etag'));
|
|
const envFiles = await loadEnvState(filename, signal);
|
|
await loadContainerState(filename, signal);
|
|
await loadBackupState(filename, signal);
|
|
// Post-load auto-edit evaluates permission for the loaded target, not
|
|
// the previously selected stack (selectedFile was just updated above).
|
|
if (options?.startInComposeEdit && canEditStack(filename)) {
|
|
editorState.setEditingCompose(true);
|
|
editorState.setActiveTab('compose');
|
|
editorState.setIsEditing(true);
|
|
}
|
|
return { ok: true, envFiles };
|
|
} catch (error) {
|
|
if (isAbortError(error) || signal.aborted) return { ok: false };
|
|
console.error('Failed to load file:', error);
|
|
toast.error(`Could not open "${filename.replace(/\.(ya?ml)$/, '')}". Check your connection and try again.`);
|
|
stackListState.setSelectedFile(null);
|
|
editorState.setContent('');
|
|
editorState.setOriginalContent('');
|
|
editorState.setComposeEtag(null);
|
|
editorState.setEnvContent('');
|
|
editorState.setOriginalEnvContent('');
|
|
editorState.setEnvEtag(null);
|
|
editorState.setContainers([]);
|
|
return { ok: false };
|
|
} finally {
|
|
if (!signal.aborted) {
|
|
editorState.setIsFileLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const loadFile = async (filename: string, options?: LoadFileOptions) => {
|
|
await loadFileCore(filename, options);
|
|
};
|
|
|
|
const loadFileForRoute = async (filename: string): Promise<RouteStackLoadResult> => {
|
|
return loadFileCore(filename);
|
|
};
|
|
|
|
// Keep ref in sync so loadFileOnNode always calls the latest loadFile closure
|
|
loadFileRef.current = loadFile;
|
|
|
|
const navigateToNotification = (notif: NotificationItem) => {
|
|
if (!notif.stack_name) return;
|
|
pendingLogsRef.current = notif.container_name
|
|
? { stackName: notif.stack_name, containerName: notif.container_name }
|
|
: null;
|
|
const targetNode =
|
|
notif.nodeId !== undefined ? nodes.find(n => n.id === notif.nodeId) : activeNode;
|
|
if (targetNode && targetNode.id !== activeNode?.id) {
|
|
void loadFileOnNode(targetNode, notif.stack_name);
|
|
} else {
|
|
void loadFile(notif.stack_name);
|
|
}
|
|
};
|
|
|
|
const changeEnvFile = async (file: string) => {
|
|
editorState.setSelectedEnvFile(file);
|
|
editorState.setIsFileLoading(true);
|
|
try {
|
|
const res = await apiFetch(
|
|
`/stacks/${stackListState.selectedFile}/env?file=${encodeURIComponent(file)}`,
|
|
);
|
|
if (!res.ok) {
|
|
editorState.setEnvContent('');
|
|
editorState.setOriginalEnvContent('');
|
|
editorState.setEnvEtag(null);
|
|
toast.error('Could not load env file');
|
|
return;
|
|
}
|
|
const text = await res.text();
|
|
editorState.setEnvContent(text || '');
|
|
editorState.setOriginalEnvContent(text || '');
|
|
editorState.setEnvEtag(res.headers.get('etag'));
|
|
} catch (e) {
|
|
console.error('Failed to switch env file', e);
|
|
editorState.setEnvContent('');
|
|
editorState.setOriginalEnvContent('');
|
|
editorState.setEnvEtag(null);
|
|
toast.error('Failed to load env file');
|
|
} finally {
|
|
editorState.setIsFileLoading(false);
|
|
}
|
|
};
|
|
|
|
const saveFile = async (options?: { force?: boolean }): Promise<boolean> => {
|
|
if (editorState.activeTab === 'files') return false;
|
|
if (!stackListState.selectedFile) return false;
|
|
const force = options?.force === true;
|
|
const isCompose = editorState.activeTab === 'compose';
|
|
const currentContent = isCompose
|
|
? editorState.content || ''
|
|
: editorState.envContent || '';
|
|
const endpoint = isCompose
|
|
? `/stacks/${stackListState.selectedFile}`
|
|
: `/stacks/${stackListState.selectedFile}/env?file=${encodeURIComponent(editorState.selectedEnvFile)}`;
|
|
const etag = isCompose ? editorState.composeEtag : editorState.envEtag;
|
|
const headers: Record<string, string> = {};
|
|
if (!force && etag) headers['If-Match'] = etag;
|
|
try {
|
|
const response = await apiFetch(endpoint, {
|
|
method: 'PUT',
|
|
headers,
|
|
body: JSON.stringify({ content: currentContent }),
|
|
});
|
|
if (response.status === 412) {
|
|
const payload = await response.json().catch(() => null);
|
|
const currentRemoteContent =
|
|
payload && typeof payload.currentContent === 'string' ? payload.currentContent : '';
|
|
const fileName = isCompose
|
|
? 'compose.yaml'
|
|
: (editorState.selectedEnvFile || '.env').split('/').pop() ?? '.env';
|
|
const confirmed = window.confirm(
|
|
`${fileName} was changed by another tab or process. Overwrite their changes with yours? Click Cancel to discard your local edits and reload the latest version.`,
|
|
);
|
|
if (confirmed) {
|
|
return await saveFile({ force: true });
|
|
}
|
|
if (isCompose) {
|
|
editorState.setContent(currentRemoteContent);
|
|
editorState.setOriginalContent(currentRemoteContent);
|
|
editorState.setComposeEtag(response.headers.get('etag'));
|
|
} else {
|
|
editorState.setEnvContent(currentRemoteContent);
|
|
editorState.setOriginalEnvContent(currentRemoteContent);
|
|
editorState.setEnvEtag(response.headers.get('etag'));
|
|
}
|
|
toast.success('Reloaded the latest version of the file.');
|
|
return false;
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
}
|
|
const newEtag = response.headers.get('etag');
|
|
if (isCompose) {
|
|
editorState.setOriginalContent(editorState.content);
|
|
if (newEtag) editorState.setComposeEtag(newEtag);
|
|
} else {
|
|
editorState.setOriginalEnvContent(editorState.envContent);
|
|
if (newEtag) editorState.setEnvEtag(newEtag);
|
|
}
|
|
toast.success('File saved successfully!');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Failed to save file:', error);
|
|
toast.error(`Failed to save file: ${(error as Error).message}`);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const requestSave = () => {
|
|
const isCompose = editorState.activeTab === 'compose';
|
|
const orig = isCompose ? editorState.originalContent : editorState.originalEnvContent;
|
|
const curr = isCompose ? editorState.content : editorState.envContent;
|
|
if (diffPreviewEnabled && editorState.activeTab !== 'files' && curr !== orig) {
|
|
overlayState.setDiffPreview({
|
|
mode: 'save',
|
|
language: isCompose ? 'yaml' : 'ini',
|
|
original: orig,
|
|
modified: curr,
|
|
fileName: isCompose ? 'compose.yaml' : editorState.selectedEnvFile || '.env',
|
|
});
|
|
} else {
|
|
void saveFile();
|
|
}
|
|
};
|
|
|
|
const requestSaveAndDeploy = (e: React.MouseEvent) => {
|
|
const isCompose = editorState.activeTab === 'compose';
|
|
const orig = isCompose ? editorState.originalContent : editorState.originalEnvContent;
|
|
const curr = isCompose ? editorState.content : editorState.envContent;
|
|
if (diffPreviewEnabled && editorState.activeTab !== 'files' && curr !== orig) {
|
|
overlayState.setDiffPreview({
|
|
mode: 'save-and-deploy',
|
|
language: isCompose ? 'yaml' : 'ini',
|
|
original: orig,
|
|
modified: curr,
|
|
fileName: isCompose ? 'compose.yaml' : editorState.selectedEnvFile || '.env',
|
|
});
|
|
} else {
|
|
void handleSaveAndDeploy(e);
|
|
}
|
|
};
|
|
|
|
// Parse a 409 body for a scan-policy block. When it is one, record it (with
|
|
// the originating action, file, and the node the operation targeted so the
|
|
// bypass retries the right endpoint on the right node) so PolicyBlockDialog
|
|
// can open, and return the policy name. Returns null when the body is not a
|
|
// policy block (e.g. a stack-op-in-progress 409).
|
|
const tryOpenPolicyBlock = (
|
|
rawBody: string,
|
|
stackName: string,
|
|
stackFile: string,
|
|
action: PolicyBlockableAction,
|
|
opNodeId: number | null,
|
|
): string | null => {
|
|
let parsed: PolicyBlockPayload | null = null;
|
|
try {
|
|
parsed = JSON.parse(rawBody) as PolicyBlockPayload;
|
|
} catch {
|
|
/* not JSON */
|
|
}
|
|
if (parsed && parsed.policy && Array.isArray(parsed.violations)) {
|
|
overlayState.setPolicyBlock({ stackName, stackFile, action, payload: parsed, nodeId: opNodeId });
|
|
return parsed.policy.name;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const runDeploy = async (
|
|
stackName: string,
|
|
stackFile: string,
|
|
ignorePolicy: boolean,
|
|
started?: Promise<void>,
|
|
deploySessionId?: string,
|
|
opNodeId?: number | null,
|
|
): Promise<RunResult> => {
|
|
const previousStatus = stackListState.stackStatuses[stackFile];
|
|
const startedAt = Date.now();
|
|
stackListState.setOptimisticStatus(stackFile, 'running');
|
|
try {
|
|
const path = ignorePolicy
|
|
? `/stacks/${stackName}/deploy?ignorePolicy=true`
|
|
: `/stacks/${stackName}/deploy`;
|
|
if (started) await started;
|
|
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.
|
|
if (previousStatus !== undefined)
|
|
stackListState.setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
|
|
const inProgress = parseStackOpInProgress(rawBody);
|
|
if (inProgress) {
|
|
const message = stackOpInProgressMessage(stackName, inProgress);
|
|
toast.error(message);
|
|
return { ok: false, errorMessage: message };
|
|
}
|
|
const blockedBy = tryOpenPolicyBlock(rawBody, stackName, stackFile, 'deploy', opNodeId ?? null);
|
|
if (blockedBy) {
|
|
const message = `Deploy blocked by policy "${blockedBy}"`;
|
|
toast.error(message);
|
|
return { ok: false, errorMessage: message };
|
|
}
|
|
}
|
|
throw parseStackActionError(rawBody, 'Deploy failed', response.status);
|
|
}
|
|
overlayState.setPolicyBlock(null);
|
|
const healthGateId = await parseHealthGateId(response);
|
|
// With a health gate observing, the operation finishing is not the
|
|
// final verdict yet; soften the toast so success is not claimed twice.
|
|
if (healthGateId) {
|
|
toast.info(ignorePolicy ? 'Stack deployed (policy bypassed). Verifying health...' : 'Stack deployed. Verifying health...');
|
|
} else {
|
|
toast.success(ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!');
|
|
}
|
|
await refreshSelectedContainers(stackName, stackFile);
|
|
try {
|
|
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
|
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
stackListState.recordActionSuccess(stackFile);
|
|
return { ok: true, healthGateId };
|
|
} catch (error) {
|
|
console.error('Failed to deploy:', error);
|
|
if (previousStatus !== undefined)
|
|
stackListState.setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
|
|
const deployError = error as StackActionError;
|
|
const errorMessage = deployError.message || 'Failed to deploy stack';
|
|
toast.error(
|
|
deployError.rolledBack === true
|
|
? `${errorMessage} - automatically restored the previous compose and env files.`
|
|
: errorMessage,
|
|
);
|
|
recordActionFailureFor(stackFile, stackName, 'deploy', startedAt, errorMessage, deployError.rolledBack === true, deployError.failure);
|
|
await refreshSelectedContainers(stackName, stackFile);
|
|
return { ok: false, errorMessage, rolledBack: deployError.rolledBack };
|
|
}
|
|
};
|
|
|
|
const deployStack = async (e?: React.MouseEvent) => {
|
|
e?.preventDefault();
|
|
e?.stopPropagation();
|
|
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
|
|
// it even if the active node changes while the advisory dialog is open.
|
|
const opNodeId = activeNode?.id ?? null;
|
|
deployPendingRef.current = true;
|
|
|
|
// The actual deploy, pulled out so the optional pre-deploy advisory can gate
|
|
// it without duplicating the action lifecycle. The stack action is set here
|
|
// (not before the advisory) so cancelling the advisory leaves no stuck state.
|
|
const runDeployFlow = async () => {
|
|
stackListState.setStackAction(stackFile, 'deploy');
|
|
try {
|
|
await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) =>
|
|
runDeploy(stackName, stackFile, false, started, ds, opNodeId),
|
|
);
|
|
} finally {
|
|
stackListState.clearStackAction(stackFile);
|
|
stackListState.refreshStacks(true);
|
|
deployPendingRef.current = false;
|
|
}
|
|
};
|
|
|
|
// Advisory runs before the deploy log opens (fails open: a null result means
|
|
// setting off / timeout / older node / error, so the deploy proceeds).
|
|
const advisoryImages = await fetchPreDeployAdvisory(stackName, opNodeId);
|
|
if (advisoryImages && advisoryImages.length > 0) {
|
|
let settled = false;
|
|
overlayState.setPreDeployAdvisory({
|
|
stackName,
|
|
images: advisoryImages,
|
|
proceed: () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
overlayState.setPreDeployAdvisory(null);
|
|
void runDeployFlow();
|
|
},
|
|
cancel: () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
overlayState.setPreDeployAdvisory(null);
|
|
deployPendingRef.current = false;
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
await runDeployFlow();
|
|
};
|
|
|
|
const handleSaveAndDeploy = async (e: React.MouseEvent) => {
|
|
const saved = await saveFile();
|
|
if (!saved) return;
|
|
await deployStack(e);
|
|
};
|
|
|
|
// Admin "Deploy anyway": re-issue the blocked action with ?ignorePolicy=true.
|
|
// Retries whichever action triggered the block (deploy or update) so an
|
|
// update bypass still re-pulls images, matching the backend bypass on each
|
|
// endpoint. The server ignores the flag unless the caller is an admin.
|
|
const bypassPolicyAndRetry = async () => {
|
|
const policyBlock = overlayState.policyBlock;
|
|
if (!policyBlock) return;
|
|
// Retry on the node the block was raised against, not the live active node,
|
|
// which may have changed while the dialog was open.
|
|
const { stackName, stackFile, action, nodeId: opNodeId } = policyBlock;
|
|
const existingFile = stackListState.files.includes(stackFile)
|
|
? stackFile
|
|
: (stackListState.files.find(f => f.replace(/\.(yml|yaml)$/, '') === stackName) ?? stackFile);
|
|
overlayState.setPolicyBypassing(true);
|
|
try {
|
|
if (action === 'update') {
|
|
await runStackAction(existingFile, 'update', 'update', 'running', 'Stack updated successfully!', true, opNodeId);
|
|
} else if (action === 'rollback') {
|
|
await rollbackStack(true, opNodeId);
|
|
} else {
|
|
stackListState.setStackAction(existingFile, 'deploy');
|
|
try {
|
|
await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) =>
|
|
runDeploy(stackName, existingFile, true, started, ds, opNodeId),
|
|
);
|
|
} finally {
|
|
stackListState.clearStackAction(existingFile);
|
|
stackListState.refreshStacks(true);
|
|
}
|
|
}
|
|
} finally {
|
|
overlayState.setPolicyBypassing(false);
|
|
}
|
|
};
|
|
|
|
const rollbackStack = async (ignorePolicy = false, opNodeId: number | null = activeNode?.id ?? null) => {
|
|
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');
|
|
stackListState.setOptimisticStatus(stackFile, 'running');
|
|
try {
|
|
const path = ignorePolicy
|
|
? `/stacks/${stackFile}/rollback?ignorePolicy=true`
|
|
: `/stacks/${stackFile}/rollback`;
|
|
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) {
|
|
const message = stackOpInProgressMessage(stackName, inProgress);
|
|
toast.error(message);
|
|
return;
|
|
}
|
|
const blockedBy = tryOpenPolicyBlock(rawBody, stackName, stackFile, 'rollback', opNodeId);
|
|
if (blockedBy) {
|
|
toast.error(`Rollback blocked by policy "${blockedBy}"`);
|
|
return;
|
|
}
|
|
}
|
|
throw parseStackActionError(rawBody, 'Rollback failed', res.status);
|
|
}
|
|
overlayState.setPolicyBlock(null);
|
|
toast.success('Stack rolled back: compose and env files restored.');
|
|
stackListState.recordActionSuccess(stackFile);
|
|
// The rollback already succeeded; a failure of the cosmetic refetches below
|
|
// (containers redeployed by the rollback, restored compose content, backup
|
|
// info) must not be mis-recorded as a rollback failure.
|
|
try {
|
|
await refreshSelectedContainers(stackName, stackFile);
|
|
const contentRes = await apiFetch(`/stacks/${stackFile}`);
|
|
const text = await contentRes.text();
|
|
editorState.setContent(text || '');
|
|
editorState.setOriginalContent(text || '');
|
|
const backupRes = await apiFetch(`/stacks/${stackFile}/backup`);
|
|
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
|
} catch (refetchError) {
|
|
console.error('Post-rollback refetch failed:', refetchError);
|
|
}
|
|
} catch (error: unknown) {
|
|
const msg = error instanceof Error ? error.message : 'Rollback failed';
|
|
toast.error(msg);
|
|
recordActionFailureFor(stackFile, stackName, 'rollback', startedAt, msg, false,
|
|
error instanceof Error ? (error as StackActionError).failure : undefined);
|
|
await refreshSelectedContainers(stackName, stackFile);
|
|
} finally {
|
|
stackListState.clearStackAction(stackFile);
|
|
stackListState.refreshStacks(true);
|
|
}
|
|
};
|
|
|
|
const discardChanges = () => {
|
|
if (editorState.activeTab === 'files') return;
|
|
if (editorState.activeTab === 'compose') {
|
|
editorState.setContent(editorState.originalContent);
|
|
} else {
|
|
editorState.setEnvContent(editorState.originalEnvContent);
|
|
}
|
|
};
|
|
|
|
// Revert both compose and env buffers. Used by closeComposeEditor so a dirty
|
|
// buffer on a non-active tab is not left hidden behind the Anatomy panel.
|
|
// Keep discardChanges for the Save-dropdown current-tab discard.
|
|
const discardAllChanges = () => {
|
|
editorState.setContent(editorState.originalContent);
|
|
editorState.setEnvContent(editorState.originalEnvContent);
|
|
};
|
|
|
|
const openComposeEditor = () => {
|
|
const selected = stackListState.selectedFile;
|
|
if (!selected || !canEditStack(selected)) return;
|
|
editorState.setEditingCompose(true);
|
|
editorState.setActiveTab('compose');
|
|
editorState.setIsEditing(true);
|
|
};
|
|
|
|
const closeComposeEditor = () => {
|
|
if (hasUnsavedChanges()) {
|
|
discardAllChanges();
|
|
}
|
|
editorState.setEditingCompose(false);
|
|
};
|
|
|
|
const scanStackConfig = async () => {
|
|
if (!stackListState.selectedFile || editorState.stackMisconfigScanning) return;
|
|
const stackName = stackListState.selectedFile.replace(/\.(yml|yaml)$/, '');
|
|
editorState.setStackMisconfigScanning(true);
|
|
const loadingId = toast.loading(`Scanning ${stackName} configuration...`);
|
|
try {
|
|
const res = await apiFetch('/security/scan/stack', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ stackName }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data?.error || 'Failed to start scan');
|
|
if (data.status === 'failed') {
|
|
throw new Error(data.error || 'Scan failed');
|
|
}
|
|
toast.success(
|
|
`Config scan complete: ${data.misconfig_count ?? 0} misconfigurations found`,
|
|
);
|
|
overlayState.setStackMisconfigScanId(data.id as number);
|
|
} catch (error) {
|
|
const msg = error instanceof Error
|
|
? error.message
|
|
: ((error as { error?: string })?.error ?? 'Config scan failed');
|
|
toast.error(msg);
|
|
} finally {
|
|
toast.dismiss(loadingId);
|
|
editorState.setStackMisconfigScanning(false);
|
|
}
|
|
};
|
|
|
|
const runStackAction = async (
|
|
stackFile: string,
|
|
action: 'stop' | 'restart' | 'update',
|
|
endpoint: string,
|
|
optimisticStatus: 'running' | 'exited',
|
|
successMessage: string,
|
|
ignorePolicy = false,
|
|
// Node the operation targets. Defaults to the live active node for direct
|
|
// callers (toolbar stop/restart); the update path passes the node captured
|
|
// when its readiness dialog opened so a mid-dialog node switch cannot
|
|
// retarget the update.
|
|
opNodeId: number | null = activeNode?.id ?? null,
|
|
): Promise<void> => {
|
|
if (stackListState.isStackBusy(stackFile)) return;
|
|
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
|
const previousStatus = stackListState.stackStatuses[stackFile];
|
|
const startedAt = Date.now();
|
|
stackListState.setStackAction(stackFile, action);
|
|
stackListState.setOptimisticStatus(stackFile, optimisticStatus);
|
|
try {
|
|
await runWithLog({ stackName, action, nodeId: opNodeId }, async (started, ds) => {
|
|
await started;
|
|
try {
|
|
const url = ignorePolicy
|
|
? `/stacks/${stackName}/${endpoint}?ignorePolicy=true`
|
|
: `/stacks/${stackName}/${endpoint}`;
|
|
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) {
|
|
const message = stackOpInProgressMessage(stackName, inProgress);
|
|
toast.error(message);
|
|
return { ok: false as const, errorMessage: message };
|
|
}
|
|
if (action === 'update') {
|
|
const blockedBy = tryOpenPolicyBlock(errText, stackName, stackFile, 'update', opNodeId);
|
|
if (blockedBy) {
|
|
const message = `Update blocked by policy "${blockedBy}"`;
|
|
toast.error(message);
|
|
return { ok: false as const, errorMessage: message };
|
|
}
|
|
}
|
|
}
|
|
const actionError = parseStackActionError(errText, `${action} failed`, response.status);
|
|
recordActionFailureFor(stackFile, stackName, action, startedAt, actionError.message, actionError.rolledBack === true, actionError.failure);
|
|
await refreshSelectedContainers(stackName, stackFile);
|
|
return {
|
|
ok: false as const,
|
|
errorMessage: actionError.message,
|
|
rolledBack: actionError.rolledBack,
|
|
};
|
|
}
|
|
overlayState.setPolicyBlock(null);
|
|
const healthGateId = await parseHealthGateId(response);
|
|
// With a health gate observing, the operation finishing is not the
|
|
// final verdict yet; soften the toast so success is not claimed twice.
|
|
if (healthGateId && action === 'update') {
|
|
toast.info('Stack updated. Verifying health...');
|
|
} else {
|
|
toast.success(successMessage);
|
|
}
|
|
if (action === 'update') stackListState.fetchImageUpdates();
|
|
await refreshSelectedContainers(stackName, stackFile);
|
|
stackListState.recordActionSuccess(stackFile);
|
|
return { ok: true as const, healthGateId };
|
|
} catch (err) {
|
|
const message = (err as Error).message || `${action} failed`;
|
|
recordActionFailureFor(stackFile, stackName, action, startedAt, message, false);
|
|
await refreshSelectedContainers(stackName, stackFile);
|
|
return { ok: false as const, errorMessage: message };
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error(`Failed to ${action}:`, error);
|
|
if (previousStatus !== undefined)
|
|
stackListState.setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
|
|
toast.error((error as Error).message || `Failed to ${action} stack`);
|
|
} finally {
|
|
stackListState.clearStackAction(stackFile);
|
|
stackListState.refreshStacks(true);
|
|
}
|
|
};
|
|
|
|
const stopStack = async (e?: React.MouseEvent) => {
|
|
e?.preventDefault();
|
|
e?.stopPropagation();
|
|
if (!stackListState.selectedFile) return;
|
|
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
|
|
await runStackAction(stackListState.selectedFile, 'stop', 'stop', 'exited', 'Stack stopped successfully!');
|
|
};
|
|
|
|
const restartStack = async (e?: React.MouseEvent) => {
|
|
e?.preventDefault();
|
|
e?.stopPropagation();
|
|
if (!stackListState.selectedFile) return;
|
|
await runStackAction(stackListState.selectedFile, 'restart', 'restart', 'running', 'Stack restarted successfully!');
|
|
};
|
|
|
|
const serviceAction = async (
|
|
action: 'start' | 'stop' | 'restart',
|
|
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) {
|
|
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}`);
|
|
const cr = await apiFetch(`/stacks/${stackName}/containers`);
|
|
const conts = await cr.json();
|
|
editorState.setContainers(Array.isArray(conts) ? conts : []);
|
|
} catch (e) {
|
|
console.error(`Failed to ${action} service "${serviceName}":`, e);
|
|
toast.error((e as Error).message || `Failed to ${action} service "${serviceName}"`);
|
|
} finally {
|
|
stackListState.refreshStacks(true);
|
|
}
|
|
};
|
|
|
|
// Single entry point for every manual update trigger (toolbar, sidebar
|
|
// context menu, recovery retry). With the update-guard capability it opens
|
|
// the readiness dialog first; the dialog's proceed runs the same
|
|
// runWithLog-backed executor either way, so there is exactly one update path.
|
|
const requestStackUpdate = async (stackFile: string): Promise<void> => {
|
|
if (stackListState.isStackBusy(stackFile)) return;
|
|
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
|
// Capture the node now so the readiness fetch and the update both target it
|
|
// even if the active node changes while the readiness dialog is open.
|
|
const opNodeId = activeNode?.id ?? null;
|
|
const run = () => runStackAction(stackFile, 'update', 'update', 'running', 'Stack updated successfully!', false, opNodeId);
|
|
if (hasUpdateGuard) {
|
|
overlayState.setUpdateReadiness({
|
|
stackName,
|
|
stackFile,
|
|
nodeId: opNodeId,
|
|
proceed: () => {
|
|
overlayState.setUpdateReadiness(null);
|
|
void run();
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
await run();
|
|
};
|
|
|
|
const updateStack = async (e?: React.MouseEvent) => {
|
|
e?.preventDefault();
|
|
e?.stopPropagation();
|
|
if (!stackListState.selectedFile) return;
|
|
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
|
|
await requestStackUpdate(stackListState.selectedFile);
|
|
};
|
|
|
|
const deleteStack = async (pruneVolumes: boolean) => {
|
|
const stackToDelete = overlayState.stackToDelete;
|
|
if (!stackToDelete) return;
|
|
const deleteKey =
|
|
stackListState.files.find(
|
|
f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete,
|
|
) ?? stackToDelete;
|
|
if (stackListState.isStackBusy(deleteKey)) return;
|
|
stackListState.setStackAction(deleteKey, 'delete');
|
|
try {
|
|
const url = pruneVolumes
|
|
? `/stacks/${stackToDelete}?pruneVolumes=true`
|
|
: `/stacks/${stackToDelete}`;
|
|
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!');
|
|
overlayState.closeDeleteDialog();
|
|
if (stackListState.selectedFile === stackToDelete) {
|
|
resetEditorState();
|
|
}
|
|
await stackListState.refreshStacks();
|
|
} catch (error) {
|
|
console.error('Failed to delete stack:', error);
|
|
toast.error((error as Error).message || 'Failed to delete stack');
|
|
} finally {
|
|
stackListState.clearStackAction(deleteKey);
|
|
}
|
|
};
|
|
|
|
const requestTakeDownStack = (stackName: string) => {
|
|
if (openSelfStackProtectedIfNeeded(
|
|
stackListState.files.find(f => f.replace(/\.(yml|yaml)$/, '') === stackName) ?? stackName,
|
|
)) return;
|
|
overlayState.openTakeDownDialog(stackName);
|
|
};
|
|
|
|
const takeDownStack = async (removeVolumes: boolean) => {
|
|
const stackToTakeDown = overlayState.stackToTakeDown;
|
|
if (!stackToTakeDown) return;
|
|
if (removeVolumes && !canOfferVolumeRemoval) {
|
|
toast.error('Volume removal is not supported on this node');
|
|
overlayState.closeTakeDownDialog();
|
|
return;
|
|
}
|
|
const stackFile =
|
|
stackListState.files.find(
|
|
f => f === stackToTakeDown || f.replace(/\.(yml|yaml)$/, '') === stackToTakeDown,
|
|
) ?? stackToTakeDown;
|
|
if (stackListState.isStackBusy(stackFile)) return;
|
|
if (openSelfStackProtectedIfNeeded(stackFile)) return;
|
|
|
|
const previousStatus = stackListState.stackStatuses[stackFile];
|
|
const startedAt = Date.now();
|
|
const opNodeId = activeNode?.id ?? null;
|
|
stackListState.setStackAction(stackFile, 'down');
|
|
stackListState.setOptimisticStatus(stackFile, 'exited');
|
|
try {
|
|
await runWithLog({ stackName: stackToTakeDown, action: 'down', nodeId: opNodeId }, async (started, ds) => {
|
|
await started;
|
|
try {
|
|
const url = removeVolumes
|
|
? `/stacks/${stackToTakeDown}/down?removeVolumes=true`
|
|
: `/stacks/${stackToTakeDown}/down`;
|
|
const response = await apiFetch(url, withDeploySession(ds, { method: 'POST', nodeId: opNodeId }));
|
|
if (!response.ok) {
|
|
const errText = await response.text();
|
|
if (response.status === 409) {
|
|
const inProgress = parseStackOpInProgress(errText);
|
|
if (inProgress) {
|
|
const message = stackOpInProgressMessage(stackToTakeDown, inProgress);
|
|
toast.error(message);
|
|
return { ok: false as const, errorMessage: message };
|
|
}
|
|
}
|
|
if (isSelfStackProtectedResponse(errText, response.status)) {
|
|
overlayState.openSelfStackProtected();
|
|
overlayState.closeTakeDownDialog();
|
|
return { ok: false as const, errorMessage: 'Protected stack' };
|
|
}
|
|
const actionError = parseStackActionError(errText, 'Take down failed', response.status);
|
|
recordActionFailureFor(stackFile, stackToTakeDown, 'down', startedAt, actionError.message, false, actionError.failure);
|
|
await refreshSelectedContainers(stackToTakeDown, stackFile);
|
|
return { ok: false as const, errorMessage: actionError.message };
|
|
}
|
|
toast.success('Stack taken down successfully!');
|
|
await refreshSelectedContainers(stackToTakeDown, stackFile);
|
|
stackListState.recordActionSuccess(stackFile);
|
|
overlayState.closeTakeDownDialog();
|
|
return { ok: true as const };
|
|
} catch (err) {
|
|
const message = (err as Error).message || 'Take down failed';
|
|
recordActionFailureFor(stackFile, stackToTakeDown, 'down', startedAt, message, false);
|
|
await refreshSelectedContainers(stackToTakeDown, stackFile);
|
|
return { ok: false as const, errorMessage: message };
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to take down stack:', error);
|
|
if (previousStatus !== undefined) {
|
|
stackListState.setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
|
|
}
|
|
toast.error((error as Error).message || 'Failed to take down stack');
|
|
} finally {
|
|
stackListState.clearStackAction(stackFile);
|
|
stackListState.refreshStacks(true);
|
|
}
|
|
};
|
|
|
|
// Guard a navigation that would leave (and discard) a dirty editor: back to
|
|
// the list, Home, or any bottom-tab / hamburger / command-palette
|
|
// destination. When the editor is dirty the navigation is stashed and the
|
|
// unsaved-changes dialog opens; discardAndLoadPending runs it on confirm.
|
|
// When clean it runs immediately.
|
|
const attemptLeaveEditor = (perform: () => void, onCancel?: () => void) => {
|
|
if (stackListState.selectedFile && hasUnsavedChanges()) {
|
|
overlayState.setPendingLeaveAction({ run: perform, onCancel });
|
|
return;
|
|
}
|
|
perform();
|
|
};
|
|
|
|
const wouldDiscardOnPopstate = (): boolean => {
|
|
if (!stackListState.selectedFile) return false;
|
|
const parsed = parsePath(window.location.pathname, window.location.search);
|
|
const targetStack = parsed.stackName;
|
|
const targetView = parsed.view;
|
|
|
|
if (targetView !== 'editor' || !targetStack || targetStack !== stackListState.selectedFile) {
|
|
return isComposeDirty() || isEnvDirty();
|
|
}
|
|
// Leaving Monaco for stack detail on the same stack.
|
|
if (editorState.editingCompose && parsed.editorTab == null) {
|
|
return isComposeDirty() || isEnvDirty();
|
|
}
|
|
if (
|
|
parsed.editorTab === 'env'
|
|
&& editorState.activeTab === 'env'
|
|
&& parsed.envFile
|
|
) {
|
|
const resolved = resolveEnvFilePath(parsed.envFile, editorState.envFiles);
|
|
if (resolved && resolved !== editorState.selectedEnvFile) {
|
|
return isEnvDirty();
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const attemptPopstateNavigation = (apply: () => void, onCancel: () => void) => {
|
|
if (wouldDiscardOnPopstate()) {
|
|
overlayState.setPendingLeaveAction({ run: apply, onCancel });
|
|
return;
|
|
}
|
|
apply();
|
|
};
|
|
|
|
const cancelPendingUnsavedLoad = () => {
|
|
const cancel = overlayState.pendingLeaveAction?.onCancel;
|
|
overlayState.setPendingUnsavedLoad(null);
|
|
overlayState.setPendingLoadOptions(null);
|
|
overlayState.setPendingUnsavedNode(null);
|
|
overlayState.setPendingLeaveAction(null);
|
|
cancel?.();
|
|
};
|
|
|
|
const discardAndLoadPending = () => {
|
|
const leave = overlayState.pendingLeaveAction;
|
|
const target = overlayState.pendingUnsavedLoad;
|
|
const targetNode = overlayState.pendingUnsavedNode;
|
|
const loadOptions = overlayState.pendingLoadOptions;
|
|
editorState.setContent(editorState.originalContent);
|
|
editorState.setEnvContent(editorState.originalEnvContent);
|
|
overlayState.setPendingUnsavedLoad(null);
|
|
overlayState.setPendingLoadOptions(null);
|
|
overlayState.setPendingUnsavedNode(null);
|
|
overlayState.setPendingLeaveAction(null);
|
|
// A stashed "leave editor" navigation takes precedence; it already knows
|
|
// how to tear down editor state (resetEditorState) and move the surface.
|
|
if (leave) {
|
|
leave.run();
|
|
return;
|
|
}
|
|
if (target === NODE_SWITCH_PENDING_TOKEN) {
|
|
if (targetNode) setActiveNode(targetNode);
|
|
return;
|
|
}
|
|
if (target) {
|
|
const resumeOptions: LoadFileOptions = {
|
|
...(loadOptions ?? {}),
|
|
skipUnsavedCheck: true,
|
|
};
|
|
if (targetNode) void loadFileOnNode(targetNode, target, resumeOptions);
|
|
else void loadFile(target, resumeOptions);
|
|
}
|
|
};
|
|
|
|
const requestDeleteStack = () => {
|
|
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
|
|
overlayState.openDeleteDialog(stackListState.selectedFile ?? '');
|
|
};
|
|
|
|
const executeStackActionByFile = async (
|
|
stackFile: string,
|
|
action: StackAction,
|
|
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.
|
|
if (action === 'update') {
|
|
await requestStackUpdate(stackFile);
|
|
return;
|
|
}
|
|
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
|
const startedAt = Date.now();
|
|
// Bind this sidebar action to the active node now so a policy-block bypass
|
|
// retries on the same node even if the active node changes meanwhile.
|
|
const opNodeId = activeNode?.id ?? null;
|
|
stackListState.setStackAction(stackFile, action);
|
|
|
|
if (action === 'stop') {
|
|
stackListState.setOptimisticStatus(stackFile, 'exited');
|
|
} else if (action === 'deploy' || action === 'restart') {
|
|
stackListState.setOptimisticStatus(stackFile, 'running');
|
|
}
|
|
|
|
try {
|
|
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) {
|
|
toast.error(stackOpInProgressMessage(stackName, inProgress));
|
|
return;
|
|
}
|
|
if (action === 'deploy') {
|
|
const blockedBy = tryOpenPolicyBlock(errText, stackName, stackFile, action, opNodeId);
|
|
if (blockedBy) {
|
|
toast.error(`Deploy blocked by policy "${blockedBy}"`);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
throw parseStackActionError(errText, `${action} failed`, response.status);
|
|
}
|
|
toast.success(`Stack ${action}ed successfully!`);
|
|
await refreshSelectedContainers(stackName, stackFile);
|
|
if (action === 'deploy') {
|
|
try {
|
|
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
|
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
stackListState.recordActionSuccess(stackFile);
|
|
} catch (error) {
|
|
console.error(`Failed to ${action}:`, error);
|
|
const actionError = error as StackActionError;
|
|
const msg = actionError.message || `Failed to ${action} stack`;
|
|
toast.error(
|
|
action === 'deploy' && actionError.rolledBack === true
|
|
? `${msg} - automatically restored the previous compose and env files.`
|
|
: msg,
|
|
);
|
|
recordActionFailureFor(stackFile, stackName, action, startedAt, msg, actionError.rolledBack === true, actionError.failure);
|
|
await refreshSelectedContainers(stackName, stackFile);
|
|
} finally {
|
|
stackListState.clearStackAction(stackFile);
|
|
stackListState.refreshStacks(true);
|
|
}
|
|
};
|
|
|
|
const checkUpdatesForStack = async () => {
|
|
try {
|
|
const res = await apiFetch('/image-updates/refresh', { method: 'POST' });
|
|
if (res.ok) {
|
|
toast.success('Checking for image updates...');
|
|
let elapsed = 0;
|
|
const poll = setInterval(async () => {
|
|
elapsed += 2000;
|
|
try {
|
|
const statusRes = await apiFetch('/image-updates/status');
|
|
if (statusRes.ok) {
|
|
const { checking } = await statusRes.json();
|
|
if (!checking || elapsed >= 60000) {
|
|
clearInterval(poll);
|
|
checkUpdatesIntervalRef.current = null;
|
|
await stackListState.fetchImageUpdates();
|
|
if (!checking) toast.success('Image update check complete.');
|
|
}
|
|
}
|
|
} catch {
|
|
clearInterval(poll);
|
|
checkUpdatesIntervalRef.current = null;
|
|
await stackListState.fetchImageUpdates();
|
|
}
|
|
}, 2000);
|
|
checkUpdatesIntervalRef.current = poll;
|
|
} else {
|
|
const data = await res.json().catch(() => ({}));
|
|
toast.error(data.error || 'Failed to check for updates');
|
|
}
|
|
} catch {
|
|
toast.error('Failed to check for updates');
|
|
}
|
|
};
|
|
|
|
const getDisplayName = (stackName: string) => stackName;
|
|
|
|
// Adapter wrappers: convert (id, name) signature to overlayState object style
|
|
const openBashModal = useCallback(
|
|
(containerId: string, containerName: string) =>
|
|
overlayState.openBashModal({ id: containerId, name: containerName }),
|
|
[overlayState.openBashModal],
|
|
);
|
|
const closeBashModal = overlayState.closeBashModal;
|
|
const openLogViewer = useCallback(
|
|
(containerId: string, containerName: string) =>
|
|
overlayState.openLogViewer({ id: containerId, name: containerName }),
|
|
[overlayState.openLogViewer],
|
|
);
|
|
const closeLogViewer = overlayState.closeLogViewer;
|
|
|
|
return {
|
|
pendingStackLoadRef,
|
|
pendingLogsRef,
|
|
hasUnsavedChanges,
|
|
getStackMenuVisibility,
|
|
openStackApp,
|
|
resetEditorState,
|
|
refreshSelectedContainers,
|
|
refreshGitSourcePending,
|
|
loadFile,
|
|
loadFileForRoute,
|
|
loadFileOnNode,
|
|
applyEditorRouteState,
|
|
navigateToNotification,
|
|
changeEnvFile,
|
|
saveFile,
|
|
requestSave,
|
|
requestSaveAndDeploy,
|
|
handleSaveAndDeploy,
|
|
rollbackStack,
|
|
discardChanges,
|
|
openComposeEditor,
|
|
closeComposeEditor,
|
|
scanStackConfig,
|
|
runDeploy,
|
|
deployStack,
|
|
bypassPolicyAndRetry,
|
|
stopStack,
|
|
restartStack,
|
|
serviceAction,
|
|
updateStack,
|
|
requestStackUpdate,
|
|
deleteStack,
|
|
attemptLeaveEditor,
|
|
attemptPopstateNavigation,
|
|
cancelPendingUnsavedLoad,
|
|
discardAndLoadPending,
|
|
requestDeleteStack,
|
|
requestTakeDownStack,
|
|
takeDownStack,
|
|
executeStackActionByFile,
|
|
checkUpdatesForStack,
|
|
getDisplayName,
|
|
openBashModal,
|
|
closeBashModal,
|
|
openLogViewer,
|
|
closeLogViewer,
|
|
isSelfStackFile,
|
|
openSelfStackProtectedIfNeeded,
|
|
};
|
|
}
|
|
|
|
export type StackActionsHook = ReturnType<typeof useStackActions>;
|