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

@@ -1,3 +1,10 @@
# Bascule l'en-tête Connection selon qu'il s'agit d'un WebSocket ou non —
# nécessaire pour le terminal des conteneurs, qui passe par /api/….
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
root /usr/share/nginx/html;
@@ -8,6 +15,16 @@ server {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Relais WebSocket (terminal interactif)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Un terminal ouvert peut rester inactif longtemps sans être coupé
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
# SPA fallback

View File

@@ -498,6 +498,21 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -900,6 +915,21 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",

View File

@@ -8,6 +8,8 @@
"name": "vps-monitor-frontend",
"version": "1.0.0",
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"lucide-react": "^0.396.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -1224,6 +1226,21 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",

View File

@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"lucide-react": "^0.396.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react'
import { ServerCrash, SearchX, Plus, RefreshCw } from 'lucide-react'
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent, exportVps } from './api/client'
import Header from './components/Header'
@@ -16,6 +16,9 @@ import { useToast } from './components/ui/Toast'
import { Button, EmptyState, Skeleton } from './components/ui/controls'
import { copyText, downloadText } from './lib/clipboard'
// xterm.js pèse ~300 kB : chargé seulement à l'ouverture d'un terminal.
const TerminalModal = lazy(() => import('./components/TerminalModal'))
const INTERVAL_OPTIONS = [
{ label: '10 s', value: 10_000 },
{ label: '30 s', value: 30_000 },
@@ -60,6 +63,7 @@ export default function App() {
const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin'
const [isFirstUser, setIsFirstUser] = useState(false)
const [passkeyEnabled, setPasskeyEnabled] = useState(false)
const [terminalEnabled, setTerminalEnabled] = useState(true)
const [authChecked, setAuthChecked] = useState(false)
const [vpsList, setVpsList] = useState([])
@@ -117,13 +121,15 @@ export default function App() {
const [updateLoading, setUpdateLoading] = useState(false)
const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName }
const [terminalModal, setTerminalModal] = useState(null) // { vps, container }
// Vérifie si des utilisateurs existent (pour afficher login ou register)
useEffect(() => {
authStatus()
.then(({ has_users, passkey_enabled }) => {
.then(({ has_users, passkey_enabled, terminal_enabled }) => {
setIsFirstUser(!has_users)
setPasskeyEnabled(!!passkey_enabled)
setTerminalEnabled(terminal_enabled !== false)
})
.catch(() => setIsFirstUser(false))
.finally(() => setAuthChecked(true))
@@ -200,7 +206,7 @@ export default function App() {
}, [token, username])
// Raccourcis clavier : « / » cible la recherche, « r » actualise.
const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget)
const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget || terminalModal)
useEffect(() => {
if (!token || page !== 'main' || modalOpen) return
const onKeyDown = (e) => {
@@ -369,6 +375,10 @@ export default function App() {
)
}
// Le terminal donne un shell root dans le conteneur : réservé aux admins, et
// désactivable globalement depuis la page d'administration.
const canUseTerminal = role === 'admin' && terminalEnabled
// Statistiques globales
const totalOnline = vpsList.filter(v => v.online).length
const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0)
@@ -511,6 +521,7 @@ export default function App() {
onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })}
onUpdateAgent={handleUpdateAgent}
onExport={handleExportVps}
onTerminal={canUseTerminal ? (target, containerRow) => setTerminalModal({ vps: target, container: containerRow }) : undefined}
/>
))}
</div>
@@ -563,6 +574,21 @@ export default function App() {
/>
)}
{/* Terminal interactif */}
{terminalModal && (
<Suspense fallback={
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm text-sm text-gray-400">
Chargement du terminal
</div>
}>
<TerminalModal
vps={terminalModal.vps}
container={terminalModal.container}
onClose={() => setTerminalModal(null)}
/>
</Suspense>
)}
{/* Confirmation de suppression */}
{deleteTarget && (
<ConfirmDialog

View File

@@ -127,6 +127,27 @@ export async function fetchVpsStats(vpsId, duration = 600) {
return handleResponse(res)
}
// ─── Terminal conteneur ───────────────────────────────────────────────────────
/** Échange le JWT contre un ticket à usage unique (60 s) pour ouvrir le terminal. */
export async function requestExecTicket(vpsId, containerId) {
const res = await fetch(`${BASE}/vps/${vpsId}/containers/${containerId}/exec/ticket`, {
method: 'POST',
headers: authHeaders(),
})
return handleResponse(res)
}
/**
* URL du WebSocket de terminal. Le token n'y figure jamais : seul un ticket
* jetable transite en query string.
*/
export function execSocketUrl(vpsId, containerId, ticket, cols, rows) {
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
const params = new URLSearchParams({ ticket, cols: String(cols), rows: String(rows) })
return `${scheme}://${window.location.host}${BASE}/vps/${vpsId}/containers/${containerId}/exec?${params}`
}
// ─── Profile ──────────────────────────────────────────────────────────────────
export async function changePassword(oldPassword, newPassword) {

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

View File

@@ -5,7 +5,8 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000',
// `ws: true` : le terminal des conteneurs passe par un WebSocket sur /api
'/api': { target: 'http://localhost:8000', ws: true },
},
},
})