- Rename app title, header, tagline, footer, terminal tab to RootNode Academy - Apply RootNode color palette (#39FF14 green, #00D4FF blue, GitHub-style darks) - Add rootnode-runaway-monitor scenario (homelab NOC incident: oversized memory request) - Add rootnode-real-ops bundle in terminal-green accent - Update package name to rootnode-academy-ui
178 lines
6.6 KiB
React
178 lines
6.6 KiB
React
import { useEffect, useRef, useCallback } from 'react'
|
|
import { Terminal } from '@xterm/xterm'
|
|
import { FitAddon } from '@xterm/addon-fit'
|
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
import '@xterm/xterm/css/xterm.css'
|
|
import styles from './Terminal.module.css'
|
|
|
|
// ── xterm.js themes ──────────────────────────────────────────────────────────
|
|
const TERM_THEMES = {
|
|
dark: {
|
|
background: '#0d1117',
|
|
foreground: '#e6edf3',
|
|
cursor: '#58a6ff',
|
|
cursorAccent: '#0d1117',
|
|
selectionBackground:'#264f78',
|
|
black: '#0d1117', brightBlack: '#6e7681',
|
|
red: '#ff7b72', brightRed: '#ffa198',
|
|
green: '#3fb950', brightGreen: '#56d364',
|
|
yellow: '#d29922', brightYellow: '#e3b341',
|
|
blue: '#58a6ff', brightBlue: '#79c0ff',
|
|
magenta: '#bc8cff', brightMagenta: '#d2a8ff',
|
|
cyan: '#39c5cf', brightCyan: '#56d4dd',
|
|
white: '#e6edf3', brightWhite: '#ffffff',
|
|
},
|
|
light: {
|
|
background: '#f6f8fa',
|
|
foreground: '#1f2328',
|
|
cursor: '#0969da',
|
|
cursorAccent: '#f6f8fa',
|
|
selectionBackground:'rgba(84,174,255,0.35)',
|
|
black: '#24292f', brightBlack: '#57606a',
|
|
red: '#cf222e', brightRed: '#a40e26',
|
|
green: '#116329', brightGreen: '#1a7f37',
|
|
yellow: '#633c01', brightYellow: '#7d4e00',
|
|
blue: '#0969da', brightBlue: '#218bff',
|
|
magenta: '#8250df', brightMagenta: '#a475f9',
|
|
cyan: '#1b7c83', brightCyan: '#3192aa',
|
|
white: '#6e7781', brightWhite: '#8c959f',
|
|
},
|
|
}
|
|
|
|
function getCurrentTheme() {
|
|
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'
|
|
}
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
export default function TerminalComponent({ collapsed, onToggleCollapse }) {
|
|
const containerRef = useRef(null)
|
|
const termRef = useRef(null)
|
|
const fitRef = useRef(null)
|
|
const wsRef = useRef(null)
|
|
|
|
const fit = useCallback(() => {
|
|
const fitAddon = fitRef.current
|
|
const term = termRef.current
|
|
if (!fitAddon || !term) return
|
|
if (!containerRef.current || containerRef.current.offsetHeight === 0) return
|
|
try {
|
|
fitAddon.fit()
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
|
}
|
|
} catch {}
|
|
}, [])
|
|
|
|
const connect = useCallback(() => {
|
|
const term = termRef.current
|
|
if (!term) return
|
|
if (wsRef.current) { wsRef.current.onclose = null; wsRef.current.close() }
|
|
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
const ws = new WebSocket(`${proto}//${location.host}/shell-ws`)
|
|
wsRef.current = ws
|
|
|
|
ws.onopen = () => { term.clear(); fit() }
|
|
ws.onmessage = (e) => { term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data)) }
|
|
ws.onclose = () => { term.write('\r\n\x1b[33m[Disconnected — click Reconnect]\x1b[0m\r\n') }
|
|
ws.onerror = () => { term.write('\r\n\x1b[31m[WebSocket error]\x1b[0m\r\n') }
|
|
}, [fit])
|
|
|
|
// Re-fit one frame after expand so the CSS height transition has settled
|
|
useEffect(() => {
|
|
if (!collapsed) {
|
|
const id = requestAnimationFrame(() => fit())
|
|
return () => cancelAnimationFrame(id)
|
|
}
|
|
}, [collapsed, fit])
|
|
|
|
// Mount terminal
|
|
useEffect(() => {
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 14,
|
|
fontFamily: '"Cascadia Code", "Fira Code", Menlo, Monaco, "Courier New", monospace',
|
|
theme: TERM_THEMES[getCurrentTheme()],
|
|
scrollback: 5000,
|
|
allowTransparency: false,
|
|
})
|
|
|
|
const fitAddon = new FitAddon()
|
|
term.loadAddon(fitAddon)
|
|
term.loadAddon(new WebLinksAddon())
|
|
term.open(containerRef.current)
|
|
|
|
requestAnimationFrame(() => fitAddon.fit())
|
|
|
|
termRef.current = term
|
|
fitRef.current = fitAddon
|
|
|
|
term.onData((data) => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data)
|
|
})
|
|
|
|
connect()
|
|
|
|
window.addEventListener('resize', fit)
|
|
const ro = new ResizeObserver(() => fit())
|
|
if (containerRef.current) ro.observe(containerRef.current)
|
|
|
|
// Watch <html data-theme> and update xterm theme live
|
|
const mo = new MutationObserver((mutations) => {
|
|
for (const m of mutations) {
|
|
if (m.attributeName === 'data-theme') {
|
|
term.options.theme = TERM_THEMES[getCurrentTheme()]
|
|
}
|
|
}
|
|
})
|
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', fit)
|
|
ro.disconnect()
|
|
mo.disconnect()
|
|
wsRef.current?.close()
|
|
term.dispose()
|
|
}
|
|
}, [connect, fit])
|
|
|
|
return (
|
|
<div className={styles.wrap}>
|
|
<div className={styles.bar}>
|
|
<div className={styles.barLeft}>
|
|
<div className={styles.dots}>
|
|
<span className={styles.dot} style={{background:'#ff5f56'}} />
|
|
<span className={styles.dot} style={{background:'#ffbd2e'}} />
|
|
<span className={styles.dot} style={{background:'#27c93f'}} />
|
|
</div>
|
|
<span className={styles.barTitle}>
|
|
<span className={styles.barIcon}>$_</span>
|
|
bash — rootnode
|
|
</span>
|
|
</div>
|
|
<div className={styles.barRight}>
|
|
<button className={styles.barBtn} onClick={connect} title="Reconnect terminal">
|
|
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 5, verticalAlign: 'middle' }}>
|
|
<path d="M23 4v6h-6" />
|
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
|
</svg>
|
|
Reconnect
|
|
</button>
|
|
<button className={styles.barBtn} onClick={onToggleCollapse} title={collapsed ? 'Expand terminal' : 'Collapse terminal'}>
|
|
{collapsed ? '▲' : '▼'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/*
|
|
xtermOuter is position:relative so the absolutely-positioned xterm
|
|
mount point (containerRef) fills it exactly — canonical xterm.js pattern.
|
|
The div stays in the DOM even when collapsed so the PTY session lives.
|
|
*/}
|
|
<div className={styles.xtermOuter} style={collapsed ? { height: 0 } : undefined}>
|
|
<div ref={containerRef} className={styles.terminal} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|