mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 09:24:09 +00:00
fix(deploy-progress): decouple deploys from the live progress stream (#1246)
* fix(deploy-progress): decouple deploys from the live progress stream The deploy progress modal streamed compose output over a WebSocket, but the deploy itself was coupled to that socket in two ways that could break or silently abort a deploy: - The deploy request was gated on the progress socket connecting, so any upgrade failure (a reverse proxy blocking WebSocket upgrades, or the admin-only stream rejecting a scoped deployer) left the modal stuck on "Connecting..." and the deploy never fired. - The backend terminated the running compose process when that socket closed, so minimizing the modal, navigating away, or a network blip aborted an in-flight deploy. Make the progress socket output-only: the deploy is owned by its request and runs to completion (or the existing command timeout) regardless of the stream. The modal now degrades to a "Live progress unavailable" state and still reports success or failure from the request result. Connect failures, drops, and a connect timeout all release the deploy instead of blocking it. Also route progress output per deploy: the frontend sends a correlation id on both the connectTerminal message and the deploy request header, and the backend keys progress sockets by that id so concurrent deploys from different tabs or users no longer cross-stream each other's output. Cap the in-memory parsed log rows so a very long deploy cannot grow the modal's state unbounded. * fix(deploy-progress): generate the deploy session id with a CSPRNG The per-deploy correlation id keys which WebSocket receives a deploy's live output, so a guessable id lets one authenticated client register a victim's id and read its compose output. It was built from Math.random() plus a timestamp, which is not cryptographically secure. Generate it with crypto.getRandomValues (128 bits, hex). That is the one Crypto member available in insecure contexts, so it still works over LAN HTTP where crypto.randomUUID is unavailable. * fix(deploy-progress): stop headerless ops bleeding into a keyed progress modal Address review findings on the progress-stream routing: - Only an id-less connectTerminal registration may become the id-less fallback socket. Previously every connectTerminal (including keyed deploy modals) set the fallback, so a headerless operation (bulk update, rollback, or a legacy client) resolved via getTerminalWs() into another user's keyed deploy modal. Keyed sockets are now excluded from the fallback, and a socket that adopts a session id is removed from it. - The connect-timeout fallback now also flags the modal as "Live progress unavailable" instead of leaving it on "Connecting..." while the deploy runs. - Log only a short prefix of the deploy session id in developer diagnostics, not the full capability value.
This commit is contained in:
@@ -9,7 +9,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { apiFetch, withDeploySession } from '@/lib/api';
|
||||
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
@@ -189,9 +189,9 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runWithLog({ stackName: stackName.trim(), action: 'install' }, async (started) => {
|
||||
const result = await runWithLog({ stackName: stackName.trim(), action: 'install' }, async (started, ds) => {
|
||||
await started;
|
||||
const res = await apiFetch('/templates/deploy', {
|
||||
const res = await apiFetch('/templates/deploy', withDeploySession(ds, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
stackName: stackName.trim(),
|
||||
@@ -199,7 +199,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
envVars: finalEnvVars,
|
||||
skip_scan: !autoScan,
|
||||
}),
|
||||
});
|
||||
}));
|
||||
const data = await res.json();
|
||||
if (!res.ok) return { ok: false, errorMessage: data.error || 'Failed to deploy template' };
|
||||
return { ok: true };
|
||||
|
||||
@@ -32,7 +32,7 @@ function formatElapsed(seconds: number): string {
|
||||
}
|
||||
|
||||
export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) {
|
||||
const { panelState, logRows, onTerminalReady, onMessage, onPanelClose } = useDeployFeedback();
|
||||
const { panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback();
|
||||
const { isPaid } = useLicense();
|
||||
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
@@ -46,7 +46,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
const autoCloseHoveredRef = useRef(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { isOpen, stackName, action, status, errorMessage } = panelState;
|
||||
const { isOpen, stackName, action, status, errorMessage, progressUnavailable } = panelState;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -190,6 +190,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<StatusIndicator
|
||||
status={status}
|
||||
progressUnavailable={progressUnavailable}
|
||||
rowCount={logRows.length}
|
||||
errorMessage={errorMessage}
|
||||
countdown={countdown}
|
||||
@@ -232,7 +233,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{logRows.length === 0 ? (
|
||||
<EmptyBody status={status} />
|
||||
<EmptyBody status={status} progressUnavailable={progressUnavailable} />
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{logRows.map((row) => (
|
||||
@@ -248,7 +249,9 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
style={{ height: showRaw ? '200px' : 0, overflow: 'hidden' }}
|
||||
>
|
||||
<TerminalComponent
|
||||
deploySessionId={panelState.deploySessionId}
|
||||
onReady={onTerminalReady}
|
||||
onError={onTerminalError}
|
||||
onMessage={onMessage}
|
||||
/>
|
||||
</div>
|
||||
@@ -293,12 +296,24 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
|
||||
progressUnavailable: boolean;
|
||||
rowCount: number;
|
||||
errorMessage?: string;
|
||||
countdown: number;
|
||||
}
|
||||
|
||||
function StatusIndicator({ status, rowCount, errorMessage, countdown }: StatusIndicatorProps) {
|
||||
function StatusIndicator({ status, progressUnavailable, rowCount, errorMessage, countdown }: StatusIndicatorProps) {
|
||||
// While the deploy is still in flight (preparing/streaming) but the progress
|
||||
// socket is gone, the deploy keeps running server-side with no live output.
|
||||
if (progressUnavailable && (status === 'preparing' || status === 'streaming')) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
<span>Live progress unavailable</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'preparing') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
@@ -343,9 +358,18 @@ function StatusIndicator({ status, rowCount, errorMessage, countdown }: StatusIn
|
||||
|
||||
interface EmptyBodyProps {
|
||||
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
|
||||
progressUnavailable: boolean;
|
||||
}
|
||||
|
||||
function EmptyBody({ status }: EmptyBodyProps) {
|
||||
function EmptyBody({ status, progressUnavailable }: EmptyBodyProps) {
|
||||
if (progressUnavailable && (status === 'preparing' || status === 'streaming')) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground text-center px-4">
|
||||
Live progress is unavailable for this deploy. It continues running in the background.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'preparing') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-muted-foreground">
|
||||
|
||||
@@ -6,7 +6,14 @@ import type { useStackListState } from './useStackListState';
|
||||
import type { useViewNavigationState } from './useViewNavigationState';
|
||||
import type { OverlayState } from './useOverlayState';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
DEPLOY_SESSION_HEADER: 'x-deploy-session-id',
|
||||
withDeploySession: (deploySessionId: string, options: RequestInit = {}) => ({
|
||||
...options,
|
||||
headers: { ...(options.headers as Record<string, string> | undefined), 'x-deploy-session-id': deploySessionId },
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
@@ -72,7 +79,7 @@ function makeOverlay(): OverlayState {
|
||||
}
|
||||
|
||||
const runWithLog: Parameters<typeof useStackActions>[0]['runWithLog'] = async (_p, run) =>
|
||||
run(Promise.resolve());
|
||||
run(Promise.resolve(), 'test-session');
|
||||
|
||||
function setup(over: { editorState?: Partial<EditorState> } = {}) {
|
||||
const editorState = makeEditorState(over.editorState);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { apiFetch, withDeploySession } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import type { useEditorViewState } from './useEditorViewState';
|
||||
import type { useStackListState } from './useStackListState';
|
||||
@@ -61,7 +61,7 @@ interface UseStackActionsOptions {
|
||||
isPaid: boolean;
|
||||
runWithLog: (
|
||||
params: { stackName: string; action: ActionVerb },
|
||||
run: (deployStarted: Promise<void>) => Promise<RunResult>,
|
||||
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>,
|
||||
) => Promise<RunResult>;
|
||||
diffPreviewEnabled: boolean;
|
||||
}
|
||||
@@ -511,6 +511,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
stackFile: string,
|
||||
ignorePolicy: boolean,
|
||||
started?: Promise<void>,
|
||||
deploySessionId?: string,
|
||||
): Promise<RunResult> => {
|
||||
const previousStatus = stackListState.stackStatuses[stackFile];
|
||||
stackListState.setOptimisticStatus(stackFile, 'running');
|
||||
@@ -519,7 +520,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
? `/stacks/${stackName}/deploy?ignorePolicy=true`
|
||||
: `/stacks/${stackName}/deploy`;
|
||||
if (started) await started;
|
||||
const response = await apiFetch(path, { method: 'POST' });
|
||||
const response = await apiFetch(path, withDeploySession(deploySessionId ?? '', { method: 'POST' }));
|
||||
if (!response.ok) {
|
||||
const rawBody = await response.text();
|
||||
if (response.status === 409) {
|
||||
@@ -598,8 +599,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
stackListState.setStackAction(stackFile, 'deploy');
|
||||
try {
|
||||
await runWithLog({ stackName, action: 'deploy' }, started =>
|
||||
runDeploy(stackName, stackFile, false, started),
|
||||
await runWithLog({ stackName, action: 'deploy' }, (started, ds) =>
|
||||
runDeploy(stackName, stackFile, false, started, ds),
|
||||
);
|
||||
} finally {
|
||||
stackListState.clearStackAction(stackFile);
|
||||
@@ -624,8 +625,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
overlayState.setPolicyBypassing(true);
|
||||
stackListState.setStackAction(existingFile, 'deploy');
|
||||
try {
|
||||
await runWithLog({ stackName, action: 'deploy' }, started =>
|
||||
runDeploy(stackName, existingFile, true, started),
|
||||
await runWithLog({ stackName, action: 'deploy' }, (started, ds) =>
|
||||
runDeploy(stackName, existingFile, true, started, ds),
|
||||
);
|
||||
} finally {
|
||||
overlayState.setPolicyBypassing(false);
|
||||
@@ -719,10 +720,10 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
stackListState.setStackAction(stackFile, action);
|
||||
stackListState.setOptimisticStatus(stackFile, optimisticStatus);
|
||||
try {
|
||||
await runWithLog({ stackName, action }, async (started) => {
|
||||
await runWithLog({ stackName, action }, async (started, ds) => {
|
||||
await started;
|
||||
try {
|
||||
const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' });
|
||||
const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, withDeploySession(ds, { method: 'POST' }));
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
if (response.status === 409) {
|
||||
|
||||
@@ -7,11 +7,17 @@ import { buildXtermTheme } from '@/lib/terminalTheme';
|
||||
|
||||
interface TerminalComponentProps {
|
||||
stackName?: string;
|
||||
/** Correlation id sent on the generic `connectTerminal` handshake so the
|
||||
* backend streams the matching deploy's output to this socket. */
|
||||
deploySessionId?: string;
|
||||
onReady?: () => void;
|
||||
/** Fired when the socket fails to connect or drops while still mounted, so the
|
||||
* caller can stop waiting on a best-effort progress stream. */
|
||||
onError?: () => void;
|
||||
onMessage?: (text: string) => void;
|
||||
}
|
||||
|
||||
export default function TerminalComponent({ stackName, onReady, onMessage }: TerminalComponentProps) {
|
||||
export default function TerminalComponent({ stackName, deploySessionId, onReady, onError, onMessage }: TerminalComponentProps) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const terminalInstance = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
@@ -124,8 +130,9 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter
|
||||
ws.onopen = () => {
|
||||
if (mounted) {
|
||||
if (!cleanStackName) {
|
||||
// Generic terminal mode - send connect action
|
||||
ws.send(JSON.stringify({ action: 'connectTerminal' }));
|
||||
// Generic terminal mode - send connect action with the deploy
|
||||
// correlation id so the backend streams this deploy's output here.
|
||||
ws.send(JSON.stringify({ action: 'connectTerminal', sessionId: deploySessionId }));
|
||||
onReady?.();
|
||||
}
|
||||
// For stack logs mode, the server starts streaming automatically on connection
|
||||
@@ -142,10 +149,21 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('WebSocket error:', err);
|
||||
if (mounted) onError?.();
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// Only surface unexpected closes. Intentional teardown sets mounted
|
||||
// false before closing, so this skips minimize/navigation/unmount.
|
||||
if (mounted) onError?.();
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error initializing terminal:', err);
|
||||
// A synchronous setup failure (e.g. WebSocket construction blocked by
|
||||
// CSP) gives no onerror/onclose, so release the deploy gate now instead
|
||||
// of waiting out the connect timeout.
|
||||
if (mounted) onError?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -195,7 +213,7 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter
|
||||
searchAddonRef.current = null;
|
||||
serializeAddonRef.current = null;
|
||||
};
|
||||
}, [stackName, onReady, onMessage]);
|
||||
}, [stackName, deploySessionId, onReady, onError, onMessage]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!serializeAddonRef.current) return;
|
||||
|
||||
@@ -9,6 +9,8 @@ function panel(over: Partial<DeployPanelState> = {}): DeployPanelState {
|
||||
stackName: '',
|
||||
action: 'deploy',
|
||||
status: 'preparing',
|
||||
progressUnavailable: false,
|
||||
deploySessionId: '',
|
||||
sessionId: 0,
|
||||
...over,
|
||||
};
|
||||
|
||||
@@ -19,9 +19,9 @@ function notif(overrides: Partial<NotificationItem> = {}): NotificationItem {
|
||||
};
|
||||
}
|
||||
|
||||
const IDLE_PANEL: DeployPanelState = { isOpen: false, stackName: '', action: 'deploy', status: 'preparing', sessionId: 0 };
|
||||
const STREAMING_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'streaming', sessionId: 1 };
|
||||
const SUCCEEDED_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'succeeded', sessionId: 1 };
|
||||
const IDLE_PANEL: DeployPanelState = { isOpen: false, stackName: '', action: 'deploy', status: 'preparing', progressUnavailable: false, deploySessionId: '', sessionId: 0 };
|
||||
const STREAMING_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'streaming', progressUnavailable: false, deploySessionId: '', sessionId: 1 };
|
||||
const SUCCEEDED_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'succeeded', progressUnavailable: false, deploySessionId: '', sessionId: 1 };
|
||||
|
||||
function inputs(overrides: Partial<Parameters<typeof deriveSummary>[0]> = {}) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user