feat: add interactive terminal support for containers via WebSocket
All checks were successful
Build and Push Docker Images / docker (push) Successful in 30s
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
175
vps-monitor/frontend/src/components/TerminalModal.jsx
Normal file
175
vps-monitor/frontend/src/components/TerminalModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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.`
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user