@flame0510/project-aether 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,313 +1,267 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useState, useMemo } from 'react';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import '@xterm/xterm/css/xterm.css';
6
+ import { Badge, Button, type Tone } from '../../../components/ui';
4
7
 
5
- const RECONNECT_DELAY = 2000;
8
+ /** Retry delays after a drop; the last one repeats. */
9
+ const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000];
10
+ /**
11
+ * Handshakes that fail before ever opening before giving up — about 40 s with the delays
12
+ * above, longer than a Rev4a restart (`rev4a update`).
13
+ */
14
+ const MAX_FAILED_OPENS = 6;
15
+ /**
16
+ * Close codes from terminal-ws-server.js (and 1000, the page's own deliberate close) after
17
+ * which retrying cannot help: the reason is shown and the page waits for Reconnect, which
18
+ * starts a new shell.
19
+ */
20
+ const FINAL_CLOSE_CODES = new Set([1000, 4001, 4002, 4003, 4404, 4409, 4410, 4429, 4500]);
6
21
 
7
- // ── ANSI Color Map ──────────────────────────────────────────────
8
- const ANSI_FG: Record<number, string> = {
9
- 30: '#1d1d1d', 31: '#e06c75', 32: '#98c379', 33: '#d19a66',
10
- 34: '#61afef', 35: '#c678dd', 36: '#56b6c2', 37: '#abb2bf',
11
- 90: '#5c6370', 91: '#e06c75', 92: '#98c379', 93: '#d19a66',
12
- 94: '#61afef', 95: '#c678dd', 96: '#56b6c2', 97: '#ffffff',
22
+ type Status = 'connecting' | 'connected' | 'reconnecting' | 'closed';
23
+ const STATUS: Record<Status, { label: string; tone: Tone }> = {
24
+ connecting: { label: 'Connecting…', tone: 'info' },
25
+ connected: { label: 'Connected', tone: 'success' },
26
+ reconnecting: { label: 'Reconnecting…', tone: 'warning' },
27
+ closed: { label: 'Closed', tone: 'danger' },
13
28
  };
14
- const ANSI_BG: Record<number, string> = {
15
- 40: '#1d1d1d', 41: '#e06c75', 42: '#98c379', 43: '#d19a66',
16
- 44: '#61afef', 45: '#c678dd', 46: '#56b6c2', 47: '#abb2bf',
17
- 100: '#5c6370', 101: '#e06c75', 102: '#98c379', 103: '#d19a66',
18
- 104: '#61afef', 105: '#c678dd', 106: '#56b6c2', 107: '#ffffff',
19
- };
20
-
21
- type AnsiStyle = { fg?: string; bg?: string; bold?: boolean; dim?: boolean; italic?: boolean; underline?: boolean };
22
- type Segment = { text: string; style: AnsiStyle };
23
-
24
- // ── ANSI Parser ─────────────────────────────────────────────────
25
- // Parses text with ANSI escape sequences into {text, style} segments.
26
- // Handles: SGR (Select Graphic Rendition) — colors, bold, dim, italic, underline, reset.
27
- // Strips: bracketed paste, OSC, non-SGR sequences, bell, CR.
28
- function parseAnsi(text: string): Segment[] {
29
- const segments: Segment[] = [];
30
- const current: AnsiStyle = {};
31
- let i = 0;
32
-
33
- while (i < text.length) {
34
- // Check for ESC
35
- if (text[i] === '\x1b' && i + 1 < text.length) {
36
- if (text[i + 1] === '[') {
37
- // CSI sequence: \x1b[ ... <final byte>
38
- const start = i + 2;
39
- const end = text.indexOf('m', start); // SGR ends with 'm'
40
- if (end !== -1) {
41
- const params = text.slice(start, end).split(';');
42
- for (const p of params) {
43
- const code = parseInt(p, 10);
44
- if (isNaN(code)) continue;
45
- if (code === 0) {
46
- // Reset all
47
- Object.keys(current).forEach(k => delete (current as any)[k]);
48
- } else if (code === 1) current.bold = true;
49
- else if (code === 2) current.dim = true;
50
- else if (code === 3) current.italic = true;
51
- else if (code === 4) current.underline = true;
52
- else if (code === 22) { current.bold = false; current.dim = false; }
53
- else if (code === 23) current.italic = false;
54
- else if (code === 24) current.underline = false;
55
- else if (code >= 30 && code <= 37) { current.fg = ANSI_FG[code]; }
56
- else if (code >= 90 && code <= 97) { current.fg = ANSI_FG[code]; }
57
- else if (code === 39) delete current.fg;
58
- else if (code >= 40 && code <= 47) { current.bg = ANSI_BG[code]; }
59
- else if (code >= 100 && code <= 107) { current.bg = ANSI_BG[code]; }
60
- else if (code === 49) delete current.bg;
61
- }
62
- i = end + 1;
63
- continue;
64
- }
65
- // CSI not ending with 'm' — strip whole sequence
66
- const endByte = text.slice(i + 2).search(/[@-~]/);
67
- if (endByte !== -1) {
68
- i += 2 + endByte + 1;
69
- continue;
70
- }
71
- } else if (text[i + 1] === ']') {
72
- // OSC sequence: strip until ST (\x1b\\ or \x07)
73
- const st = text.indexOf('\x1b\\', i);
74
- const bell = text.indexOf('\x07', i);
75
- const endPos = st !== -1 ? st + 2 : (bell !== -1 ? bell + 1 : -1);
76
- if (endPos !== -1) { i = endPos; continue; }
77
- } else {
78
- // Any other ESC sequence: strip 2 chars
79
- i += 2;
80
- continue;
81
- }
82
- }
83
29
 
84
- // Bell
85
- if (text[i] === '\x07') { i++; continue; }
86
- // CR
87
- if (text[i] === '\r') { i++; continue; }
30
+ /**
31
+ * A random UUID v4. `crypto.randomUUID()` exists only in a secure context (HTTPS or
32
+ * localhost) and a plain-HTTP dashboard (a VPS by IP) is not one; `getRandomValues` is
33
+ * available everywhere.
34
+ */
35
+ function uuid(): string {
36
+ const b = crypto.getRandomValues(new Uint8Array(16));
37
+ b[6] = (b[6] & 0x0f) | 0x40;
38
+ b[8] = (b[8] & 0x3f) | 0x80;
39
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
40
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
41
+ }
88
42
 
89
- // Collect visible characters until next ESC or EOL
90
- let j = i;
91
- while (j < text.length && text[j] !== '\x1b' && text[j] !== '\x07' && text[j] !== '\r') {
92
- j++;
93
- }
94
- if (j > i) {
95
- segments.push({ text: text.slice(i, j), style: { ...current } });
96
- }
97
- i = j;
43
+ /** The session id for this container in this tab: a reload resumes the same shell. */
44
+ function sessionKey(containerId: string) {
45
+ return `rev4a-terminal-session:${containerId}`;
46
+ }
47
+ function readSession(containerId: string): string {
48
+ try {
49
+ const stored = sessionStorage.getItem(sessionKey(containerId));
50
+ if (stored) return stored;
51
+ const fresh = uuid();
52
+ sessionStorage.setItem(sessionKey(containerId), fresh);
53
+ return fresh;
54
+ } catch {
55
+ return uuid();
98
56
  }
99
-
100
- return segments;
101
57
  }
102
-
103
- // ── Segment to inline style ─────────────────────────────────────
104
- function segmentStyle(s: AnsiStyle): React.CSSProperties {
105
- const style: React.CSSProperties = {};
106
- if (s.fg) style.color = s.fg;
107
- if (s.bg) style.backgroundColor = s.bg;
108
- if (s.bold) style.fontWeight = 700;
109
- if (s.dim) style.opacity = 0.6;
110
- if (s.italic) style.fontStyle = 'italic';
111
- if (s.underline) style.textDecoration = 'underline';
112
- return style;
58
+ function forgetSession(containerId: string) {
59
+ try { sessionStorage.removeItem(sessionKey(containerId)); } catch { /* storage blocked */ }
113
60
  }
114
61
 
115
- // ── Render text as segments (cached via useMemo) ────────────────
116
- function RenderAnsi({ text }: { text: string }) {
117
- const segments = useMemo(() => parseAnsi(text), [text]);
118
- return (
119
- <>
120
- {segments.map((seg, i) => (
121
- <span key={i} style={segmentStyle(seg.style)}>{seg.text}</span>
122
- ))}
123
- </>
124
- );
62
+ /** The terminal palette from the design tokens, so it follows globals.css. */
63
+ function themeFromTokens() {
64
+ const css = getComputedStyle(document.documentElement);
65
+ const v = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
66
+ return {
67
+ background: v('--bg', '#0A0A0B'),
68
+ foreground: v('--text', '#E8E8E8'),
69
+ cursor: v('--violet', '#925BFC'),
70
+ cursorAccent: v('--bg', '#0A0A0B'),
71
+ selectionBackground: 'rgba(146, 91, 252, 0.35)',
72
+ };
125
73
  }
126
74
 
127
- // ── Terminal Client Component ───────────────────────────────────
75
+ /**
76
+ * The container terminal: xterm.js (a full terminal emulator — raw keystrokes, cursor
77
+ * movement, full-screen programs) over the WebSocket that server.js forwards to
78
+ * terminal-ws-server.js. The shell survives a dropped connection for two minutes and a
79
+ * reconnect with the same session id picks it up; see docs/CONTAINER-TERMINAL.md.
80
+ */
128
81
  export default function TerminalClient({ containerId }: { containerId: string }) {
129
- const outputRef = useRef<HTMLDivElement>(null);
130
- const inputRef = useRef<HTMLTextAreaElement>(null);
82
+ const router = useRouter();
83
+ const hostRef = useRef<HTMLDivElement>(null);
131
84
  const wsRef = useRef<WebSocket | null>(null);
132
- const cmdHistoryRef = useRef<string[]>([]);
133
- const histIdxRef = useRef(-1);
134
- const cmdBufferRef = useRef('');
135
- const pendingCmdRef = useRef('');
136
- const [status, setStatus] = useState('Connecting...');
137
- const [buffer, setBuffer] = useState(''); // raw PTY output (kept for segment rendering)
138
- const [currentInput, setCurrentInput] = useState('');
139
-
140
- function appendOutput(raw: string) {
141
- if (!raw) return;
142
- pendingCmdRef.current = '';
143
- setBuffer(prev => {
144
- const next = prev + raw;
145
- return next.length > 200000 ? next.slice(-200000) : next;
146
- });
147
- }
85
+ const [status, setStatus] = useState<Status>('connecting');
86
+ /** Why the terminal stopped retrying; null while it connects or retries. */
87
+ const [stopped, setStopped] = useState<string | null>(null);
88
+ /** Bumped by Reconnect: a new shell. */
89
+ const [generation, setGeneration] = useState(0);
148
90
 
149
91
  useEffect(() => {
150
- if (outputRef.current) {
151
- outputRef.current.scrollTop = outputRef.current.scrollHeight;
152
- }
153
- }, [buffer]);
92
+ const host = hostRef.current;
93
+ if (!host) return;
94
+ let disposed = false;
95
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
96
+ let resizeTimer: ReturnType<typeof setTimeout> | null = null;
97
+ let observer: ResizeObserver | null = null;
98
+ let cleanupTerminal = () => {};
99
+ setStopped(null);
100
+ setStatus('connecting');
154
101
 
155
- function sendCommand(cmd: string) {
156
- if (!cmd.trim() && !cmd) return;
157
- cmdHistoryRef.current.push(cmd);
158
- histIdxRef.current = cmdHistoryRef.current.length;
159
- cmdBufferRef.current = '';
160
- pendingCmdRef.current = cmd;
161
- if (wsRef.current?.readyState === WebSocket.OPEN) {
162
- wsRef.current.send(cmd + '\n');
163
- }
164
- }
102
+ (async () => {
103
+ // xterm.js touches `window` at import: load it in the browser only.
104
+ const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
105
+ import('@xterm/xterm'),
106
+ import('@xterm/addon-fit'),
107
+ import('@xterm/addon-web-links'),
108
+ ]);
109
+ if (disposed) return;
165
110
 
166
- function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
167
- const val = e.currentTarget.value;
168
- if (e.key === 'Enter' && !e.shiftKey) {
169
- e.preventDefault();
170
- setCurrentInput('');
171
- sendCommand(val);
172
- } else if (e.key === 'ArrowUp') { e.preventDefault();
173
- if (!cmdHistoryRef.current.length) return;
174
- if (histIdxRef.current === cmdHistoryRef.current.length) cmdBufferRef.current = val;
175
- histIdxRef.current = Math.max(0, histIdxRef.current - 1);
176
- setCurrentInput(cmdHistoryRef.current[histIdxRef.current]);
177
- } else if (e.key === 'ArrowDown') { e.preventDefault();
178
- if (histIdxRef.current >= cmdHistoryRef.current.length - 1) {
179
- histIdxRef.current = cmdHistoryRef.current.length;
180
- setCurrentInput(cmdBufferRef.current);
181
- } else { histIdxRef.current++; setCurrentInput(cmdHistoryRef.current[histIdxRef.current]); }
182
- } else if (e.key === 'Tab') { e.preventDefault();
183
- if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send('\t');
184
- } else if (e.key === 'c' && e.ctrlKey) { e.preventDefault();
185
- setCurrentInput('');
186
- if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send('\x03');
187
- } else if (e.key === 'l' && e.ctrlKey) { e.preventDefault();
188
- setBuffer(''); setCurrentInput('');
189
- } else if (e.key === 'd' && e.ctrlKey) { e.preventDefault();
190
- if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send('\x04');
191
- } else if (e.key === 'u' && e.ctrlKey) { e.preventDefault(); setCurrentInput(''); }
192
- }
111
+ const term = new Terminal({
112
+ cursorBlink: true,
113
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
114
+ fontSize: 13,
115
+ lineHeight: 1.2,
116
+ scrollback: 5000,
117
+ macOptionIsMeta: true,
118
+ theme: themeFromTokens(),
119
+ });
120
+ const fit = new FitAddon();
121
+ term.loadAddon(fit);
122
+ term.loadAddon(new WebLinksAddon());
123
+ term.open(host);
124
+ fit.fit();
125
+ term.focus();
193
126
 
194
- function handlePaste(e: React.ClipboardEvent) {
195
- const pasted = e.clipboardData.getData('text');
196
- if (pasted.includes('\n')) {
197
- e.preventDefault();
198
- for (const line of pasted.trim().split('\n')) if (line.trim()) sendCommand(line.trim());
199
- }
200
- }
127
+ const note = (text: string) => term.write(`\r\n\x1b[2m[${text}]\x1b[0m\r\n`);
128
+ const sendSize = () => {
129
+ const ws = wsRef.current;
130
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
131
+ };
132
+ const send = (data: string) => {
133
+ const ws = wsRef.current;
134
+ if (ws?.readyState === WebSocket.OPEN) ws.send(data);
135
+ };
136
+ const inputSub = term.onData(send);
137
+ // Legacy (X10) mouse reports come as binary strings; the PTY takes them as text.
138
+ const binarySub = term.onBinary(send);
139
+ const resizeSub = term.onResize(() => sendSize());
140
+ observer = new ResizeObserver(() => {
141
+ if (resizeTimer) clearTimeout(resizeTimer);
142
+ resizeTimer = setTimeout(() => { try { fit.fit(); } catch { /* hidden */ } }, 100);
143
+ });
144
+ observer.observe(host);
201
145
 
202
- useEffect(() => {
203
- let mounted = true;
204
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
146
+ const session = readSession(containerId);
147
+ let drops = 0;
148
+ let failedOpens = 0;
149
+ /** A shell was attached on this page load: later reconnects resume it. */
150
+ let hadShell = false;
205
151
 
206
- function connect() {
207
- if (!mounted) return;
208
- setStatus('Connecting...');
209
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
210
- const ws = new WebSocket(
211
- `${protocol}//${window.location.host}/api/terminal-ws?id=${encodeURIComponent(containerId)}`
212
- );
213
- ws.onopen = () => {
214
- if (!mounted) { ws.close(); return; }
215
- setStatus('Connected'); inputRef.current?.focus();
216
- appendOutput(`[Connected] ${containerId}\n`);
217
- };
218
- ws.onmessage = (event) => {
219
- if (!mounted) return;
220
- const text = typeof event.data === 'string' ? event.data : new TextDecoder().decode(new Uint8Array(event.data));
221
- appendOutput(text);
152
+ const connect = () => {
153
+ if (disposed) return;
154
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
155
+ // After a drop the page asks to *resume*: if the shell is gone the server says so
156
+ // (4002) instead of silently starting a new one under the same id.
157
+ const resume = drops > 0 && hadShell ? '&resume=1' : '';
158
+ const ws = new WebSocket(
159
+ `${protocol}//${window.location.host}/api/terminal-ws?id=${encodeURIComponent(containerId)}&session=${session}${resume}`,
160
+ );
161
+ let opened = false;
162
+ wsRef.current = ws;
163
+ ws.onopen = () => {
164
+ if (disposed) { ws.close(1000); return; }
165
+ opened = true;
166
+ hadShell = true;
167
+ if (drops) note('reconnected');
168
+ drops = 0;
169
+ failedOpens = 0;
170
+ setStatus('connected');
171
+ sendSize();
172
+ term.focus();
173
+ };
174
+ ws.onmessage = (event) => {
175
+ if (typeof event.data === 'string') term.write(event.data); // the server sends text only
176
+ };
177
+ ws.onclose = (event) => {
178
+ if (disposed || wsRef.current !== ws) return;
179
+ if (FINAL_CLOSE_CODES.has(event.code)) {
180
+ forgetSession(containerId);
181
+ setStatus('closed');
182
+ setStopped(event.reason || 'The terminal session ended.');
183
+ if (opened) note(event.reason || 'session ended');
184
+ return;
185
+ }
186
+ if (!opened && ++failedOpens >= MAX_FAILED_OPENS) {
187
+ setStatus('closed');
188
+ setStopped('Cannot open the terminal. Check that you are still logged in and that Rev4a is running; behind a reverse proxy, that it forwards WebSocket upgrades and keeps the Host header.');
189
+ return;
190
+ }
191
+ if (opened) note('connection lost — reconnecting');
192
+ const delay = RECONNECT_DELAYS_MS[Math.min(drops, RECONNECT_DELAYS_MS.length - 1)];
193
+ drops++;
194
+ setStatus('reconnecting');
195
+ reconnectTimer = setTimeout(connect, delay);
196
+ };
222
197
  };
223
- ws.onclose = () => {
224
- if (!mounted) return;
225
- setStatus('Disconnected');
226
- appendOutput('\n[Disconnected]\n');
227
- reconnectTimer = setTimeout(() => { if (mounted) connect(); }, RECONNECT_DELAY);
198
+ connect();
199
+
200
+ cleanupTerminal = () => {
201
+ inputSub.dispose();
202
+ binarySub.dispose();
203
+ resizeSub.dispose();
204
+ term.dispose();
228
205
  };
229
- wsRef.current = ws;
230
- }
231
- connect();
232
- setTimeout(() => inputRef.current?.focus(), 300);
206
+ })();
207
+
233
208
  return () => {
234
- mounted = false;
209
+ disposed = true;
235
210
  if (reconnectTimer) clearTimeout(reconnectTimer);
236
- if (wsRef.current) { wsRef.current.close(); wsRef.current = null; }
211
+ if (resizeTimer) clearTimeout(resizeTimer);
212
+ observer?.disconnect();
213
+ const ws = wsRef.current;
214
+ wsRef.current = null;
215
+ // Leaving the page is not a deliberate close: the shell waits two minutes for a reload.
216
+ ws?.close(4000, 'page left');
217
+ cleanupTerminal();
237
218
  };
238
- }, [containerId]);
219
+ }, [containerId, generation]);
239
220
 
240
- function handleOutputClick() { inputRef.current?.focus(); }
221
+ function handleClose() {
222
+ // A deliberate close ends the shell at once (code 1000) instead of keeping it for a return.
223
+ const ws = wsRef.current;
224
+ wsRef.current = null;
225
+ if (ws?.readyState === WebSocket.OPEN) {
226
+ ws.close(1000, 'closed');
227
+ } else {
228
+ // Not connected right now (reconnecting): attach briefly to the waiting shell only to end
229
+ // it, so it does not linger for two minutes.
230
+ ws?.close(4000, 'page left');
231
+ const session = readSession(containerId);
232
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
233
+ const ender = new WebSocket(
234
+ `${protocol}//${window.location.host}/api/terminal-ws?id=${encodeURIComponent(containerId)}&session=${session}&resume=1`,
235
+ );
236
+ ender.onopen = () => ender.close(1000, 'closed');
237
+ }
238
+ forgetSession(containerId);
239
+ router.push('/containers');
240
+ }
241
241
 
242
- const lines = useMemo(() => buffer.split('\n'), [buffer]);
243
- const lastLine = lines[lines.length - 1] || '';
244
- const showInput = pendingCmdRef.current ? pendingCmdRef.current : currentInput;
242
+ function handleReconnect() {
243
+ forgetSession(containerId);
244
+ setGeneration((g) => g + 1);
245
+ }
245
246
 
247
+ const s = STATUS[status];
246
248
  return (
247
- <div style={{
248
- position: 'fixed', top: 0, left: 0,
249
- width: '100vw', height: '100dvh',
250
- background: '#0a0a0b', display: 'flex', flexDirection: 'column',
251
- zIndex: 9999,
252
- fontFamily: 'Menlo, Monaco, "Courier New", monospace',
253
- fontSize: 13, lineHeight: 1.5, color: '#e0e0e0',
254
- }}>
255
- {/* Top bar */}
256
- <div style={{
257
- height: 44, minHeight: 44, background: '#141416',
258
- borderBottom: '1px solid #222',
259
- display: 'flex', alignItems: 'center', justifyContent: 'space-between',
260
- padding: '0 16px', color: '#888', fontSize: 12, flexShrink: 0,
261
- }}>
262
- <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
263
- <span style={{ color: '#925BFC', letterSpacing: '4px', fontSize: 14, fontFamily: 'Instrument Serif, serif' }}>TERMINAL</span>
264
- <span style={{ color: status === 'Connected' ? '#22c55e' : '#ef4444' }}>●</span>
265
- <span style={{ color: '#ccc' }}>{containerId}</span>
266
- <span style={{ color: '#555', fontSize: 11 }}>{status}</span>
249
+ <div className="terminal-page">
250
+ <div className="terminal-page__bar">
251
+ <div className="terminal-page__title">
252
+ <span className="ui-kicker ui-kicker--accent">Terminal</span>
253
+ <span className="terminal-page__container">{containerId}</span>
254
+ <Badge tone={s.tone}>{s.label}</Badge>
267
255
  </div>
268
- <a href="/containers" style={{
269
- color: '#888', textDecoration: 'none', border: '1px solid #333',
270
- borderRadius: 4, padding: '3px 10px', fontSize: 11, cursor: 'pointer',
271
- }}>CLOSE</a>
272
- </div>
273
-
274
- {/* Scrollable output area */}
275
- <div ref={outputRef} onClick={handleOutputClick} style={{
276
- flex: 1, overflow: 'auto', padding: '8px 12px 0',
277
- whiteSpace: 'pre-wrap', wordBreak: 'break-all',
278
- cursor: 'text', userSelect: 'text', WebkitUserSelect: 'text',
279
- }}>
280
- {lines.length > 1 && (
281
- <div>
282
- {lines.slice(0, -1).map((line, i) => (
283
- <div key={i} style={{ minHeight: '1.5em', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
284
- <RenderAnsi text={line} />
285
- </div>
286
- ))}
287
- </div>
288
- )}
289
- {/* Last line + cursor inline */}
290
- <div style={{ display: 'flex', minHeight: '1.5em' }}>
291
- <span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
292
- <RenderAnsi text={lastLine} />
293
- {showInput}
294
- <span style={{
295
- display: 'inline-block', width: 8, height: '1.1em',
296
- background: '#e0e0e0',
297
- animation: 'blink 1s step-end infinite',
298
- verticalAlign: 'text-bottom', marginLeft: 1,
299
- }} />
300
- </span>
256
+ <div className="terminal-page__actions">
257
+ {stopped && (
258
+ <Button type="button" variant="secondary" size="sm" onClick={handleReconnect}>Reconnect</Button>
259
+ )}
260
+ <Button type="button" variant="ghost" size="sm" onClick={handleClose}>Close</Button>
301
261
  </div>
302
262
  </div>
303
-
304
- {/* Hidden textarea */}
305
- <textarea ref={inputRef} value={currentInput}
306
- onChange={e => setCurrentInput(e.target.value)}
307
- onKeyDown={handleKeyDown} onPaste={handlePaste}
308
- autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false}
309
- style={{ position: 'fixed', top: '-9999px', left: '-9999px', width: 1, height: 1, opacity: 0 }}
310
- />
263
+ {stopped && <div role="status" className="terminal-page__notice">{stopped}</div>}
264
+ <div ref={hostRef} className="terminal-page__screen" />
311
265
  </div>
312
266
  );
313
267
  }
@@ -0,0 +1,20 @@
1
+ import { Skeleton } from '../../../components/Skeleton';
2
+
3
+ /**
4
+ * The terminal while the route loads: its own full-screen frame (the `terminal-page`
5
+ * bar and dark screen), not the Containers list's rows — the page is a terminal, and it
6
+ * already shows "Connecting…" itself once it renders.
7
+ */
8
+ export default function TerminalLoading() {
9
+ return (
10
+ <div className="terminal-page" aria-busy="true">
11
+ <div className="terminal-page__bar">
12
+ <div className="terminal-page__title">
13
+ <span className="ui-kicker ui-kicker--accent">Terminal</span>
14
+ <Skeleton width={120} height={12} />
15
+ </div>
16
+ </div>
17
+ <div className="terminal-page__screen" />
18
+ </div>
19
+ );
20
+ }
@@ -1,10 +1,5 @@
1
- import { Skeleton, CardRowSkeleton } from '../components/Skeleton';
1
+ import { ListPageSkeleton } from '../components/Skeleton';
2
2
 
3
- const PageSkeleton = () => (
4
- <div style={{ padding: '24px 20px', maxWidth: 1080, margin: '0 auto' }}>
5
- <Skeleton width={120} height={20} accent style={{ marginBottom: 20 }} />
6
- <CardRowSkeleton count={5} height={48} />
7
- </div>
8
- );
9
-
10
- export default PageSkeleton;
3
+ export default function CronsLoading() {
4
+ return <ListPageSkeleton />;
5
+ }
@@ -1,12 +1,8 @@
1
- import { Skeleton } from '../components/Skeleton';
1
+ import { RouteLoadingShell, Skeleton } from '../components/Skeleton';
2
2
 
3
3
  export default function GatewayLoading() {
4
4
  return (
5
- <div style={{
6
- padding: '24px 20px',
7
- maxWidth: 1000,
8
- margin: '0 auto',
9
- }}>
5
+ <RouteLoadingShell maxWidth={1000}>
10
6
  <Skeleton width={80} height={18} accent style={{ marginBottom: 8 }} />
11
7
  <Skeleton width={200} height={28} style={{ marginBottom: 20 }} />
12
8
  <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 20 }}>
@@ -19,6 +15,6 @@ export default function GatewayLoading() {
19
15
  <Skeleton key={i} width={`${100 - i * 10}%`} height={16} />
20
16
  ))}
21
17
  </div>
22
- </div>
18
+ </RouteLoadingShell>
23
19
  );
24
20
  }
package/app/globals.css CHANGED
@@ -667,6 +667,16 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
667
667
  .ui-chart__table th, .ui-chart__table td { text-align: left; padding: 3px 6px; border-bottom: 1px solid var(--border); font-weight: 400; }
668
668
  .ui-chart__table th { color: var(--text-dim); }
669
669
 
670
+ /* Container terminal: full screen, a bar on top, xterm.js below (it owns its own scrolling). */
671
+ .terminal-page { position: fixed; inset: 0; height: 100dvh; z-index: 9999; display: flex; flex-direction: column; background: var(--bg); color: var(--text); }
672
+ .terminal-page__bar { flex-shrink: 0; min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 16px; background: var(--bg2); border-bottom: 1px solid var(--border); flex-wrap: wrap; }
673
+ .terminal-page__title { display: flex; align-items: center; gap: 12px; min-width: 0; }
674
+ .terminal-page__container { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
675
+ .terminal-page__actions { display: flex; gap: 8px; }
676
+ .terminal-page__notice { flex-shrink: 0; padding: 8px 16px; font-size: 12px; color: var(--yellow); border-bottom: 1px solid var(--border); }
677
+ .terminal-page__screen { flex: 1; min-height: 0; padding: 8px 0 0 12px; overflow: hidden; }
678
+ .terminal-page__screen .xterm { height: 100%; }
679
+
670
680
  /* System page: three cards, then one chart per metric; one column below lg (992px). */
671
681
  .system-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
672
682
  .system-charts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
@@ -1,10 +1,5 @@
1
- import { Skeleton, CardRowSkeleton } from '../components/Skeleton';
1
+ import { ListPageSkeleton } from '../components/Skeleton';
2
2
 
3
- const PageSkeleton = () => (
4
- <div style={{ padding: '24px 20px', maxWidth: 1080, margin: '0 auto' }}>
5
- <Skeleton width={120} height={20} accent style={{ marginBottom: 20 }} />
6
- <CardRowSkeleton count={5} height={48} />
7
- </div>
8
- );
9
-
10
- export default PageSkeleton;
3
+ export default function LineageLoading() {
4
+ return <ListPageSkeleton />;
5
+ }