feat: add interactive terminal support for containers via WebSocket
All checks were successful
Build and Push Docker Images / docker (push) Successful in 30s

- Implemented WebSocket endpoint for executing interactive shells in Docker containers.
- Added ticket-based authentication for terminal access to enhance security.
- Updated frontend to support terminal interactions, including lazy loading of terminal components.
- Enhanced backend to manage terminal session tickets and settings for enabling/disabling terminal access.
- Updated Nginx configuration to support WebSocket connections for terminal sessions.
- Added necessary dependencies for terminal functionality in the frontend.
- Improved user interface to include terminal access controls and feedback on terminal status.
This commit is contained in:
jeanotx32
2026-08-01 01:49:26 -04:00
parent f37b639226
commit e277e99155
15 changed files with 732 additions and 24 deletions

View File

@@ -103,6 +103,20 @@ export default function AdminPage({ onBack }) {
}
}
const toggleTerminal = async () => {
if (!settings) return
const newValue = settings.terminal_enabled === 'false' ? 'true' : 'false'
setToggleLoading(true)
try {
await setAdminSetting('terminal_enabled', newValue)
setSettings(prev => ({ ...prev, terminal_enabled: newValue }))
} catch (err) {
setSettingsError(err.message)
} finally {
setToggleLoading(false)
}
}
// ─── Notifications (Pushover) ────────────────────────────────────────────
const [pushoverToken, setPushoverToken] = useState('')
const [pushoverUserKey, setPushoverUserKey] = useState('')
@@ -411,6 +425,13 @@ export default function AdminPage({ onBack }) {
onChange={togglePasskeys}
loading={toggleLoading}
/>
<ToggleRow
label="Terminal des conteneurs"
description="Permet aux administrateurs d'ouvrir un shell dans un conteneur (agent 1.3.0+)."
enabled={settings?.terminal_enabled !== 'false'}
onChange={toggleTerminal}
loading={toggleLoading}
/>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { Play, Square, RotateCcw, FileText, Loader2, Heart } from 'lucide-react'
import { Play, Square, RotateCcw, FileText, Loader2, Heart, TerminalSquare } from 'lucide-react'
import StatusBadge from './StatusBadge'
const HEALTH_STYLES = {
@@ -28,7 +28,7 @@ function hostPorts(ports) {
return [...found].sort((a, b) => Number(a) - Number(b))
}
export default function ContainerRow({ container, onAction, onLogs }) {
export default function ContainerRow({ container, onAction, onLogs, onTerminal, execDisabledReason }) {
const [pending, setPending] = useState(null)
const isRunning = container.status === 'running'
const ports = useMemo(() => hostPorts(container.ports), [container.ports])
@@ -91,6 +91,15 @@ export default function ContainerRow({ container, onAction, onLogs }) {
<ActionBtn title={`Redémarrer ${container.name}`} onClick={() => handle('restart')} loading={pending === 'restart'}>
<RotateCcw size={13} />
</ActionBtn>
{onTerminal && isRunning && (
<ActionBtn
title={execDisabledReason ?? `Ouvrir un terminal dans ${container.name}`}
onClick={onTerminal}
disabled={!!execDisabledReason}
>
<TerminalSquare size={13} />
</ActionBtn>
)}
<ActionBtn title={`Logs de ${container.name}`} onClick={onLogs}>
<FileText size={13} />
</ActionBtn>
@@ -99,13 +108,13 @@ export default function ContainerRow({ container, onAction, onLogs }) {
)
}
function ActionBtn({ children, onClick, title, danger = false, loading = false }) {
function ActionBtn({ children, onClick, title, danger = false, loading = false, disabled = false }) {
return (
<button
onClick={onClick}
title={title}
aria-label={title}
disabled={loading}
disabled={loading || disabled}
className={`p-1.5 rounded transition-colors disabled:opacity-40 ${
danger
? 'hover:bg-red-500/20 text-gray-500 hover:text-red-400'

View File

@@ -0,0 +1,175 @@
import { useEffect, useRef, useState } from 'react'
import { TerminalSquare, RotateCcw } from 'lucide-react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import Modal from './ui/Modal'
import { Button } from './ui/controls'
import { requestExecTicket, execSocketUrl } from '../api/client'
const THEME = {
background: '#030712',
foreground: '#e5e7eb',
cursor: '#818cf8',
cursorAccent: '#030712',
selectionBackground: '#312e81',
black: '#1f2937', red: '#f87171', green: '#34d399', yellow: '#fbbf24',
blue: '#60a5fa', magenta: '#c084fc', cyan: '#22d3ee', white: '#e5e7eb',
brightBlack: '#4b5563', brightRed: '#fca5a5', brightGreen: '#6ee7b7',
brightYellow:'#fcd34d', brightBlue: '#93c5fd', brightMagenta:'#d8b4fe',
brightCyan: '#67e8f9', brightWhite: '#f9fafb',
}
const STATUS = {
connecting: { label: 'Connexion…', dot: 'bg-yellow-400 animate-pulse', text: 'text-yellow-400' },
open: { label: 'Connecté', dot: 'bg-emerald-400', text: 'text-emerald-400' },
closed: { label: 'Session terminée', dot: 'bg-gray-500', text: 'text-gray-500' },
error: { label: 'Erreur', dot: 'bg-red-400', text: 'text-red-400' },
}
export default function TerminalModal({ vps, container, onClose }) {
const hostRef = useRef(null)
const [status, setStatus] = useState('connecting')
const [attempt, setAttempt] = useState(0) // incrémenté pour relancer la session
useEffect(() => {
const term = new Terminal({
theme: THEME,
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontSize: 13,
cursorBlink: true,
scrollback: 5000,
convertEol: false,
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(hostRef.current)
term.focus()
let socket = null
let cancelled = false
// Ajuster la grille demande que le nœud ait déjà une taille et que le moteur
// de rendu de xterm soit initialisé : on passe donc toujours par une frame.
const safeFit = () => {
const host = hostRef.current
if (cancelled || !host || host.clientWidth === 0 || host.clientHeight === 0) return
try { fit.fit() } catch { /* rendu pas encore prêt */ }
}
requestAnimationFrame(safeFit)
const observer = new ResizeObserver(() => requestAnimationFrame(safeFit))
observer.observe(hostRef.current)
const send = (payload) => {
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(payload))
}
const dataSub = term.onData(data => send({ t: 'i', d: data }))
const resizeSub = term.onResize(({ cols, rows }) => send({ t: 'r', cols, rows }))
;(async () => {
setStatus('connecting')
try {
const { ticket } = await requestExecTicket(vps.id, container.id)
if (cancelled) return
socket = new WebSocket(execSocketUrl(vps.id, container.id, ticket, term.cols, term.rows))
socket.binaryType = 'arraybuffer'
socket.onopen = () => {
setStatus('open')
send({ t: 'r', cols: term.cols, rows: term.rows })
term.focus()
}
socket.onmessage = (event) => {
// Binaire = sortie brute du TTY ; texte = message de contrôle JSON.
if (typeof event.data !== 'string') {
term.write(new Uint8Array(event.data))
return
}
try {
const message = JSON.parse(event.data)
if (message.t === 'error') {
setStatus('error')
term.writeln(`\r\n\x1b[31m${message.m}\x1b[0m`)
} else if (message.t === 'exit') {
term.writeln(`\r\n\x1b[90m— shell terminé (code ${message.code ?? '?'})\x1b[0m`)
}
} catch { /* message non JSON : ignoré */ }
}
socket.onerror = () => {
if (!cancelled) setStatus(current => (current === 'open' ? current : 'error'))
}
socket.onclose = () => {
if (cancelled) return
setStatus(current => (current === 'error' ? current : 'closed'))
term.writeln('\r\n\x1b[90m— connexion fermée\x1b[0m')
}
} catch (e) {
if (cancelled) return
setStatus('error')
term.writeln(`\r\n\x1b[31mImpossible d'ouvrir le terminal : ${e.message}\x1b[0m`)
}
})()
return () => {
cancelled = true
observer.disconnect()
dataSub.dispose()
resizeSub.dispose()
socket?.close()
// xterm garde ses propres callbacks de redimensionnement en file : les
// laisser s'exécuter sur une instance vivante, sinon ils lèvent une erreur
// sur un cœur déjà libéré (visible au double montage de StrictMode).
requestAnimationFrame(() => requestAnimationFrame(() => term.dispose()))
}
}, [vps.id, container.id, attempt])
const state = STATUS[status] ?? STATUS.connecting
return (
<Modal
size="xl"
title={`${vps.name} / ${container.name}`}
subtitle={container.image}
icon={<TerminalSquare size={16} className="text-indigo-400 flex-shrink-0" />}
onClose={onClose}
// Le shell a besoin d'Échap (vim) et de Tab (complétion) : la modale les
// laisse passer et se ferme via la croix ou le bouton du pied de page.
closeOnEscape={false}
trapTab={false}
bodyClassName="flex flex-col overflow-hidden p-0"
headerRight={
<span className={`hidden sm:flex items-center gap-1.5 text-xs ${state.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${state.dot}`} />
{state.label}
</span>
}
footer={
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-gray-600">
Échap et Tab sont transmis au shell fermez avec .
</p>
<div className="flex items-center gap-2">
{(status === 'closed' || status === 'error') && (
<Button variant="secondary" size="sm" icon={RotateCcw} onClick={() => setAttempt(a => a + 1)}>
Reconnecter
</Button>
)}
<Button variant="outline" size="sm" onClick={onClose}>Fermer</Button>
</div>
</div>
}
>
<div
ref={hostRef}
className="h-[60vh] bg-gray-950 px-3 py-2"
aria-label={`Terminal du conteneur ${container.name}`}
/>
</Modal>
)
}

View File

@@ -5,6 +5,14 @@ import { tagColor } from './TagInput'
import { IconButton, ProgressBar } from './ui/controls'
import { formatBps, formatRam, loadColor, loadBarColor } from '../lib/format'
/** Le terminal interactif n'existe qu'à partir de l'agent 1.3.0. */
function agentSupportsExec(version) {
if (!version || version === 'unknown') return true // version inconnue : on laisse tenter
const [major, minor] = version.split('.').map(n => parseInt(n, 10))
if (Number.isNaN(major)) return true
return major > 1 || (major === 1 && (minor || 0) >= 3)
}
/** Métrique système avec barre de progression (CPU, RAM). */
function Metric({ icon: Icon, label, percent: rawPercent, detail }) {
const percent = Number.isFinite(rawPercent) ? rawPercent : 0
@@ -23,7 +31,7 @@ function Metric({ icon: Icon, label, percent: rawPercent, detail }) {
)
}
export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport }) {
export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport, onTerminal }) {
const storageKey = `vps:${vps.id}:collapsed`
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(storageKey) === '1')
const [updatingProject, setUpdatingProject] = useState(null)
@@ -294,6 +302,12 @@ export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, o
container={c}
onAction={(action) => onAction(vps.id, c.id, action)}
onLogs={() => onLogs(vps.id, c.id, `${vps.name} / ${c.name}`)}
onTerminal={onTerminal ? () => onTerminal(vps, c) : undefined}
execDisabledReason={
agentSupportsExec(vps.agent_version)
? undefined
: `Terminal indisponible : agent v${vps.agent_version} — mettez à jour vers v1.3.0 ou plus.`
}
/>
))}
</>

View File

@@ -33,15 +33,20 @@ export default function Modal({
children,
bodyClassName = 'overflow-y-auto p-4',
panelClassName = '',
// Le terminal a besoin de Tab (complétion) et d'Échap (vim) : il désactive
// ces deux raccourcis et se ferme via la croix.
trapTab = true,
closeOnEscape = true,
}) {
const panelRef = useRef(null)
const onCloseRef = useRef(onClose)
const titleId = useId()
const panelRef = useRef(null)
const titleId = useId()
// `onClose` est souvent une lambda recréée à chaque rendu du parent : on la
// lit via une ref pour que l'effet ne se rejoue pas (sinon le focus sauterait
// au premier champ à chaque rafraîchissement automatique).
useEffect(() => { onCloseRef.current = onClose })
// `onClose` est souvent une lambda recréée à chaque rendu du parent : le
// gestionnaire de touches lit ces valeurs via une ref pour que l'effet ne se
// rejoue pas (sinon le focus sauterait au premier champ à chaque
// rafraîchissement automatique).
const handlersRef = useRef({ onClose, trapTab, closeOnEscape })
useEffect(() => { handlersRef.current = { onClose, trapTab, closeOnEscape } })
useEffect(() => {
const previouslyFocused = document.activeElement
@@ -50,12 +55,12 @@ export default function Modal({
;(firstFocusable ?? panel)?.focus({ preventScroll: true })
const onKeyDown = (e) => {
if (e.key === 'Escape') {
if (e.key === 'Escape' && handlersRef.current.closeOnEscape) {
e.stopPropagation()
onCloseRef.current?.()
handlersRef.current.onClose?.()
return
}
if (e.key !== 'Tab' || !panel) return
if (e.key !== 'Tab' || !handlersRef.current.trapTab || !panel) return
const items = [...panel.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null)
if (items.length === 0) return