mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
48cebf9501
* fix: bind deploy progress, request, and health gate to the captured node A deploy/update/install/git-apply re-read the active node from localStorage independently at three points: the progress WebSocket at mount, the POST at call time, and the health-gate poll. If the active node changed between the click and any of those, the operation, its live output, and its health verdict could target different nodes, and the socket and POST splitting across nodes broke output streaming. Capture the operation's node once when it starts and thread it through every leg. A new nodeId option on apiFetch overrides the active-node read, the progress terminal takes a nodeId prop for its socket URL, the health gate polls on the captured node, and a failed gate records its recovery entry only on the node it ran on. The surface, the request, and the gate now always agree. * fix: scope failed-gate recovery to the file list's node and harden node targeting Addresses review findings on the captured-node binding: - Track the node the stack file list was fetched for (filesNodeId) and record a failed gate's recovery entry only when it matches the gate's node. This closes a race where switching back to the gate's node could match a same-named stack from the previous node's still-loaded list before the new list lands, keying the record to the wrong file and blocking the correct one. refreshStacks now carries a sequence token so an out-of-order resolution cannot leave files and filesNodeId inconsistent. - Make an explicit apiFetch nodeId authoritative over a caller-supplied x-node-id header. - Add the missing stack-logs nodeId cases (null, and active-node fallback) to the terminal tests.
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Button } from './ui/button';
|
|
import { Input } from './ui/input';
|
|
import { Download, Search, ChevronUp, ChevronDown, X } from 'lucide-react';
|
|
import { loadXtermModules, type Terminal, type FitAddon, type SearchAddon, type SerializeAddon } from '@/lib/xtermLoader';
|
|
import { buildXtermTheme } from '@/lib/terminalTheme';
|
|
|
|
interface TerminalComponentProps {
|
|
stackName?: string;
|
|
/** Node the socket connects to (a number, or null for local), captured when
|
|
* the operation started. Binds the progress stream to that node so an
|
|
* active-node switch mid-stream cannot split it from the deploy request.
|
|
* Leave undefined to track the active node (the standalone stack-logs view). */
|
|
nodeId?: number | null;
|
|
/** 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, nodeId, deploySessionId, onReady, onError, onMessage }: TerminalComponentProps) {
|
|
const terminalRef = useRef<HTMLDivElement>(null);
|
|
const terminalInstance = useRef<Terminal | null>(null);
|
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
const searchAddonRef = useRef<SearchAddon | null>(null);
|
|
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
|
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
|
const [searchText, setSearchText] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (!terminalRef.current) {
|
|
console.error('Terminal ref not ready');
|
|
return;
|
|
}
|
|
|
|
// Clean up any existing terminal
|
|
if (terminalInstance.current) {
|
|
try {
|
|
terminalInstance.current.dispose();
|
|
} catch {
|
|
// Ignore dispose errors
|
|
}
|
|
}
|
|
if (wsRef.current) {
|
|
try {
|
|
wsRef.current.close();
|
|
} catch {
|
|
// Ignore close errors
|
|
}
|
|
}
|
|
|
|
let mounted = true;
|
|
|
|
const initTerminal = async () => {
|
|
if (!mounted || !terminalRef.current) return;
|
|
|
|
const mods = await loadXtermModules().catch((err) => {
|
|
console.error('Terminal: failed to load xterm:', err);
|
|
return null;
|
|
});
|
|
if (!mods || !mounted || !terminalRef.current) return;
|
|
|
|
try {
|
|
const term = new mods.Terminal({
|
|
cursorBlink: true,
|
|
convertEol: true,
|
|
allowProposedApi: true,
|
|
theme: buildXtermTheme(),
|
|
fontFamily: "'Geist Mono', monospace",
|
|
fontSize: 13,
|
|
scrollback: 10000,
|
|
});
|
|
|
|
const fitAddon = new mods.FitAddon();
|
|
const searchAddon = new mods.SearchAddon();
|
|
const serializeAddon = new mods.SerializeAddon();
|
|
|
|
term.loadAddon(fitAddon);
|
|
term.loadAddon(searchAddon);
|
|
term.loadAddon(serializeAddon);
|
|
|
|
fitAddonRef.current = fitAddon;
|
|
searchAddonRef.current = searchAddon;
|
|
serializeAddonRef.current = serializeAddon;
|
|
|
|
term.open(terminalRef.current);
|
|
terminalInstance.current = term;
|
|
|
|
// Custom key handler for Ctrl+F
|
|
term.attachCustomKeyEventHandler((event) => {
|
|
if (event.ctrlKey && event.key === 'f' && event.type === 'keydown') {
|
|
event.preventDefault();
|
|
setIsSearchVisible(prev => {
|
|
const next = !prev;
|
|
if (next) {
|
|
setTimeout(() => searchInputRef.current?.focus(), 50);
|
|
}
|
|
return next;
|
|
});
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Fit after DOM paint using requestAnimationFrame
|
|
requestAnimationFrame(() => {
|
|
if (!mounted || !fitAddonRef.current || !terminalRef.current) return;
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
} catch (err) {
|
|
console.error('Error fitting terminal:', err);
|
|
}
|
|
});
|
|
|
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const cleanStackName = stackName?.replace(/\.(yml|yaml)$/, '');
|
|
// A captured nodeId (number, or null for local) wins over the active
|
|
// node; undefined falls back to the active node for standalone views.
|
|
let resolvedNodeId: string;
|
|
if (nodeId !== undefined) {
|
|
resolvedNodeId = nodeId === null ? '' : String(nodeId);
|
|
} else {
|
|
resolvedNodeId = localStorage.getItem('sencho-active-node') || '';
|
|
}
|
|
|
|
// If a stackName is provided, connect to the dedicated logs WebSocket
|
|
// Otherwise, fall back to the generic terminal WebSocket
|
|
const wsUrl = cleanStackName
|
|
? `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs${resolvedNodeId ? `?nodeId=${resolvedNodeId}` : ''}`
|
|
: `${wsProtocol}//${window.location.host}/ws${resolvedNodeId ? `?nodeId=${resolvedNodeId}` : ''}`;
|
|
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
if (mounted) {
|
|
if (!cleanStackName) {
|
|
// 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
|
|
}
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
if (mounted && terminalInstance.current) {
|
|
const text = typeof event.data === 'string' ? event.data : event.data.toString();
|
|
onMessage?.(text);
|
|
terminalInstance.current.write(text.replace(/\r?\n/g, '\r\n'));
|
|
}
|
|
};
|
|
|
|
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?.();
|
|
}
|
|
};
|
|
|
|
// Initialize terminal after a small delay to ensure container is rendered
|
|
const timeoutId = setTimeout(() => { void initTerminal(); }, 50);
|
|
|
|
// Attach ResizeObserver to the terminal's parent container
|
|
let resizeTimeout: number | undefined;
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
if (resizeTimeout) clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(() => {
|
|
if (fitAddonRef.current && terminalRef.current && mounted) {
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
} catch {
|
|
// Ignore fit errors during resize
|
|
}
|
|
}
|
|
}, 50);
|
|
});
|
|
|
|
if (terminalRef.current.parentElement) {
|
|
resizeObserver.observe(terminalRef.current.parentElement);
|
|
}
|
|
|
|
return () => {
|
|
mounted = false;
|
|
clearTimeout(timeoutId);
|
|
resizeObserver.disconnect();
|
|
if (wsRef.current) {
|
|
try {
|
|
wsRef.current.close();
|
|
} catch {
|
|
// Ignore close errors
|
|
}
|
|
wsRef.current = null;
|
|
}
|
|
if (terminalInstance.current) {
|
|
try {
|
|
terminalInstance.current.dispose();
|
|
} catch {
|
|
// Ignore dispose errors
|
|
}
|
|
terminalInstance.current = null;
|
|
}
|
|
fitAddonRef.current = null;
|
|
searchAddonRef.current = null;
|
|
serializeAddonRef.current = null;
|
|
};
|
|
}, [stackName, nodeId, deploySessionId, onReady, onError, onMessage]);
|
|
|
|
const handleDownload = () => {
|
|
if (!serializeAddonRef.current) return;
|
|
const content = serializeAddonRef.current.serialize();
|
|
const blob = new Blob([content], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
a.download = `logs-${stackName || 'terminal'}-${timestamp}.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const handleSearchNext = () => {
|
|
if (!searchAddonRef.current || !searchText) return;
|
|
searchAddonRef.current.findNext(searchText);
|
|
};
|
|
|
|
const handleSearchPrev = () => {
|
|
if (!searchAddonRef.current || !searchText) return;
|
|
searchAddonRef.current.findPrevious(searchText);
|
|
};
|
|
|
|
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') {
|
|
if (e.shiftKey) handleSearchPrev();
|
|
else handleSearchNext();
|
|
} else if (e.key === 'Escape') {
|
|
setIsSearchVisible(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full w-full relative group">
|
|
{/* Floating Action Bar / Search Bar Panel */}
|
|
<div className={`absolute top-2 right-6 z-10 flex gap-2 transition-opacity duration-200 ${isSearchVisible ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'}`}>
|
|
{!isSearchVisible ? (
|
|
<Button variant="outline" size="sm" onClick={() => {
|
|
setIsSearchVisible(true);
|
|
setTimeout(() => searchInputRef.current?.focus(), 50);
|
|
}} className="h-8 bg-background/80 backdrop-blur-sm shadow-sm" title="Search (Ctrl+F)">
|
|
<Search className="w-4 h-4 mr-2" />
|
|
Search
|
|
</Button>
|
|
) : (
|
|
<div className="flex flex-row items-center p-1 pr-1 bg-background/95 backdrop-blur-sm border border-border shadow-md rounded-md">
|
|
<Input
|
|
ref={searchInputRef}
|
|
value={searchText}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
onKeyDown={handleSearchKeyDown}
|
|
placeholder="Find in terminal..."
|
|
className="h-7 w-48 border-none focus-visible:ring-0 bg-transparent text-sm min-h-0"
|
|
/>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleSearchPrev} title="Previous (Shift+Enter)">
|
|
<ChevronUp className="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleSearchNext} title="Next (Enter)">
|
|
<ChevronDown className="w-4 h-4" />
|
|
</Button>
|
|
<div className="w-px h-4 bg-border mx-1" />
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 hover:bg-destructive/10 hover:text-destructive" onClick={() => setIsSearchVisible(false)} title="Close (Esc)">
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<Button variant="outline" size="sm" onClick={handleDownload} className="h-8 bg-background/80 backdrop-blur-sm shadow-sm" title="Download Logs">
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Download
|
|
</Button>
|
|
</div>
|
|
|
|
<div ref={terminalRef} className="flex-1 w-full overflow-hidden" />
|
|
</div>
|
|
);
|
|
} |