Add comprehensive SSH terminal drawer feature with embedded and popout modes, integrate playbook lint results API with local cache fallback, and enhance host management UI with terminal access buttons
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
CRUD operations for terminal sessions.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, update, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.terminal_session import TerminalSession
|
||||
|
||||
|
||||
class TerminalSessionRepository:
|
||||
"""Repository for TerminalSession CRUD operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
id: str,
|
||||
host_id: str,
|
||||
host_name: str,
|
||||
host_ip: str,
|
||||
token_hash: str,
|
||||
ttyd_port: int,
|
||||
expires_at: datetime,
|
||||
user_id: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
ttyd_pid: Optional[int] = None,
|
||||
mode: str = "embedded",
|
||||
) -> TerminalSession:
|
||||
"""Create a new terminal session."""
|
||||
session = TerminalSession(
|
||||
id=id,
|
||||
host_id=host_id,
|
||||
host_name=host_name,
|
||||
host_ip=host_ip,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
token_hash=token_hash,
|
||||
ttyd_port=ttyd_port,
|
||||
ttyd_pid=ttyd_pid,
|
||||
mode=mode,
|
||||
status="active",
|
||||
expires_at=expires_at,
|
||||
)
|
||||
self.session.add(session)
|
||||
await self.session.flush()
|
||||
return session
|
||||
|
||||
async def get(self, session_id: str) -> Optional[TerminalSession]:
|
||||
"""Get a terminal session by ID."""
|
||||
result = await self.session.execute(
|
||||
select(TerminalSession).where(TerminalSession.id == session_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_active_by_id(self, session_id: str) -> Optional[TerminalSession]:
|
||||
"""Get an active terminal session by ID."""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await self.session.execute(
|
||||
select(TerminalSession).where(
|
||||
and_(
|
||||
TerminalSession.id == session_id,
|
||||
TerminalSession.status == "active",
|
||||
TerminalSession.expires_at > now
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_active_for_user(self, user_id: str) -> List[TerminalSession]:
|
||||
"""List all active sessions for a user."""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await self.session.execute(
|
||||
select(TerminalSession).where(
|
||||
and_(
|
||||
TerminalSession.user_id == user_id,
|
||||
TerminalSession.status == "active",
|
||||
TerminalSession.expires_at > now
|
||||
)
|
||||
).order_by(TerminalSession.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count_active_for_user(self, user_id: str) -> int:
|
||||
"""Count active sessions for a user."""
|
||||
sessions = await self.list_active_for_user(user_id)
|
||||
return len(sessions)
|
||||
|
||||
async def list_expired(self) -> List[TerminalSession]:
|
||||
"""List all expired but not yet closed sessions."""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await self.session.execute(
|
||||
select(TerminalSession).where(
|
||||
and_(
|
||||
TerminalSession.status == "active",
|
||||
TerminalSession.expires_at <= now
|
||||
)
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
session_id: str,
|
||||
status: str,
|
||||
closed_at: Optional[datetime] = None
|
||||
) -> Optional[TerminalSession]:
|
||||
"""Update session status."""
|
||||
session = await self.get(session_id)
|
||||
if session:
|
||||
session.status = status
|
||||
if closed_at:
|
||||
session.closed_at = closed_at
|
||||
await self.session.flush()
|
||||
return session
|
||||
|
||||
async def close_session(self, session_id: str) -> Optional[TerminalSession]:
|
||||
"""Close a terminal session."""
|
||||
return await self.update_status(
|
||||
session_id,
|
||||
status="closed",
|
||||
closed_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
async def mark_expired(self, session_id: str) -> Optional[TerminalSession]:
|
||||
"""Mark a session as expired."""
|
||||
return await self.update_status(
|
||||
session_id,
|
||||
status="expired",
|
||||
closed_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
async def cleanup_old_sessions(self, days: int = 7) -> int:
|
||||
"""Delete sessions older than specified days."""
|
||||
from datetime import timedelta
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
result = await self.session.execute(
|
||||
select(TerminalSession).where(
|
||||
and_(
|
||||
TerminalSession.status.in_(["closed", "expired", "error"]),
|
||||
TerminalSession.created_at < cutoff
|
||||
)
|
||||
)
|
||||
)
|
||||
sessions = result.scalars().all()
|
||||
count = len(sessions)
|
||||
|
||||
for session in sessions:
|
||||
await self.session.delete(session)
|
||||
|
||||
await self.session.flush()
|
||||
return count
|
||||
|
||||
async def close_all_active(self) -> int:
|
||||
"""Close all active sessions (for cleanup/reset)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await self.session.execute(
|
||||
select(TerminalSession).where(TerminalSession.status == "active")
|
||||
)
|
||||
sessions = list(result.scalars().all())
|
||||
count = len(sessions)
|
||||
|
||||
for session in sessions:
|
||||
session.status = "closed"
|
||||
session.closed_at = now
|
||||
|
||||
await self.session.flush()
|
||||
return count
|
||||
+199
@@ -2967,6 +2967,205 @@
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
Terminal SSH Drawer Styles
|
||||
===================================================== */
|
||||
.terminal-drawer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.terminal-drawer.open {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.terminal-drawer-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.terminal-drawer-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 50vw;
|
||||
min-width: 400px;
|
||||
max-width: 900px;
|
||||
background: #0f0f1a;
|
||||
border-left: 1px solid #374151;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.terminal-drawer.open .terminal-drawer-panel {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.terminal-drawer-header {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #1a1a2e;
|
||||
border-bottom: 1px solid #374151;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-host-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.terminal-host-name {
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.terminal-host-ip {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.terminal-status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.terminal-status-badge.online {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.terminal-status-badge.connecting {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.terminal-header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.terminal-btn-secondary {
|
||||
background: #374151;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.terminal-btn-secondary:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.terminal-btn-danger {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.terminal-btn-danger:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.terminal-drawer-body {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.terminal-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.terminal-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0f0f1a;
|
||||
}
|
||||
|
||||
.terminal-loading .spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid #374151;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.terminal-loading .loading-text {
|
||||
margin-top: 1rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.terminal-drawer-footer {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #1a1a2e;
|
||||
border-top: 1px solid #374151;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-timer {
|
||||
margin-left: auto;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.terminal-timer.warning {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.terminal-timer.expired {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.terminal-drawer-panel {
|
||||
width: 100vw;
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
}
|
||||
|
||||
.terminal-host-ip {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+390
-18
@@ -84,6 +84,11 @@ class DashboardManager {
|
||||
|
||||
// WebSocket
|
||||
this.ws = null;
|
||||
|
||||
// Terminal SSH
|
||||
this.terminalSession = null;
|
||||
this.terminalDrawerOpen = false;
|
||||
this.terminalFeatureAvailable = false;
|
||||
|
||||
// Configuration: collecte métriques
|
||||
this.metricsCollectionInterval = 'off';
|
||||
@@ -465,6 +470,9 @@ class DashboardManager {
|
||||
schedules: this.schedules.length
|
||||
});
|
||||
|
||||
// Charger les résultats de lint depuis l'API
|
||||
await this.loadPlaybookLintResults();
|
||||
|
||||
// Mettre à jour l'affichage
|
||||
this.renderHosts();
|
||||
this.renderTasks();
|
||||
@@ -1035,6 +1043,24 @@ class DashboardManager {
|
||||
// Initialiser le calendrier de filtrage des tâches
|
||||
this.setupTaskDateCalendar();
|
||||
|
||||
// Event delegation for terminal buttons (avoids inline onclick issues)
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action="terminal"], [data-action="terminal-popout"]');
|
||||
if (!btn || btn.disabled) return;
|
||||
|
||||
e.stopPropagation();
|
||||
const action = btn.dataset.action;
|
||||
const hostId = btn.dataset.hostId;
|
||||
const hostName = btn.dataset.hostName;
|
||||
const hostIp = btn.dataset.hostIp;
|
||||
|
||||
if (action === 'terminal') {
|
||||
this.openTerminal(hostId, hostName, hostIp);
|
||||
} else if (action === 'terminal-popout') {
|
||||
this.openTerminalPopout(hostId, hostName, hostIp);
|
||||
}
|
||||
});
|
||||
|
||||
// Navigation est gérée par le script de navigation des pages dans index.html
|
||||
}
|
||||
|
||||
@@ -1576,6 +1602,24 @@ class DashboardManager {
|
||||
<button class="px-3 py-1.5 bg-teal-600 text-xs rounded-lg hover:bg-teal-500 transition-colors flex items-center" onclick="dashboard.showPlaybookModalForHost('${host.name}')" title="Exécuter un playbook sur cet hôte">
|
||||
<i class="fas fa-play-circle mr-1"></i>Playbook
|
||||
</button>
|
||||
<button class="px-3 py-1.5 ${bootstrapOk && host.status !== 'offline' ? 'bg-cyan-600 hover:bg-cyan-500' : 'bg-gray-600 cursor-not-allowed opacity-50'} text-xs rounded-lg transition-colors flex items-center btn-terminal"
|
||||
data-action="terminal"
|
||||
data-host-id="${host.id}"
|
||||
data-host-name="${this.escapeHtml(host.name)}"
|
||||
data-host-ip="${host.ip}"
|
||||
title="${!bootstrapOk ? 'Bootstrap requis pour SSH' : host.status === 'offline' ? 'Hôte hors ligne' : 'Ouvrir un terminal SSH'}"
|
||||
${!bootstrapOk || host.status === 'offline' ? 'disabled' : ''}>
|
||||
<i class="fas fa-terminal mr-1"></i>Terminal
|
||||
</button>
|
||||
<button class="px-3 py-1.5 ${bootstrapOk && host.status !== 'offline' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-gray-600 cursor-not-allowed opacity-50'} text-xs rounded-lg transition-colors flex items-center btn-terminal-popout"
|
||||
data-action="terminal-popout"
|
||||
data-host-id="${host.id}"
|
||||
data-host-name="${this.escapeHtml(host.name)}"
|
||||
data-host-ip="${host.ip}"
|
||||
title="${!bootstrapOk ? 'Bootstrap requis pour SSH' : host.status === 'offline' ? 'Hôte hors ligne' : 'Ouvrir terminal dans une nouvelle fenêtre'}"
|
||||
${!bootstrapOk || host.status === 'offline' ? 'disabled' : ''}>
|
||||
<i class="fas fa-external-link-alt mr-1"></i>Pop-out
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
// Append to all containers
|
||||
@@ -7319,11 +7363,58 @@ class DashboardManager {
|
||||
|
||||
// ===== GESTION DES PLAYBOOKS =====
|
||||
|
||||
// Cache des résultats lint (chargé depuis l'API backend)
|
||||
playbookLintResults = {};
|
||||
|
||||
async loadPlaybookLintResults() {
|
||||
/**
|
||||
* Charge tous les résultats de lint depuis l'API backend.
|
||||
* Appelé au démarrage et après chaque lint.
|
||||
*/
|
||||
try {
|
||||
const data = await this.apiCall('/api/playbooks/results');
|
||||
this.playbookLintResults = data?.results || {};
|
||||
console.log('[Dashboard] Loaded lint results for', Object.keys(this.playbookLintResults).length, 'playbooks');
|
||||
} catch (e) {
|
||||
console.warn('[Dashboard] Failed to load lint results from API:', e);
|
||||
// Fallback: essayer localStorage
|
||||
this.playbookLintResults = this.getPlaybookLintResultsFromLocalStorage();
|
||||
}
|
||||
}
|
||||
|
||||
getPlaybookLintStorageKey() {
|
||||
return 'playbookLintResults:v1';
|
||||
}
|
||||
|
||||
getPlaybookLintResultsFromLocalStorage() {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.getPlaybookLintStorageKey());
|
||||
if (!raw) return {};
|
||||
const map = JSON.parse(raw);
|
||||
// Convertir au format attendu (result -> direct)
|
||||
const converted = {};
|
||||
for (const [filename, entry] of Object.entries(map)) {
|
||||
if (entry?.result) {
|
||||
converted[filename] = {
|
||||
...entry.result,
|
||||
updated_at: entry.stored_at
|
||||
};
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
storePlaybookLintResult(filename, lintResult) {
|
||||
// Mettre à jour le cache local immédiatement
|
||||
this.playbookLintResults[filename] = {
|
||||
...lintResult,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Aussi sauvegarder en localStorage comme fallback
|
||||
try {
|
||||
const key = this.getPlaybookLintStorageKey();
|
||||
const raw = localStorage.getItem(key);
|
||||
@@ -7336,32 +7427,29 @@ class DashboardManager {
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(map));
|
||||
} catch (e) {
|
||||
console.warn('Failed to store lint result:', e);
|
||||
console.warn('Failed to store lint result in localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
getPlaybookLintResult(filename) {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.getPlaybookLintStorageKey());
|
||||
if (!raw) return null;
|
||||
const map = JSON.parse(raw);
|
||||
return map?.[filename] || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
// Utiliser le cache chargé depuis l'API
|
||||
const result = this.playbookLintResults?.[filename];
|
||||
if (result) {
|
||||
return { result, stored_at: result.updated_at };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async copyPlaybookLintToClipboard(filename) {
|
||||
const entry = this.getPlaybookLintResult(filename);
|
||||
if (!entry?.result) {
|
||||
const lintData = this.playbookLintResults?.[filename];
|
||||
if (!lintData) {
|
||||
this.showNotification('Aucun résultat lint à copier (exécutez Lint d\'abord)', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
filename,
|
||||
stored_at: entry.stored_at,
|
||||
...entry.result
|
||||
...lintData
|
||||
};
|
||||
|
||||
const text = JSON.stringify(payload, null, 2);
|
||||
@@ -7484,14 +7572,14 @@ class DashboardManager {
|
||||
// Calculer le temps relatif
|
||||
const modifiedAgo = playbook.modified ? this.getRelativeTime(playbook.modified) : 'Date inconnue';
|
||||
|
||||
const lintEntry = this.getPlaybookLintResult(playbook.filename);
|
||||
const lintResult = lintEntry?.result || null;
|
||||
const qualityScore = typeof lintResult?.quality_score === 'number' ? lintResult.quality_score : null;
|
||||
const lintSummary = lintResult?.summary || null;
|
||||
const lintTooltip = lintResult ? (
|
||||
// Récupérer le résultat lint depuis le cache (API backend ou localStorage)
|
||||
const lintData = this.playbookLintResults?.[playbook.filename] || null;
|
||||
const qualityScore = typeof lintData?.quality_score === 'number' ? lintData.quality_score : null;
|
||||
const lintSummary = lintData?.summary || null;
|
||||
const lintTooltip = lintData ? (
|
||||
`Quality: ${qualityScore}/100\n` +
|
||||
`Total: ${lintSummary?.total ?? 0} | Errors: ${lintSummary?.errors ?? 0} | Warnings: ${lintSummary?.warnings ?? 0}\n` +
|
||||
`Dernier lint: ${lintEntry?.stored_at ? new Date(lintEntry.stored_at).toLocaleString('fr-FR') : ''}`
|
||||
`Dernier lint: ${lintData?.updated_at ? new Date(lintData.updated_at).toLocaleString('fr-FR') : ''}`
|
||||
) : 'Aucun lint exécuté';
|
||||
|
||||
let lintBadgeHtml = '';
|
||||
@@ -10002,6 +10090,290 @@ class DashboardManager {
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// Terminal SSH - Web Terminal Feature
|
||||
// =====================================================
|
||||
|
||||
async checkTerminalFeatureStatus() {
|
||||
try {
|
||||
const data = await this.apiCall('/api/terminal/status');
|
||||
this.terminalFeatureAvailable = Boolean(data && data.available);
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.warn('Terminal feature check failed:', e);
|
||||
this.terminalFeatureAvailable = false;
|
||||
}
|
||||
return { available: false };
|
||||
}
|
||||
|
||||
async openTerminal(hostId, hostName, hostIp) {
|
||||
// Check if terminal feature is available
|
||||
if (!this.terminalFeatureAvailable) {
|
||||
const status = await this.checkTerminalFeatureStatus();
|
||||
if (!status.available) {
|
||||
this.showNotification('Terminal SSH non disponible: ttyd n\'est pas installé', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
this.showTerminalDrawer(hostName, hostIp, true);
|
||||
|
||||
try {
|
||||
const session = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode: 'embedded' })
|
||||
});
|
||||
this.terminalSession = session;
|
||||
|
||||
// Update drawer with terminal iframe
|
||||
this.updateTerminalDrawer(session);
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to open terminal:', e);
|
||||
this.showNotification(`Erreur terminal: ${e.message}`, 'error');
|
||||
this.closeTerminalDrawer();
|
||||
}
|
||||
}
|
||||
|
||||
async openTerminalPopout(hostId, hostName, hostIp) {
|
||||
// Check if terminal feature is available
|
||||
if (!this.terminalFeatureAvailable) {
|
||||
const status = await this.checkTerminalFeatureStatus();
|
||||
if (!status.available) {
|
||||
this.showNotification('Terminal SSH non disponible: ttyd n\'est pas installé', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.showNotification('Création de la session terminal...', 'info');
|
||||
|
||||
try {
|
||||
const session = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode: 'popout' })
|
||||
});
|
||||
|
||||
// Open in popup window
|
||||
const popupUrl = session.url;
|
||||
const popupFeatures = 'width=900,height=600,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no';
|
||||
const popup = window.open(popupUrl, `terminal_${session.session_id}`, popupFeatures);
|
||||
|
||||
if (!popup || popup.closed) {
|
||||
// Popup blocked - open in new tab instead
|
||||
window.open(popupUrl, '_blank');
|
||||
this.showNotification('Terminal ouvert dans un nouvel onglet (popup bloqué)', 'warning');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to open terminal popout:', e);
|
||||
this.showNotification(`Erreur terminal: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
showTerminalDrawer(hostName, hostIp, loading = false) {
|
||||
this.terminalDrawerOpen = true;
|
||||
|
||||
// Create drawer if it doesn't exist
|
||||
let drawer = document.getElementById('terminalDrawer');
|
||||
if (!drawer) {
|
||||
drawer = document.createElement('div');
|
||||
drawer.id = 'terminalDrawer';
|
||||
drawer.className = 'terminal-drawer';
|
||||
document.body.appendChild(drawer);
|
||||
}
|
||||
|
||||
const loadingContent = loading ? `
|
||||
<div class="terminal-loading">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text">Connexion à ${this.escapeHtml(hostName)}...</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
drawer.innerHTML = `
|
||||
<div class="terminal-drawer-backdrop" onclick="dashboard.closeTerminalDrawer()"></div>
|
||||
<div class="terminal-drawer-panel" onclick="event.stopPropagation()">
|
||||
<div class="terminal-drawer-header">
|
||||
<div class="terminal-host-info">
|
||||
<span class="terminal-host-name">${this.escapeHtml(hostName)}</span>
|
||||
<span class="terminal-host-ip">${this.escapeHtml(hostIp)}</span>
|
||||
<span class="terminal-status-badge online">Connexion...</span>
|
||||
</div>
|
||||
<div class="terminal-header-actions">
|
||||
<button class="terminal-btn terminal-btn-secondary" onclick="dashboard.openCurrentTerminalPopout()" title="Ouvrir dans une nouvelle fenêtre">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="terminal-btn terminal-btn-danger" onclick="dashboard.closeTerminalDrawer()" title="Fermer">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal-drawer-body" id="terminalDrawerBody">
|
||||
${loadingContent}
|
||||
</div>
|
||||
<div class="terminal-drawer-footer">
|
||||
<button class="terminal-btn terminal-btn-secondary" onclick="dashboard.copySSHCommand()">
|
||||
<i class="fas fa-copy mr-1"></i>Copier SSH
|
||||
</button>
|
||||
<button class="terminal-btn terminal-btn-secondary" onclick="dashboard.reconnectTerminal()">
|
||||
<i class="fas fa-redo mr-1"></i>Reconnecter
|
||||
</button>
|
||||
<span class="terminal-timer" id="terminalTimer"></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => {
|
||||
drawer.classList.add('open');
|
||||
});
|
||||
|
||||
// Add escape key handler
|
||||
this._terminalEscHandler = (e) => {
|
||||
if (e.key === 'Escape' && this.terminalDrawerOpen) {
|
||||
this.closeTerminalDrawer();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', this._terminalEscHandler);
|
||||
}
|
||||
|
||||
updateTerminalDrawer(session) {
|
||||
const body = document.getElementById('terminalDrawerBody');
|
||||
if (!body) return;
|
||||
|
||||
const statusBadge = document.querySelector('.terminal-status-badge');
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = 'Connecté';
|
||||
statusBadge.classList.remove('connecting');
|
||||
statusBadge.classList.add('online');
|
||||
}
|
||||
|
||||
// Create iframe for terminal
|
||||
body.innerHTML = `
|
||||
<iframe
|
||||
id="terminalIframe"
|
||||
src="${session.url}"
|
||||
class="terminal-iframe"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
onload="dashboard.onTerminalIframeLoad()"
|
||||
></iframe>
|
||||
`;
|
||||
|
||||
// Start countdown timer
|
||||
this.startTerminalTimer(session.ttl_seconds);
|
||||
}
|
||||
|
||||
onTerminalIframeLoad() {
|
||||
const iframe = document.getElementById('terminalIframe');
|
||||
if (iframe) {
|
||||
iframe.focus();
|
||||
}
|
||||
}
|
||||
|
||||
startTerminalTimer(seconds) {
|
||||
const timerEl = document.getElementById('terminalTimer');
|
||||
if (!timerEl) return;
|
||||
|
||||
let remaining = seconds;
|
||||
|
||||
const updateTimer = () => {
|
||||
if (remaining <= 0) {
|
||||
timerEl.textContent = 'Session expirée';
|
||||
timerEl.classList.add('expired');
|
||||
return;
|
||||
}
|
||||
|
||||
const mins = Math.floor(remaining / 60);
|
||||
const secs = remaining % 60;
|
||||
timerEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
|
||||
if (remaining < 60) {
|
||||
timerEl.classList.add('warning');
|
||||
}
|
||||
|
||||
remaining--;
|
||||
};
|
||||
|
||||
updateTimer();
|
||||
this._terminalTimerInterval = setInterval(updateTimer, 1000);
|
||||
}
|
||||
|
||||
async closeTerminalDrawer() {
|
||||
const drawer = document.getElementById('terminalDrawer');
|
||||
if (drawer) {
|
||||
drawer.classList.remove('open');
|
||||
setTimeout(() => {
|
||||
drawer.remove();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Clean up timer
|
||||
if (this._terminalTimerInterval) {
|
||||
clearInterval(this._terminalTimerInterval);
|
||||
this._terminalTimerInterval = null;
|
||||
}
|
||||
|
||||
// Remove escape handler
|
||||
if (this._terminalEscHandler) {
|
||||
document.removeEventListener('keydown', this._terminalEscHandler);
|
||||
this._terminalEscHandler = null;
|
||||
}
|
||||
|
||||
// Close session on server (best effort)
|
||||
if (this.terminalSession) {
|
||||
try {
|
||||
await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to close terminal session:', e);
|
||||
}
|
||||
this.terminalSession = null;
|
||||
}
|
||||
|
||||
this.terminalDrawerOpen = false;
|
||||
}
|
||||
|
||||
openCurrentTerminalPopout() {
|
||||
if (!this.terminalSession) {
|
||||
this.showNotification('Aucune session terminal active', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const popupUrl = this.terminalSession.url;
|
||||
const popupFeatures = 'width=900,height=600,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no';
|
||||
window.open(popupUrl, `terminal_${this.terminalSession.session_id}`, popupFeatures);
|
||||
}
|
||||
|
||||
copySSHCommand() {
|
||||
if (!this.terminalSession) {
|
||||
this.showNotification('Aucune session terminal active', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const cmd = `ssh automation@${this.terminalSession.host.ip}`;
|
||||
navigator.clipboard.writeText(cmd).then(() => {
|
||||
this.showNotification(`Commande copiée: ${cmd}`, 'success');
|
||||
}).catch(() => {
|
||||
this.showNotification('Impossible de copier dans le presse-papier', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
async reconnectTerminal() {
|
||||
if (!this.terminalSession) {
|
||||
this.showNotification('Aucune session terminal active', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const { host } = this.terminalSession;
|
||||
|
||||
// Close current session
|
||||
await this.closeTerminalDrawer();
|
||||
|
||||
// Reopen
|
||||
await this.openTerminal(host.id, host.name, host.ip);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard when DOM is loaded
|
||||
|
||||
@@ -13,9 +13,14 @@ from .docker_container import DockerContainer
|
||||
from .docker_image import DockerImage
|
||||
from .docker_volume import DockerVolume
|
||||
from .docker_alert import DockerAlert
|
||||
from .terminal_session import TerminalSession
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"get_db",
|
||||
"init_db",
|
||||
"async_session_maker",
|
||||
"engine",
|
||||
"Host",
|
||||
"BootstrapStatus",
|
||||
"Task",
|
||||
@@ -31,4 +36,5 @@ __all__ = [
|
||||
"DockerImage",
|
||||
"DockerVolume",
|
||||
"DockerAlert",
|
||||
"TerminalSession",
|
||||
]
|
||||
|
||||
@@ -124,6 +124,7 @@ async def init_db() -> None:
|
||||
docker_image,
|
||||
docker_volume,
|
||||
docker_alert,
|
||||
playbook_lint,
|
||||
) # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Model for storing ansible-lint results per playbook.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, String, Text, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class PlaybookLintResult(Base):
|
||||
"""Stores the latest ansible-lint result for each playbook."""
|
||||
|
||||
__tablename__ = "playbook_lint_results"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Playbook filename (unique constraint)
|
||||
filename: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
# Quality score (0-100)
|
||||
quality_score: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
|
||||
|
||||
# Summary counts
|
||||
total_issues: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
errors_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
warnings_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Execution time in milliseconds
|
||||
execution_time_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Full issues list as JSON
|
||||
issues_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Raw output from ansible-lint (for copy to clipboard)
|
||||
raw_output: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PlaybookLintResult(filename={self.filename}, score={self.quality_score})>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API response."""
|
||||
import json
|
||||
|
||||
issues = []
|
||||
if self.issues_json:
|
||||
try:
|
||||
issues = json.loads(self.issues_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
issues = []
|
||||
|
||||
return {
|
||||
"id": self.id,
|
||||
"filename": self.filename,
|
||||
"quality_score": self.quality_score,
|
||||
"summary": {
|
||||
"total": self.total_issues,
|
||||
"errors": self.errors_count,
|
||||
"warnings": self.warnings_count,
|
||||
},
|
||||
"issues": issues,
|
||||
"execution_time_ms": self.execution_time_ms,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Model for terminal sessions - stores SSH terminal session metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class TerminalSession(Base):
|
||||
"""
|
||||
Represents an active or recent SSH terminal session.
|
||||
|
||||
Sessions are created when a user opens a terminal to a host,
|
||||
and cleaned up after expiration or manual closure.
|
||||
"""
|
||||
__tablename__ = "terminal_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
host_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
host_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
host_ip: Mapped[str] = mapped_column(String, nullable=False)
|
||||
user_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
username: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
|
||||
# Token hash for session authentication (never store plain token)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
|
||||
# ttyd process management
|
||||
ttyd_port: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
ttyd_pid: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Session mode: 'embedded' or 'popout'
|
||||
mode: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'embedded'"))
|
||||
|
||||
# Session status: 'active', 'closed', 'expired', 'error'
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TerminalSession id={self.id} host={self.host_name} status={self.status}>"
|
||||
@@ -26,6 +26,7 @@ from app.routes.server import router as server_router
|
||||
from app.routes.alerts import router as alerts_router
|
||||
from app.routes.docker import router as docker_router
|
||||
from app.routes.lint import router as lint_router
|
||||
from app.routes.terminal import router as terminal_router
|
||||
|
||||
# Router principal qui agrège tous les sous-routers
|
||||
api_router = APIRouter()
|
||||
@@ -50,6 +51,7 @@ api_router.include_router(server_router, prefix="/server", tags=["Server"])
|
||||
api_router.include_router(alerts_router, prefix="/alerts", tags=["Alerts"])
|
||||
api_router.include_router(docker_router, prefix="/docker", tags=["Docker"])
|
||||
api_router.include_router(lint_router, prefix="/playbooks", tags=["Lint"])
|
||||
api_router.include_router(terminal_router, prefix="/terminal", tags=["Terminal"])
|
||||
|
||||
__all__ = [
|
||||
"api_router",
|
||||
|
||||
+83
-2
@@ -9,11 +9,15 @@ import re
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import verify_api_key
|
||||
from app.models.database import get_db
|
||||
from app.models.playbook_lint import PlaybookLintResult
|
||||
from app.schemas.lint import (
|
||||
LintRequest,
|
||||
LintResponse,
|
||||
@@ -272,7 +276,8 @@ def parse_ansible_lint_text(stdout: str, original_filename: str) -> list[LintIss
|
||||
async def lint_playbook(
|
||||
filename: str,
|
||||
request: LintRequest,
|
||||
api_key_valid: bool = Depends(verify_api_key)
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Exécute ansible-lint sur le contenu d'un playbook.
|
||||
@@ -384,6 +389,43 @@ async def lint_playbook(
|
||||
info=sum(1 for i in issues if i.severity == "info"),
|
||||
)
|
||||
|
||||
# Sauvegarder le résultat en base de données
|
||||
try:
|
||||
# Chercher un résultat existant pour ce playbook
|
||||
stmt = select(PlaybookLintResult).where(PlaybookLintResult.filename == filename)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
# Sérialiser les issues en JSON
|
||||
issues_json = json.dumps([i.model_dump() for i in issues])
|
||||
|
||||
if existing:
|
||||
# Mettre à jour
|
||||
existing.quality_score = quality_score
|
||||
existing.total_issues = summary.total
|
||||
existing.errors_count = summary.errors
|
||||
existing.warnings_count = summary.warnings
|
||||
existing.execution_time_ms = execution_time_ms
|
||||
existing.issues_json = issues_json
|
||||
else:
|
||||
# Créer nouveau
|
||||
lint_result = PlaybookLintResult(
|
||||
filename=filename,
|
||||
quality_score=quality_score,
|
||||
total_issues=summary.total,
|
||||
errors_count=summary.errors,
|
||||
warnings_count=summary.warnings,
|
||||
execution_time_ms=execution_time_ms,
|
||||
issues_json=issues_json,
|
||||
)
|
||||
db.add(lint_result)
|
||||
|
||||
await db.commit()
|
||||
logger.debug("Lint result saved for %s", filename)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save lint result: %s", e)
|
||||
# Ne pas faire échouer la requête si la sauvegarde échoue
|
||||
|
||||
return LintResponse(
|
||||
success=True,
|
||||
execution_time_ms=execution_time_ms,
|
||||
@@ -401,6 +443,45 @@ async def lint_playbook(
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/results")
|
||||
async def get_all_lint_results(
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Récupère tous les résultats de lint stockés en base de données.
|
||||
|
||||
Utile pour afficher les scores dans la liste des playbooks.
|
||||
"""
|
||||
stmt = select(PlaybookLintResult).order_by(PlaybookLintResult.updated_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
lint_results = result.scalars().all()
|
||||
|
||||
return {
|
||||
"results": {r.filename: r.to_dict() for r in lint_results},
|
||||
"count": len(lint_results)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/results/{filename}")
|
||||
async def get_lint_result(
|
||||
filename: str,
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Récupère le dernier résultat de lint pour un playbook spécifique.
|
||||
"""
|
||||
stmt = select(PlaybookLintResult).where(PlaybookLintResult.filename == filename)
|
||||
result = await db.execute(stmt)
|
||||
lint_result = result.scalar_one_or_none()
|
||||
|
||||
if not lint_result:
|
||||
raise HTTPException(status_code=404, detail=f"Aucun résultat lint pour {filename}")
|
||||
|
||||
return lint_result.to_dict()
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
async def list_lint_rules(api_key_valid: bool = Depends(verify_api_key)):
|
||||
"""
|
||||
|
||||
@@ -21,12 +21,16 @@ async def get_playbook_content(
|
||||
filename: str,
|
||||
api_key_valid: bool = Depends(verify_api_key)
|
||||
):
|
||||
"""Récupère le contenu d'un playbook."""
|
||||
playbook_path = ansible_service.playbooks_dir / filename
|
||||
|
||||
"""Récupère le contenu d'un playbook (normal ou builtin)."""
|
||||
if not filename.endswith(('.yml', '.yaml')):
|
||||
raise HTTPException(status_code=400, detail="Extension de fichier invalide. Utilisez .yml ou .yaml")
|
||||
|
||||
# Vérifier si c'est un builtin playbook (commence par _builtin_)
|
||||
if filename.startswith("_builtin_"):
|
||||
playbook_path = ansible_service.playbooks_dir / "builtin" / filename
|
||||
else:
|
||||
playbook_path = ansible_service.playbooks_dir / filename
|
||||
|
||||
if not playbook_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Playbook non trouvé: {filename}")
|
||||
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
"""
|
||||
Routes API pour les sessions terminal SSH web.
|
||||
|
||||
Provides endpoints for:
|
||||
- Creating terminal sessions
|
||||
- Listing active sessions
|
||||
- Closing sessions
|
||||
- Serving terminal popout page
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_db, get_current_user
|
||||
from app.crud.host import HostRepository
|
||||
from app.crud.bootstrap_status import BootstrapStatusRepository
|
||||
from app.crud.terminal_session import TerminalSessionRepository
|
||||
from app.schemas.terminal import (
|
||||
TerminalSessionRequest,
|
||||
TerminalSessionResponse,
|
||||
TerminalSessionHost,
|
||||
TerminalSessionStatus,
|
||||
TerminalSessionList,
|
||||
)
|
||||
from app.services.terminal_service import (
|
||||
terminal_service,
|
||||
TERMINAL_SESSION_TTL_MINUTES,
|
||||
TERMINAL_MAX_SESSIONS_PER_USER,
|
||||
HostNotReadyError,
|
||||
SessionLimitExceededError,
|
||||
TtydNotAvailableError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _as_utc_aware(dt: Optional[datetime]) -> Optional[datetime]:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_terminal_feature_status():
|
||||
"""
|
||||
Check if terminal feature is available.
|
||||
|
||||
Returns availability status and configuration info.
|
||||
"""
|
||||
ttyd_available = terminal_service.check_ttyd_available()
|
||||
|
||||
return {
|
||||
"available": ttyd_available,
|
||||
"ttyd_installed": ttyd_available,
|
||||
"max_sessions_per_user": TERMINAL_MAX_SESSIONS_PER_USER,
|
||||
"session_ttl_minutes": TERMINAL_SESSION_TTL_MINUTES,
|
||||
"active_sessions": terminal_service.get_active_session_count(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{host_id}/terminal-sessions", response_model=TerminalSessionResponse)
|
||||
async def create_terminal_session(
|
||||
host_id: str,
|
||||
request: TerminalSessionRequest = TerminalSessionRequest(),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Create a new terminal session for a host.
|
||||
|
||||
Requires:
|
||||
- Host must exist and be online
|
||||
- Host must have bootstrap completed
|
||||
- User must not exceed max sessions limit
|
||||
|
||||
Returns session URL and metadata.
|
||||
"""
|
||||
# Check if ttyd is available
|
||||
if not terminal_service.check_ttyd_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Terminal feature unavailable: ttyd is not installed"
|
||||
)
|
||||
|
||||
# Get host information
|
||||
host_repo = HostRepository(db_session)
|
||||
bs_repo = BootstrapStatusRepository(db_session)
|
||||
session_repo = TerminalSessionRepository(db_session)
|
||||
|
||||
# Try to get host by ID first, then by name
|
||||
host = await host_repo.get(host_id)
|
||||
if not host:
|
||||
host = await host_repo.get_by_name(host_id)
|
||||
if not host:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Host '{host_id}' not found"
|
||||
)
|
||||
|
||||
# Check host status
|
||||
if host.status == "offline":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Host '{host.name}' is offline. Cannot open terminal."
|
||||
)
|
||||
|
||||
# Check bootstrap status
|
||||
bootstrap = await bs_repo.latest_for_host(host.id)
|
||||
bootstrap_ok = bootstrap and bootstrap.status == "success"
|
||||
|
||||
if not bootstrap_ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Host '{host.name}' has not been bootstrapped. Run bootstrap first to configure SSH access."
|
||||
)
|
||||
|
||||
# Get user info
|
||||
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
|
||||
username = current_user.get("username", "api_user")
|
||||
|
||||
# Check session limit
|
||||
active_count = await session_repo.count_active_for_user(user_id)
|
||||
if active_count >= TERMINAL_MAX_SESSIONS_PER_USER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Maximum active sessions ({TERMINAL_MAX_SESSIONS_PER_USER}) reached. Close an existing session first."
|
||||
)
|
||||
|
||||
# Generate session credentials
|
||||
session_id = terminal_service.generate_session_id()
|
||||
token, token_hash = terminal_service.generate_session_token()
|
||||
|
||||
# Allocate port
|
||||
try:
|
||||
port = await terminal_service.allocate_port(session_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to allocate port: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="No available ports for terminal session"
|
||||
)
|
||||
|
||||
# Calculate expiration
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=TERMINAL_SESSION_TTL_MINUTES)
|
||||
|
||||
# Spawn ttyd process
|
||||
try:
|
||||
pid = await terminal_service.spawn_ttyd(
|
||||
session_id=session_id,
|
||||
host_ip=host.ip_address,
|
||||
port=port,
|
||||
token=token,
|
||||
)
|
||||
|
||||
if pid is None:
|
||||
await terminal_service.release_port(port)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to start terminal process"
|
||||
)
|
||||
|
||||
except TtydNotAvailableError:
|
||||
await terminal_service.release_port(port)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Terminal feature unavailable: ttyd is not installed"
|
||||
)
|
||||
except Exception as e:
|
||||
await terminal_service.release_port(port)
|
||||
logger.exception(f"Failed to spawn ttyd: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to start terminal: {str(e)}"
|
||||
)
|
||||
|
||||
# Create session in database
|
||||
try:
|
||||
session = await session_repo.create(
|
||||
id=session_id,
|
||||
host_id=host.id,
|
||||
host_name=host.name,
|
||||
host_ip=host.ip_address,
|
||||
token_hash=token_hash,
|
||||
ttyd_port=port,
|
||||
ttyd_pid=pid,
|
||||
expires_at=expires_at,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
mode=request.mode,
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
except Exception as e:
|
||||
# Cleanup on failure
|
||||
await terminal_service.terminate_session(session_id)
|
||||
await terminal_service.release_port(port)
|
||||
logger.exception(f"Failed to create session in DB: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create terminal session"
|
||||
)
|
||||
|
||||
# Log audit event
|
||||
logger.info(f"Terminal session created: user={username} host={host.name} session={session_id[:8]}...")
|
||||
|
||||
connect_path = f"/api/terminal/connect/{session_id}?token={token}"
|
||||
popout_path = f"/api/terminal/popout/{session_id}?token={token}"
|
||||
session_url = popout_path if request.mode == "popout" else connect_path
|
||||
|
||||
# Build response
|
||||
return TerminalSessionResponse(
|
||||
session_id=session_id,
|
||||
url=session_url,
|
||||
websocket_url=f"ws://localhost:{port}/ws",
|
||||
expires_at=expires_at,
|
||||
ttl_seconds=TERMINAL_SESSION_TTL_MINUTES * 60,
|
||||
mode=request.mode,
|
||||
host=TerminalSessionHost(
|
||||
id=host.id,
|
||||
name=host.name,
|
||||
ip=host.ip_address,
|
||||
status=host.status or "unknown",
|
||||
bootstrap_ok=bootstrap_ok,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=TerminalSessionList)
|
||||
async def list_terminal_sessions(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List active terminal sessions for the current user.
|
||||
"""
|
||||
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
|
||||
|
||||
session_repo = TerminalSessionRepository(db_session)
|
||||
sessions = await session_repo.list_active_for_user(user_id)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
session_list = []
|
||||
|
||||
for session in sessions:
|
||||
expires_at = _as_utc_aware(session.expires_at)
|
||||
created_at = _as_utc_aware(session.created_at)
|
||||
remaining = max(0, int(((expires_at or now) - now).total_seconds()))
|
||||
session_list.append(TerminalSessionStatus(
|
||||
session_id=session.id,
|
||||
status=session.status,
|
||||
host_name=session.host_name,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
remaining_seconds=remaining,
|
||||
))
|
||||
|
||||
return TerminalSessionList(
|
||||
sessions=session_list,
|
||||
total=len(session_list),
|
||||
max_per_user=TERMINAL_MAX_SESSIONS_PER_USER,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def close_terminal_session(
|
||||
session_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Close a terminal session.
|
||||
|
||||
Terminates the ttyd process and marks session as closed.
|
||||
"""
|
||||
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
|
||||
username = current_user.get("username", "api_user")
|
||||
|
||||
session_repo = TerminalSessionRepository(db_session)
|
||||
session = await session_repo.get(session_id)
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
# Check ownership (unless admin)
|
||||
if session.user_id != user_id and current_user.get("role") != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot close another user's session"
|
||||
)
|
||||
|
||||
# Terminate ttyd process
|
||||
await terminal_service.terminate_session(session_id)
|
||||
await terminal_service.release_port(session.ttyd_port)
|
||||
|
||||
# Update database
|
||||
await session_repo.close_session(session_id)
|
||||
await db_session.commit()
|
||||
|
||||
logger.info(f"Terminal session closed: user={username} host={session.host_name} session={session_id[:8]}...")
|
||||
|
||||
return {"message": "Session closed", "session_id": session_id}
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def cleanup_terminal_sessions(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Force cleanup of all terminal sessions.
|
||||
|
||||
Closes all active sessions in DB and terminates any orphaned ttyd processes.
|
||||
Useful when sessions get stuck or to reset the terminal feature.
|
||||
"""
|
||||
session_repo = TerminalSessionRepository(db_session)
|
||||
|
||||
# Get all active sessions to terminate their processes
|
||||
active_sessions = await session_repo.list_active_for_user(
|
||||
current_user.get("user_id") or current_user.get("type", "api_key")
|
||||
)
|
||||
|
||||
terminated = 0
|
||||
for session in active_sessions:
|
||||
try:
|
||||
await terminal_service.terminate_session(session.id)
|
||||
await terminal_service.release_port(session.ttyd_port)
|
||||
terminated += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to terminate session {session.id[:8]}: {e}")
|
||||
|
||||
# Close all active sessions in DB
|
||||
closed = await session_repo.close_all_active()
|
||||
await db_session.commit()
|
||||
|
||||
# Also cleanup expired sessions
|
||||
expired = await session_repo.list_expired()
|
||||
for session in expired:
|
||||
await session_repo.mark_expired(session.id)
|
||||
await db_session.commit()
|
||||
|
||||
logger.info(f"Terminal cleanup: {terminated} processes terminated, {closed} sessions closed")
|
||||
|
||||
return {
|
||||
"message": "Cleanup completed",
|
||||
"processes_terminated": terminated,
|
||||
"sessions_closed": closed,
|
||||
"expired_marked": len(expired),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/connect/{session_id}")
|
||||
async def get_terminal_connect_page(
|
||||
session_id: str,
|
||||
token: str,
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Serve the terminal connection page.
|
||||
|
||||
This page embeds the ttyd terminal in an iframe or directly connects.
|
||||
Token is verified before serving the page.
|
||||
"""
|
||||
session_repo = TerminalSessionRepository(db_session)
|
||||
session = await session_repo.get_active_by_id(session_id)
|
||||
|
||||
if not session:
|
||||
return HTMLResponse(
|
||||
content="""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Session Expirée</title>
|
||||
<style>
|
||||
body { font-family: system-ui; background: #1a1a2e; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100vh; margin: 0; }
|
||||
.error { text-align: center; padding: 2rem; }
|
||||
.error h1 { color: #ef4444; }
|
||||
.error a { color: #60a5fa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error">
|
||||
<h1>Session Expirée ou Invalide</h1>
|
||||
<p>Cette session terminal n'existe pas ou a expiré.</p>
|
||||
<p><a href="/">Retour au Dashboard</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=404
|
||||
)
|
||||
|
||||
# Verify token
|
||||
if not terminal_service.verify_token(token, session.token_hash):
|
||||
return HTMLResponse(
|
||||
content="""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Accès Refusé</title>
|
||||
<style>
|
||||
body { font-family: system-ui; background: #1a1a2e; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100vh; margin: 0; }
|
||||
.error { text-align: center; padding: 2rem; }
|
||||
.error h1 { color: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error">
|
||||
<h1>Accès Refusé</h1>
|
||||
<p>Token de session invalide.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=403
|
||||
)
|
||||
|
||||
# Calculate remaining time
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = _as_utc_aware(session.expires_at)
|
||||
remaining_seconds = max(0, int(((expires_at or now) - now).total_seconds()))
|
||||
|
||||
# Generate the terminal page
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Terminal - {session.host_name}</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background: #0f0f1a;
|
||||
color: #e5e7eb;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}}
|
||||
.terminal-header {{
|
||||
background: #1a1a2e;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #374151;
|
||||
flex-shrink: 0;
|
||||
}}
|
||||
.terminal-header .host-info {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}}
|
||||
.terminal-header .host-name {{
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}}
|
||||
.terminal-header .host-ip {{
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}}
|
||||
.terminal-header .status-badge {{
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}}
|
||||
.terminal-header .status-badge.online {{
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}}
|
||||
.terminal-header .session-timer {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}}
|
||||
.terminal-header .session-timer.warning {{
|
||||
color: #fbbf24;
|
||||
}}
|
||||
.terminal-header .session-timer.critical {{
|
||||
color: #ef4444;
|
||||
}}
|
||||
.terminal-header .actions {{
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}}
|
||||
.terminal-header .btn {{
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
transition: all 0.15s;
|
||||
}}
|
||||
.terminal-header .btn-secondary {{
|
||||
background: #374151;
|
||||
color: #e5e7eb;
|
||||
}}
|
||||
.terminal-header .btn-secondary:hover {{
|
||||
background: #4b5563;
|
||||
}}
|
||||
.terminal-header .btn-danger {{
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
}}
|
||||
.terminal-header .btn-danger:hover {{
|
||||
background: #b91c1c;
|
||||
}}
|
||||
.terminal-container {{
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}}
|
||||
.terminal-container iframe {{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #000;
|
||||
}}
|
||||
.terminal-loading {{
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0f0f1a;
|
||||
}}
|
||||
.terminal-loading .spinner {{
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid #374151;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}}
|
||||
@keyframes spin {{
|
||||
to {{ transform: rotate(360deg); }}
|
||||
}}
|
||||
.terminal-loading .loading-text {{
|
||||
margin-top: 1rem;
|
||||
color: #9ca3af;
|
||||
}}
|
||||
.pwa-hint {{
|
||||
background: #1e3a5f;
|
||||
color: #93c5fd;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}}
|
||||
.pwa-hint button {{
|
||||
background: none;
|
||||
border: none;
|
||||
color: #60a5fa;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}}
|
||||
.hidden {{ display: none !important; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pwa-hint" id="pwaHint">
|
||||
<span>💡 Pour une expérience sans barre d'outils : installez en PWA ou utilisez <code>chrome --app=URL</code></span>
|
||||
<button onclick="dismissPwaHint()">✕ Fermer</button>
|
||||
</div>
|
||||
|
||||
<div class="terminal-header">
|
||||
<div class="host-info">
|
||||
<span class="host-name">{session.host_name}</span>
|
||||
<span class="host-ip">{session.host_ip}</span>
|
||||
<span class="status-badge online">Connecté</span>
|
||||
</div>
|
||||
<div class="session-timer" id="sessionTimer">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
|
||||
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
||||
</svg>
|
||||
<span id="timerDisplay">{remaining_seconds // 60}:{remaining_seconds % 60:02d}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" onclick="copySSHCommand()" title="Copier la commande SSH">
|
||||
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3z"/>
|
||||
</svg>
|
||||
SSH
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="reconnect()" title="Reconnecter">
|
||||
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
Reconnecter
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="goToDashboard()" title="Retour au Dashboard">
|
||||
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z"/>
|
||||
</svg>
|
||||
Dashboard
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="closeSession()" title="Fermer la session">
|
||||
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/>
|
||||
</svg>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-container">
|
||||
<div class="terminal-loading" id="terminalLoading">
|
||||
<div style="text-align: center;">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text">Connexion au terminal...</div>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
id="terminalFrame"
|
||||
src="http://localhost:{session.ttyd_port}/"
|
||||
onload="onTerminalLoad()"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const SESSION_ID = '{session_id}';
|
||||
const TOKEN = '{token}';
|
||||
const HOST_NAME = '{session.host_name}';
|
||||
const HOST_IP = '{session.host_ip}';
|
||||
let remainingSeconds = {remaining_seconds};
|
||||
|
||||
// Timer countdown
|
||||
function updateTimer() {{
|
||||
remainingSeconds--;
|
||||
if (remainingSeconds <= 0) {{
|
||||
document.getElementById('timerDisplay').textContent = 'Expiré';
|
||||
document.getElementById('sessionTimer').classList.add('critical');
|
||||
return;
|
||||
}}
|
||||
|
||||
const minutes = Math.floor(remainingSeconds / 60);
|
||||
const seconds = remainingSeconds % 60;
|
||||
document.getElementById('timerDisplay').textContent =
|
||||
minutes + ':' + seconds.toString().padStart(2, '0');
|
||||
|
||||
const timer = document.getElementById('sessionTimer');
|
||||
if (remainingSeconds < 60) {{
|
||||
timer.classList.add('critical');
|
||||
timer.classList.remove('warning');
|
||||
}} else if (remainingSeconds < 300) {{
|
||||
timer.classList.add('warning');
|
||||
}}
|
||||
}}
|
||||
setInterval(updateTimer, 1000);
|
||||
|
||||
function onTerminalLoad() {{
|
||||
document.getElementById('terminalLoading').classList.add('hidden');
|
||||
// Focus the iframe
|
||||
document.getElementById('terminalFrame').focus();
|
||||
}}
|
||||
|
||||
function copySSHCommand() {{
|
||||
const cmd = 'ssh automation@' + HOST_IP;
|
||||
navigator.clipboard.writeText(cmd).then(() => {{
|
||||
alert('Commande copiée: ' + cmd);
|
||||
}});
|
||||
}}
|
||||
|
||||
function reconnect() {{
|
||||
document.getElementById('terminalLoading').classList.remove('hidden');
|
||||
document.getElementById('terminalFrame').src =
|
||||
document.getElementById('terminalFrame').src;
|
||||
}}
|
||||
|
||||
function goToDashboard() {{
|
||||
if (window.opener) {{
|
||||
window.close();
|
||||
}} else {{
|
||||
window.location.href = '/';
|
||||
}}
|
||||
}}
|
||||
|
||||
function closeSession() {{
|
||||
if (confirm('Fermer cette session terminal?')) {{
|
||||
fetch('/api/terminal/sessions/' + SESSION_ID, {{
|
||||
method: 'DELETE',
|
||||
headers: {{
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
}}
|
||||
}}).then(() => {{
|
||||
goToDashboard();
|
||||
}}).catch(() => {{
|
||||
goToDashboard();
|
||||
}});
|
||||
}}
|
||||
}}
|
||||
|
||||
function dismissPwaHint() {{
|
||||
document.getElementById('pwaHint').classList.add('hidden');
|
||||
localStorage.setItem('pwaHintDismissed', 'true');
|
||||
}}
|
||||
|
||||
// Check if PWA hint should be hidden
|
||||
if (localStorage.getItem('pwaHintDismissed') === 'true' ||
|
||||
window.matchMedia('(display-mode: standalone)').matches) {{
|
||||
document.getElementById('pwaHint').classList.add('hidden');
|
||||
}}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (e.key === 'Escape') {{
|
||||
goToDashboard();
|
||||
}}
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
|
||||
@router.get("/popout/{session_id}")
|
||||
async def get_terminal_popout_page(
|
||||
session_id: str,
|
||||
token: str,
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Serve the terminal popout page (fullscreen, minimal UI).
|
||||
|
||||
This is designed to be opened in a popup window or PWA.
|
||||
"""
|
||||
# Reuse the same connect page logic but with minimal header
|
||||
return await get_terminal_connect_page(session_id, token, db_session)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Pydantic schemas for terminal session API.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TerminalSessionRequest(BaseModel):
|
||||
"""Request to create a new terminal session."""
|
||||
mode: Literal["embedded", "popout"] = Field(
|
||||
default="embedded",
|
||||
description="Terminal display mode: embedded in drawer or popout window"
|
||||
)
|
||||
|
||||
|
||||
class TerminalSessionHost(BaseModel):
|
||||
"""Host information included in session response."""
|
||||
id: str
|
||||
name: str
|
||||
ip: str
|
||||
status: str
|
||||
bootstrap_ok: bool
|
||||
|
||||
|
||||
class TerminalSessionResponse(BaseModel):
|
||||
"""Response after creating a terminal session."""
|
||||
session_id: str
|
||||
url: str
|
||||
websocket_url: str
|
||||
expires_at: datetime
|
||||
ttl_seconds: int
|
||||
mode: str
|
||||
host: TerminalSessionHost
|
||||
|
||||
|
||||
class TerminalSessionStatus(BaseModel):
|
||||
"""Current status of a terminal session."""
|
||||
session_id: str
|
||||
status: str
|
||||
host_name: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
remaining_seconds: int
|
||||
|
||||
|
||||
class TerminalSessionList(BaseModel):
|
||||
"""List of active terminal sessions."""
|
||||
sessions: list[TerminalSessionStatus]
|
||||
total: int
|
||||
max_per_user: int
|
||||
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
Terminal Service - Manages SSH terminal sessions via ttyd.
|
||||
|
||||
This service handles:
|
||||
- Creating terminal sessions with unique tokens
|
||||
- Spawning ttyd processes for SSH connections
|
||||
- Managing session lifecycle (creation, expiration, cleanup)
|
||||
- Port allocation for ttyd instances
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Configuration
|
||||
TERMINAL_SESSION_TTL_MINUTES = int(os.environ.get("TERMINAL_SESSION_TTL_MINUTES", "30"))
|
||||
TERMINAL_MAX_SESSIONS_PER_USER = int(os.environ.get("TERMINAL_MAX_SESSIONS_PER_USER", "3"))
|
||||
TERMINAL_PORT_RANGE_START = int(os.environ.get("TERMINAL_PORT_RANGE_START", "7680"))
|
||||
TERMINAL_PORT_RANGE_END = int(os.environ.get("TERMINAL_PORT_RANGE_END", "7700"))
|
||||
SSH_USER = os.environ.get("TERMINAL_SSH_USER", "automation")
|
||||
TTYD_PATH = os.environ.get("TTYD_PATH", "ttyd")
|
||||
|
||||
|
||||
class TerminalServiceError(Exception):
|
||||
"""Base exception for terminal service errors."""
|
||||
pass
|
||||
|
||||
|
||||
class HostNotReadyError(TerminalServiceError):
|
||||
"""Host is not ready for terminal connection."""
|
||||
pass
|
||||
|
||||
|
||||
class SessionLimitExceededError(TerminalServiceError):
|
||||
"""User has too many active sessions."""
|
||||
pass
|
||||
|
||||
|
||||
class TtydNotAvailableError(TerminalServiceError):
|
||||
"""ttyd binary is not available."""
|
||||
pass
|
||||
|
||||
|
||||
class TerminalService:
|
||||
"""
|
||||
Manages terminal sessions and ttyd processes.
|
||||
|
||||
This service is responsible for:
|
||||
- Generating secure session tokens
|
||||
- Allocating ports for ttyd instances
|
||||
- Spawning and managing ttyd processes
|
||||
- Cleaning up expired sessions
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Track active ttyd processes: session_id -> subprocess.Popen
|
||||
self._processes: Dict[str, subprocess.Popen] = {}
|
||||
# Track allocated ports: port -> session_id
|
||||
self._allocated_ports: Dict[int, str] = {}
|
||||
# Lock for thread-safe operations
|
||||
self._lock = asyncio.Lock()
|
||||
# Check if ttyd is available
|
||||
self._ttyd_available: Optional[bool] = None
|
||||
|
||||
def check_ttyd_available(self) -> bool:
|
||||
"""Check if ttyd binary is available."""
|
||||
if self._ttyd_available is not None:
|
||||
return self._ttyd_available
|
||||
|
||||
self._ttyd_available = shutil.which(TTYD_PATH) is not None
|
||||
if not self._ttyd_available:
|
||||
logger.warning(f"ttyd not found at '{TTYD_PATH}'. Terminal feature will be unavailable.")
|
||||
else:
|
||||
logger.info(f"ttyd found at: {shutil.which(TTYD_PATH)}")
|
||||
|
||||
return self._ttyd_available
|
||||
|
||||
def generate_session_id(self) -> str:
|
||||
"""Generate a unique session ID."""
|
||||
return secrets.token_hex(32)
|
||||
|
||||
def generate_session_token(self) -> Tuple[str, str]:
|
||||
"""
|
||||
Generate a session token and its hash.
|
||||
|
||||
Returns:
|
||||
Tuple of (plain_token, token_hash)
|
||||
"""
|
||||
token = secrets.token_urlsafe(48)
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
return token, token_hash
|
||||
|
||||
def verify_token(self, token: str, token_hash: str) -> bool:
|
||||
"""Verify a token against its hash."""
|
||||
computed_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
return secrets.compare_digest(computed_hash, token_hash)
|
||||
|
||||
def _is_port_available(self, port: int) -> bool:
|
||||
"""Check if a TCP port is available for binding."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
|
||||
sock.bind(("0.0.0.0", port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def allocate_port(self, session_id: str) -> int:
|
||||
"""
|
||||
Allocate an available port for a ttyd instance.
|
||||
|
||||
Returns:
|
||||
Available port number
|
||||
|
||||
Raises:
|
||||
TerminalServiceError if no ports available
|
||||
"""
|
||||
async with self._lock:
|
||||
for port in range(TERMINAL_PORT_RANGE_START, TERMINAL_PORT_RANGE_END + 1):
|
||||
if port in self._allocated_ports:
|
||||
continue
|
||||
if not self._is_port_available(port):
|
||||
continue
|
||||
|
||||
if port not in self._allocated_ports:
|
||||
self._allocated_ports[port] = session_id
|
||||
logger.debug(f"Allocated port {port} for session {session_id[:8]}...")
|
||||
return port
|
||||
|
||||
raise TerminalServiceError("No available ports for terminal session")
|
||||
|
||||
async def release_port(self, port: int) -> None:
|
||||
"""Release an allocated port."""
|
||||
async with self._lock:
|
||||
if port in self._allocated_ports:
|
||||
session_id = self._allocated_ports.pop(port)
|
||||
logger.debug(f"Released port {port} from session {session_id[:8]}...")
|
||||
|
||||
async def spawn_ttyd(
|
||||
self,
|
||||
session_id: str,
|
||||
host_ip: str,
|
||||
port: int,
|
||||
token: str,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Spawn a ttyd process for SSH connection.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier
|
||||
host_ip: Target host IP address
|
||||
port: Port to run ttyd on
|
||||
token: Session token for authentication
|
||||
|
||||
Returns:
|
||||
Process ID of the spawned ttyd, or None if failed
|
||||
"""
|
||||
if not self.check_ttyd_available():
|
||||
raise TtydNotAvailableError("ttyd is not installed or not in PATH")
|
||||
|
||||
key_candidates = []
|
||||
env_key = os.environ.get("TERMINAL_SSH_KEY_PATH")
|
||||
if env_key:
|
||||
key_candidates.append(env_key)
|
||||
if getattr(settings, "ssh_key_path", None):
|
||||
key_candidates.append(settings.ssh_key_path)
|
||||
key_candidates.append(str(os.path.expanduser("~/.ssh/id_automation_ansible")))
|
||||
key_candidates.append(str(os.path.expanduser("~/.ssh/id_rsa")))
|
||||
|
||||
ssh_key_path = next((p for p in key_candidates if p and os.path.exists(p)), None)
|
||||
if not ssh_key_path:
|
||||
raise TerminalServiceError(
|
||||
"No SSH key found for terminal sessions. Set SSH_KEY_PATH or TERMINAL_SSH_KEY_PATH (expected ~/.ssh/id_automation_ansible)."
|
||||
)
|
||||
|
||||
# Build ttyd command
|
||||
# --once: Exit after single client disconnects
|
||||
# --credential: Basic auth with session token
|
||||
# --port: Listen port
|
||||
# -W: Write-only mode disabled (allow input)
|
||||
# The command executed is SSH to the target host
|
||||
ssh_cmd = [
|
||||
"ssh",
|
||||
"-i",
|
||||
ssh_key_path,
|
||||
"-o",
|
||||
"BatchMode=no",
|
||||
"-o",
|
||||
"PreferredAuthentications=publickey,keyboard-interactive,password",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
"ServerAliveInterval=30",
|
||||
"-o",
|
||||
"ServerAliveCountMax=2",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
f"{SSH_USER}@{host_ip}",
|
||||
]
|
||||
|
||||
# Note: --credential removed to avoid HTTP basic auth popup
|
||||
# Security is handled by session token validation in the connect/popout routes
|
||||
cmd = [
|
||||
TTYD_PATH,
|
||||
"--once",
|
||||
f"--port={port}",
|
||||
"--writable",
|
||||
*ssh_cmd,
|
||||
]
|
||||
|
||||
logger.info(
|
||||
f"Spawning ttyd for session {session_id[:8]}... on port {port} -> {SSH_USER}@{host_ip} (key={ssh_key_path})"
|
||||
)
|
||||
|
||||
try:
|
||||
# Spawn the process
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True, # Detach from parent process group
|
||||
)
|
||||
|
||||
# Store the process
|
||||
async with self._lock:
|
||||
self._processes[session_id] = process
|
||||
|
||||
# Wait briefly to check if process started successfully
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if process.poll() is not None:
|
||||
# Process exited immediately - likely an error
|
||||
stdout, stderr = process.communicate(timeout=1)
|
||||
error_msg = stderr.decode() if stderr else stdout.decode() if stdout else "Unknown error"
|
||||
logger.error(f"ttyd failed to start: {error_msg}")
|
||||
await self.release_port(port)
|
||||
raise TerminalServiceError(f"ttyd failed to start: {error_msg.strip()}")
|
||||
|
||||
logger.info(f"ttyd started with PID {process.pid} for session {session_id[:8]}...")
|
||||
return process.pid
|
||||
|
||||
except TerminalServiceError:
|
||||
# Preserve detailed error message for API layer
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to spawn ttyd: {e}")
|
||||
await self.release_port(port)
|
||||
raise TerminalServiceError(str(e))
|
||||
|
||||
async def terminate_session(self, session_id: str) -> bool:
|
||||
"""
|
||||
Terminate a terminal session and its ttyd process.
|
||||
|
||||
Returns:
|
||||
True if session was terminated, False if not found
|
||||
"""
|
||||
async with self._lock:
|
||||
process = self._processes.pop(session_id, None)
|
||||
|
||||
if process is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Try graceful termination first
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Force kill if graceful termination failed
|
||||
process.kill()
|
||||
process.wait(timeout=2)
|
||||
|
||||
logger.info(f"Terminated ttyd process for session {session_id[:8]}...")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error terminating ttyd process: {e}")
|
||||
return False
|
||||
|
||||
async def cleanup_expired_sessions(
|
||||
self,
|
||||
get_expired_sessions_func,
|
||||
mark_expired_func,
|
||||
db_session
|
||||
) -> int:
|
||||
"""
|
||||
Clean up expired terminal sessions.
|
||||
|
||||
Args:
|
||||
get_expired_sessions_func: Async function to get expired sessions from DB
|
||||
mark_expired_func: Async function to mark session as expired in DB
|
||||
db_session: Database session
|
||||
|
||||
Returns:
|
||||
Number of sessions cleaned up
|
||||
"""
|
||||
from app.crud.terminal_session import TerminalSessionRepository
|
||||
|
||||
repo = TerminalSessionRepository(db_session)
|
||||
expired = await repo.list_expired()
|
||||
|
||||
count = 0
|
||||
for session in expired:
|
||||
# Terminate the ttyd process
|
||||
await self.terminate_session(session.id)
|
||||
# Release the port
|
||||
await self.release_port(session.ttyd_port)
|
||||
# Mark as expired in DB
|
||||
await repo.mark_expired(session.id)
|
||||
count += 1
|
||||
logger.info(f"Cleaned up expired session {session.id[:8]}... for host {session.host_name}")
|
||||
|
||||
return count
|
||||
|
||||
def get_active_session_count(self) -> int:
|
||||
"""Get the number of active ttyd processes."""
|
||||
return len(self._processes)
|
||||
|
||||
def get_session_url(self, port: int, token: str, base_url: str = "") -> str:
|
||||
"""
|
||||
Get the URL to access a terminal session.
|
||||
|
||||
Args:
|
||||
port: ttyd port
|
||||
token: Session token
|
||||
base_url: Optional base URL prefix
|
||||
|
||||
Returns:
|
||||
Full URL to access the terminal
|
||||
"""
|
||||
# If we have a reverse proxy, use the proxied URL
|
||||
# Otherwise, direct access to ttyd port
|
||||
if base_url:
|
||||
return f"{base_url}/terminal/proxy/{port}?token={token}"
|
||||
return f"http://localhost:{port}/"
|
||||
|
||||
def get_websocket_url(self, port: int) -> str:
|
||||
"""Get the WebSocket URL for terminal connection."""
|
||||
return f"ws://localhost:{port}/ws"
|
||||
|
||||
|
||||
# Global service instance
|
||||
terminal_service = TerminalService()
|
||||
|
||||
|
||||
def get_terminal_service() -> TerminalService:
|
||||
"""Get the global terminal service instance."""
|
||||
return terminal_service
|
||||
Reference in New Issue
Block a user