mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
feat: detect stalled stack updates and add in-app recovery actions (#1347)
* feat: detect stalled stack updates and add in-app recovery actions Add a backend idle-output backstop that stops a deploy/update compose step that has gone silent (SENCHO_COMPOSE_STALL_TIMEOUT_MS, default 10m), so a hung image pull surfaces a fast failure instead of spinning indefinitely. Surface failed, timed-out, and stalled operations with recovery actions on the stack page: a desktop chip plus popover menu and an inline mobile card offering retry, restart, roll back (when a backup exists), refresh state, and copy diagnostics, all gated by deploy permission. The streaming deploy/update progress modal is now on by default and warns when output goes quiet. Container state is refreshed after a failed or stalled operation, and the UI never sits in an indefinite spinner. * fix: harden rollback against policy-blocked file mutation and refine recovery Address review findings on the stalled-update recovery work: - The rollback route restored backup files before running the policy gate, so a policy-blocked rollback could leave the on-disk config rolled back while the deployed containers were unchanged. Snapshot the current files first and revert them when the gate blocks; if that revert itself fails, escalate it on the persistent alert feed since the 409 is already sent. - Refresh container state after a successful manual rollback (rollback redeploys), without mis-recording a refetch failure as a rollback failure. - Suppress the stalled-output warning once live progress is unavailable. * test: mock snapshotStackFiles in the atomic-deploy rollback route tests The rollback route now snapshots stack files before restoring a backup, so its FileSystemService mock needs snapshotStackFiles. Without it the mocked call threw and the route returned 500, failing the success-path rollback assertions.
This commit is contained in:
@@ -53,6 +53,14 @@ interface DeployFeedbackContextValue {
|
||||
) => Promise<RunResult>;
|
||||
panelState: DeployPanelState;
|
||||
logRows: ParsedLogRow[];
|
||||
/**
|
||||
* Epoch ms of the most recent activity for the current session: stamped when
|
||||
* the deploy starts, again when the stream connects, and on every output
|
||||
* chunk. The modal compares it against now to warn that an in-flight
|
||||
* operation has gone quiet (a possible stall), covering the no-first-line
|
||||
* case because it is seeded at start rather than at first output.
|
||||
*/
|
||||
lastOutputAt: number;
|
||||
onTerminalReady: () => void;
|
||||
onTerminalError: () => void;
|
||||
onMessage: (text: string) => void;
|
||||
@@ -92,6 +100,7 @@ const DeployFeedbackContext = createContext<DeployFeedbackContextValue | undefin
|
||||
export function DeployFeedbackProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
const [panelState, setPanelState] = useState<DeployPanelState>(DEFAULT_PANEL_STATE);
|
||||
const [logRows, setLogRows] = useState<ParsedLogRow[]>([]);
|
||||
const [lastOutputAt, setLastOutputAt] = useState<number>(0);
|
||||
|
||||
// Idempotent resolver for the current session's deployStarted gate. Set at the
|
||||
// start of each runWithLog call; called by onTerminalReady (stream connected),
|
||||
@@ -116,6 +125,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
|
||||
const onTerminalReady = useCallback(() => {
|
||||
streamReadyRef.current = true;
|
||||
setLastOutputAt(Date.now());
|
||||
setPanelState((prev) => (prev.status === 'preparing' ? { ...prev, status: 'streaming' } : prev));
|
||||
// Give the connectTerminal handshake a beat to register on the backend
|
||||
// before the deploy POST fires, so the first lines are not missed.
|
||||
@@ -134,6 +144,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
const onMessage = useCallback((text: string) => {
|
||||
const newRows = parseLogChunk(text, idCounterRef.current);
|
||||
idCounterRef.current += newRows.length;
|
||||
setLastOutputAt(Date.now());
|
||||
setLogRows((prev) => {
|
||||
const combined = prev.length > 0 && prev[0].id === TRUNCATION_ROW_ID
|
||||
? [...prev.slice(1), ...newRows]
|
||||
@@ -182,6 +193,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
|
||||
// idCounterRef is intentionally not reset; keys must remain globally unique across sessions.
|
||||
setLogRows([]);
|
||||
setLastOutputAt(Date.now());
|
||||
|
||||
setPanelState({
|
||||
isOpen: true,
|
||||
@@ -236,7 +248,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
|
||||
return (
|
||||
<DeployFeedbackContext.Provider
|
||||
value={{ runWithLog, panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose }}
|
||||
value={{ runWithLog, panelState, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose }}
|
||||
>
|
||||
{children}
|
||||
</DeployFeedbackContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user