Backend Go 1.23+ : - API REST + WebSocket (chi, gorilla/websocket) - Authentification PAM via SSH + JWT RS256 - Chiffrement AES-256-GCM pour secrets SQLite - Pool SSH, client Proxmox REST, hub WebSocket pub/sub - Système de modules compilés à initialisation conditionnelle - Audit log, migrations SQLite versionnées Frontend Vue 3 + Vite + TypeScript : - Thème Neumorphism sombre/clair (CSS custom properties) - Wizard d'installation, Dashboard drag-drop, Terminal xterm.js - Toutes les vues CORE + stubs modules optionnels - i18n EN/FR (vue-i18n v11) Infrastructure : - Docker multi-stage (Go → alpine, Node → nginx) - docker-compose.yml, .gitattributes, LICENSE MIT, README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
162 lines
4.4 KiB
Vue
162 lines
4.4 KiB
Vue
<template>
|
|
<div class="terminal-page">
|
|
<div class="page-header flex items-center justify-between">
|
|
<h2>{{ t('nav.terminal') }}</h2>
|
|
<div class="flex gap-sm">
|
|
<input v-model="customHost" class="neu-input" placeholder="host:port (défaut: config)" style="width:200px" />
|
|
<button class="neu-btn neu-btn--primary" @click="reconnect">
|
|
{{ connected ? t('terminal.reconnect') : t('terminal.connect') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="neu-card terminal-container">
|
|
<div class="terminal-status flex items-center gap-sm">
|
|
<span :class="['ws-dot', connected ? 'ws-dot--connected' : 'ws-dot--disconnected']" />
|
|
<span class="text-muted" style="font-size:11px">
|
|
{{ connected ? t('terminal.connected', { host: currentHost }) : t('terminal.disconnected') }}
|
|
</span>
|
|
</div>
|
|
<!-- Conteneur xterm.js -->
|
|
<div ref="terminalContainer" class="terminal-xterm" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { Terminal } from '@xterm/xterm'
|
|
import { FitAddon } from '@xterm/addon-fit'
|
|
import { useAuthStore } from '@/stores/auth.store'
|
|
import '@xterm/xterm/css/xterm.css'
|
|
|
|
const { t } = useI18n()
|
|
const authStore = useAuthStore()
|
|
|
|
const terminalContainer = ref<HTMLElement | null>(null)
|
|
const customHost = ref('')
|
|
const connected = ref(false)
|
|
const currentHost = ref('')
|
|
|
|
let terminal: Terminal | null = null
|
|
let fitAddon: FitAddon | null = null
|
|
let ws: WebSocket | null = null
|
|
|
|
onMounted(() => {
|
|
// Initialiser xterm.js
|
|
terminal = new Terminal({
|
|
theme: {
|
|
background: 'var(--neu-bg, #1a1d2e)',
|
|
foreground: '#e2e6f6',
|
|
cursor: '#6c8ef4',
|
|
},
|
|
fontFamily: '"Courier New", Courier, monospace',
|
|
fontSize: 13,
|
|
cursorBlink: true,
|
|
scrollback: 1000,
|
|
})
|
|
|
|
fitAddon = new FitAddon()
|
|
terminal.loadAddon(fitAddon)
|
|
terminal.open(terminalContainer.value!)
|
|
fitAddon.fit()
|
|
|
|
// Observer le redimensionnement
|
|
const ro = new ResizeObserver(() => fitAddon?.fit())
|
|
if (terminalContainer.value) ro.observe(terminalContainer.value)
|
|
|
|
// Connexion automatique
|
|
connect()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
ws?.close()
|
|
terminal?.dispose()
|
|
})
|
|
|
|
function connect() {
|
|
ws?.close()
|
|
|
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
const hostParam = customHost.value ? `&host=${encodeURIComponent(customHost.value)}` : ''
|
|
const url = `${proto}//${window.location.host}/ws/terminal?token=${authStore.accessToken}${hostParam}`
|
|
|
|
ws = new WebSocket(url)
|
|
ws.binaryType = 'arraybuffer'
|
|
|
|
ws.onopen = () => {
|
|
connected.value = true
|
|
currentHost.value = customHost.value || 'ssh_host configuré'
|
|
terminal?.write('\r\n\x1b[32mConnecté au terminal SSH\x1b[0m\r\n\r\n')
|
|
}
|
|
|
|
ws.onclose = () => {
|
|
connected.value = false
|
|
terminal?.write('\r\n\x1b[31mDéconnecté\x1b[0m\r\n')
|
|
}
|
|
|
|
ws.onmessage = (event) => {
|
|
if (event.data instanceof ArrayBuffer) {
|
|
terminal?.write(new Uint8Array(event.data))
|
|
} else {
|
|
terminal?.write(event.data)
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
terminal?.write('\r\n\x1b[31mErreur de connexion WebSocket\x1b[0m\r\n')
|
|
}
|
|
|
|
// Envoyer les frappes clavier au serveur SSH
|
|
terminal?.onData((data) => {
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(data)
|
|
}
|
|
})
|
|
|
|
// Envoyer le resize au serveur
|
|
terminal?.onResize(({ cols, rows }) => {
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
|
}
|
|
})
|
|
}
|
|
|
|
function reconnect() {
|
|
terminal?.clear()
|
|
connect()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.terminal-page { height: 100%; display: flex; flex-direction: column; max-width: 1200px; }
|
|
|
|
.page-header { margin-bottom: var(--neu-space-lg); flex-shrink: 0; }
|
|
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
|
.text-muted { color: var(--neu-text-muted); }
|
|
|
|
.terminal-container {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
padding: var(--neu-space-sm);
|
|
}
|
|
|
|
.terminal-status {
|
|
padding: var(--neu-space-xs) var(--neu-space-sm);
|
|
border-bottom: 1px solid var(--neu-border);
|
|
margin-bottom: var(--neu-space-sm);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.terminal-xterm {
|
|
flex: 1;
|
|
min-height: 400px;
|
|
}
|
|
|
|
.ws-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
.ws-dot--connected { background: var(--neu-success); animation: neu-pulse 2s infinite; }
|
|
.ws-dot--disconnected { background: var(--neu-text-muted); }
|
|
</style>
|