Files
edubox/agent/ui/index.html
T
EduBox Dev e946b22a42 feat(agent): v0.3.9 sync, UI details, self-update, centralized version
- Add agent/server startup sync (sync/sync_response)
- Centralize agent version in agent/VERSION + expose /api/agent/version
- Display agent version, nodeId and server version in local UI
- Add agent self-update detection/download/restart via helper scripts
- Run start/stop/delete/reset handlers in goroutines to avoid WebSocket blocking
- Update dashboard download links and SUIVI_VPN_ONDEMAND.md
- Document Podman stays installer-managed, not agent-updated
2026-06-27 21:11:20 +00:00

1014 lines
31 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>studioE5 Agent</title>
<style>
:root {
--bg: #f8fafc;
--card: #ffffff;
--text: #1e293b;
--text-secondary: #64748b;
--primary: #2563eb;
--primary-dark: #1d4ed8;
--success: #16a34a;
--success-bg: #dcfce7;
--warning: #ca8a04;
--warning-bg: #fef9c3;
--error: #dc2626;
--error-bg: #fee2e2;
--info: #2563eb;
--info-bg: #dbeafe;
--neutral: #94a3b8;
--neutral-bg: #f1f5f9;
--border: #e2e8f0;
--shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -2px rgba(0,0,0,0.05);
--radius: 12px;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
background: var(--bg);
margin: 0;
padding: 1rem;
color: var(--text);
min-height: 100vh;
line-height: 1.5;
}
.container {
max-width: 520px;
margin: 0 auto;
}
.brand {
text-align: center;
margin-bottom: 1.5rem;
color: var(--text-secondary);
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.card {
background: var(--card);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
margin-bottom: 1rem;
border: 1px solid var(--border);
}
h1 {
font-size: 1.5rem;
margin: 0 0 0.5rem;
color: var(--text);
}
h2 {
font-size: 1.125rem;
margin: 0 0 1rem;
color: var(--text);
}
p { margin: 0 0 1rem; }
.subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
margin-bottom: 1.5rem;
}
label {
display: block;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.35rem;
color: var(--text-secondary);
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1rem;
margin-bottom: 1rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
input:read-only {
background: var(--neutral-bg);
}
button {
width: 100%;
padding: 0.8rem;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 1rem;
transition: background 0.15s, transform 0.05s;
}
button:hover { background: var(--primary-dark); }
button:active { transform: translateY(1px); }
button:disabled {
background: var(--neutral);
cursor: not-allowed;
transform: none;
}
button.secondary {
background: var(--neutral-bg);
color: var(--text);
border: 1px solid var(--border);
}
button.secondary:hover { background: #e2e8f0; }
button.ghost {
background: transparent;
color: var(--text-secondary);
border: none;
font-weight: 500;
padding: 0.5rem;
}
button.ghost:hover { color: var(--text); background: var(--neutral-bg); }
.status {
margin-top: 1rem;
font-size: 0.9rem;
min-height: 1.4rem;
}
.status.success { color: var(--success); }
.status.error { color: var(--error); }
.status.info { color: var(--info); }
/* Activation code input */
.code-input {
text-align: center;
font-size: 1.5rem;
letter-spacing: 0.5em;
text-transform: uppercase;
font-weight: 700;
padding-left: 1rem;
}
.help-text {
text-align: center;
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 1rem;
}
.help-text a {
color: var(--primary);
text-decoration: none;
}
.help-text a:hover { text-decoration: underline; }
/* Dashboard services */
.services {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.service-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem;
background: var(--neutral-bg);
border-radius: 8px;
}
.service-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
flex-shrink: 0;
}
.service-icon.ok { background: var(--success-bg); }
.service-icon.warn { background: var(--warning-bg); }
.service-icon.error { background: var(--error-bg); }
.service-icon.info { background: var(--info-bg); }
.service-icon.pending { background: var(--neutral-bg); }
.service-text {
flex: 1;
font-size: 0.95rem;
font-weight: 500;
}
.service-detail {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.15rem;
}
/* Application list */
.app-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.app-item {
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.app-meta { flex: 1; }
.app-name {
font-weight: 600;
margin-bottom: 0.25rem;
}
.app-type {
font-size: 0.8rem;
color: var(--text-secondary);
}
.badge {
display: inline-block;
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-running { background: var(--success-bg); color: var(--success); }
.badge-starting { background: var(--warning-bg); color: var(--warning); }
.badge-stopped { background: var(--neutral-bg); color: var(--text-secondary); }
.badge-error { background: var(--error-bg); color: var(--error); }
.app-link {
font-size: 0.85rem;
color: var(--primary);
text-decoration: none;
font-weight: 500;
margin-top: 0.4rem;
display: inline-block;
}
.app-link:hover { text-decoration: underline; }
.app-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-left: auto;
}
.app-actions button {
font-size: 0.75rem;
padding: 0.35rem 0.7rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card-bg);
color: var(--text);
cursor: pointer;
font-weight: 500;
}
.app-actions button:hover { background: var(--neutral-bg); }
.app-actions button.primary {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.app-actions button.primary:hover { filter: brightness(1.1); }
.app-actions button.danger {
color: var(--error);
border-color: var(--error);
}
.app-actions button.danger:hover { background: var(--error-bg); }
.progress-bar {
width: 100%;
height: 6px;
background: var(--neutral-bg);
border-radius: 3px;
margin-top: 0.75rem;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: 3px;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.4rem;
}
.empty {
text-align: center;
color: var(--text-secondary);
padding: 2rem 0;
}
/* Technical details panel */
.details-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
background: transparent;
border: none;
color: var(--text-secondary);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
}
.details-toggle:hover { background: transparent; color: var(--text); }
.details-content {
display: none;
padding-top: 0.5rem;
}
.details-content.open { display: block; }
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
.detail-row:last-child { border-bottom: none; }
.detail-label { color: var(--text-secondary); }
.detail-value { font-weight: 500; font-family: monospace; }
.log-console {
background: #0f172a;
color: #e2e8f0;
border-radius: 8px;
padding: 0.75rem;
font-family: "SF Mono", Consolas, monospace;
font-size: 0.75rem;
height: 160px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
margin-top: 0.75rem;
}
.log-console:empty::before {
content: "Aucun log pour le moment...";
color: #64748b;
}
/* Toolbar */
.toolbar {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.toolbar button { flex: 1; }
/* Header nav */
.nav {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.nav button {
width: auto;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
}
.spacer { flex: 1; }
/* Hidden */
.hidden { display: none !important; }
/* Spinner */
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 0.5rem;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
button.secondary .spinner {
border-color: rgba(0,0,0,0.1);
border-top-color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="container">
<div class="brand">studioE5 Agent</div>
<!-- Écran d'activation -->
<div id="activation-card" class="card hidden">
<h1>Active ton poste</h1>
<p class="subtitle">Saisis le code d'activation donné par ton enseignant.</p>
<input type="text" id="code-input" class="code-input" maxlength="6" placeholder="______" autocomplete="off">
<button id="activate-btn" onclick="activate()">Activer mon poste</button>
<div id="activation-status" class="status"></div>
<p class="help-text">Tu n'as pas de code ? Contacte ton enseignant.</p>
</div>
<!-- Tableau de bord -->
<div id="dashboard-card" class="card hidden">
<div class="nav">
<div class="spacer"></div>
<button class="ghost" onclick="showSettings()">⚙️ Paramètres</button>
</div>
<h1 id="welcome-text">Bienvenue</h1>
<p class="subtitle">État de ton poste</p>
<div id="update-banner"></div>
<div class="services">
<div class="service-item" id="svc-connection">
<div class="service-icon pending"></div>
<div>
<div class="service-text">Connexion au serveur de l'établissement</div>
<div class="service-detail" id="svc-connection-detail">Vérification...</div>
</div>
</div>
<div class="service-item" id="svc-appservice">
<div class="service-icon pending"></div>
<div>
<div class="service-text">Service d'applications</div>
<div class="service-detail" id="svc-appservice-detail">Vérification...</div>
</div>
</div>
<div class="service-item" id="svc-applications">
<div class="service-icon pending"></div>
<div>
<div class="service-text">Applications prêtes</div>
<div class="service-detail" id="svc-applications-detail">Vérification...</div>
</div>
</div>
</div>
<button onclick="showApplications()">Voir mes applications</button>
<div style="margin-top: 1rem;">
<button class="details-toggle" onclick="toggleDetails()">
<span>Détails techniques</span>
<span id="details-chevron"></span>
</button>
<div id="details-content" class="details-content">
<div class="detail-row">
<span class="detail-label">Version de l'agent</span>
<span class="detail-value" id="detail-version">-</span>
</div>
<div class="detail-row">
<span class="detail-label">Identifiant du poste</span>
<span class="detail-value" id="detail-nodeid">-</span>
</div>
<div class="detail-row">
<span class="detail-label">Serveur de l'établissement</span>
<span class="detail-value" id="detail-server">-</span>
</div>
<button class="secondary" style="margin-top: 0.75rem;" onclick="runDiagnostic()">🔄 Vérifier l'état</button>
<div id="log-console" class="log-console"></div>
</div>
</div>
</div>
<!-- Liste des applications -->
<div id="applications-card" class="card hidden">
<div class="nav">
<button class="ghost" onclick="showDashboard()">← Retour</button>
</div>
<h2>Mes applications</h2>
<div id="app-list" class="app-list">
<div class="empty">Aucune application assignée.</div>
</div>
</div>
<!-- Paramètres avancés -->
<div id="settings-card" class="card hidden">
<div class="nav">
<button class="ghost" onclick="showDashboard()">← Retour</button>
</div>
<h2>Paramètres avancés</h2>
<p class="subtitle">Ne modifie ces valeurs que si ton enseignant te le demande.</p>
<form id="settings-form" onsubmit="saveSettings(event)">
<label for="cfg-server">Serveur de l'établissement</label>
<input type="text" id="cfg-server" placeholder="wss://...">
<label for="cfg-node">Identifiant du poste</label>
<input type="text" id="cfg-node" placeholder="MON-PC">
<label for="cfg-headscale-url">URL du service de connexion sécurisée</label>
<input type="text" id="cfg-headscale-url" placeholder="https://...">
<label for="cfg-headscale-key">Clé du service de connexion sécurisée</label>
<input type="password" id="cfg-headscale-key" placeholder="hskey-auth-...">
<label for="cfg-data-dir">Répertoire de données</label>
<input type="text" id="cfg-data-dir" readonly>
<button type="submit">Enregistrer et redémarrer</button>
</form>
<div id="settings-status" class="status"></div>
</div>
<!-- État de déconnexion -->
<div id="disconnected-card" class="card hidden">
<h1>Connexion interrompue</h1>
<p class="subtitle">L'agent ne répond plus. Vérifie qu'il est bien lancé et recharge la page.</p>
<button onclick="location.reload()">Recharger la page</button>
</div>
</div>
<script>
const ws = new WebSocket('ws://' + location.host + '/ws');
let currentView = 'activation';
let studentName = '';
const instanceProgress = {}; // instanceId -> { percent, message }
const views = {
activation: document.getElementById('activation-card'),
dashboard: document.getElementById('dashboard-card'),
applications: document.getElementById('applications-card'),
settings: document.getElementById('settings-card'),
disconnected: document.getElementById('disconnected-card')
};
function showView(name) {
currentView = name;
for (const key in views) {
views[key].classList.toggle('hidden', key !== name);
}
}
ws.onopen = () => {
ws.send(JSON.stringify({action: 'check'}));
ws.send(JSON.stringify({action: 'get_status'}));
};
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
handleMessage(msg);
};
ws.onclose = () => {
showView('disconnected');
};
ws.onerror = () => {
showView('disconnected');
};
function handleMessage(msg) {
switch (msg.action) {
case 'not_activated':
showView('activation');
break;
case 'activated':
studentName = msg.studentName || '';
document.getElementById('welcome-text').textContent = studentName
? 'Bonjour ' + escapeHtml(studentName)
: 'Bienvenue';
showView('dashboard');
ws.send(JSON.stringify({action: 'instances'}));
loadSettings();
break;
case 'activation_failed':
showActivationError(msg.error || 'Code invalide');
break;
case 'instances_list':
renderApplications(msg.instances);
updateApplicationsStatus(msg.instances);
break;
case 'instances_updated':
ws.send(JSON.stringify({action: 'instances'}));
break;
case 'progress':
if (msg.instanceId) {
instanceProgress[msg.instanceId] = {
percent: msg.percent || '0',
message: msg.message || ''
};
updateInstanceProgress(msg.instanceId);
}
break;
case 'status':
updateServices(msg.status);
break;
case 'log':
appendLog(msg.message, msg.level);
break;
case 'diagnostic_result':
updateServices(msg.status);
if (msg.message) {
appendLog(msg.message, 'info');
}
break;
case 'config':
fillSettings(msg.config);
break;
case 'update_available':
fillSettings({
...getCurrentConfig(),
server_version: msg.version,
update_available: true
});
break;
case 'update_progress':
showUpdateProgress(msg.percent, msg.message);
break;
}
}
// Activation
const codeInput = document.getElementById('code-input');
codeInput.addEventListener('input', (e) => {
e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6);
});
codeInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') activate();
});
function activate() {
const code = codeInput.value.trim();
if (code.length !== 6) {
showActivationError('Le code doit faire 6 caractères.');
return;
}
const btn = document.getElementById('activate-btn');
const status = document.getElementById('activation-status');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Activation en cours...';
status.innerHTML = '';
ws.send(JSON.stringify({action: 'activate', code}));
}
function showActivationError(text) {
const btn = document.getElementById('activate-btn');
btn.disabled = false;
btn.textContent = 'Activer mon poste';
document.getElementById('activation-status').innerHTML =
'<span class="error">❌ ' + escapeHtml(text) + '</span>';
}
// Services status
function updateServices(status) {
status = status || {};
setService('connection', status.connection, status.connectionDetail);
setService('appservice', status.appService, status.appServiceDetail);
setService('applications', status.applications, status.applicationsDetail);
}
function setService(id, state, detail) {
const item = document.getElementById('svc-' + id);
const icon = item.querySelector('.service-icon');
const detailEl = document.getElementById('svc-' + id + '-detail');
icon.className = 'service-icon';
if (state === 'ok') {
icon.classList.add('ok');
icon.textContent = '✓';
} else if (state === 'warn') {
icon.classList.add('warn');
icon.textContent = '!';
} else if (state === 'error') {
icon.classList.add('error');
icon.textContent = '✕';
} else {
icon.classList.add('pending');
icon.textContent = '⏳';
}
if (detail) {
detailEl.textContent = detail;
}
}
function updateApplicationsStatus(instances) {
const ready = (instances || []).filter(i => i.status === 'running').length;
const total = (instances || []).length;
let state = 'pending';
let detail = 'Vérification...';
if (total === 0) {
state = 'ok';
detail = 'Aucune application assignée';
} else if (ready === total) {
state = 'ok';
detail = ready + ' application' + (ready > 1 ? 's' : '') + ' prête' + (ready > 1 ? 's' : '');
} else if (ready > 0) {
state = 'warn';
detail = ready + ' / ' + total + ' prête' + (ready > 1 ? 's' : '');
} else {
state = 'pending';
detail = total + ' application' + (total > 1 ? 's' : '') + ' en cours de démarrage';
}
setService('applications', state, detail);
}
// Applications list
function renderApplications(instances) {
const container = document.getElementById('app-list');
if (!instances || instances.length === 0) {
container.innerHTML = '<div class="empty">Aucune application assignée.</div>';
return;
}
container.innerHTML = instances.map(i => renderApplicationItem(i)).join('');
}
function renderApplicationItem(i) {
const badgeClass = i.status === 'running' ? 'badge-running' :
i.status === 'starting' ? 'badge-starting' :
i.status === 'error' ? 'badge-error' : 'badge-stopped';
const statusLabel = i.status === 'running' ? 'Prête' :
i.status === 'starting' ? 'En cours de démarrage' :
i.status === 'error' ? 'Erreur' : 'En pause';
const link = i.status === 'running' && i.url
? '<a class="app-link" href="' + escapeHtml(i.url) + '" target="_blank" rel="noopener">Ouvrir le site →</a>'
: '';
const name = escapeHtml(i.templateName || i.id);
const type = escapeHtml(i.type || 'Application');
const progress = instanceProgress[i.id];
const progressHtml = progress && i.status === 'starting'
? `<div class="progress-bar"><div class="progress-fill" style="width:${progress.percent}%"></div></div>
<div class="progress-text">${escapeHtml(progress.message)}</div>`
: '';
const actions = [];
if (i.status === 'running') {
actions.push(`<button class="primary" onclick="controlInstance('${i.id}', 'stop')">Arrêter</button>`);
actions.push(`<button onclick="controlInstance('${i.id}', 'reset')">Redémarrer</button>`);
} else if (i.status === 'stopped') {
actions.push(`<button class="primary" onclick="controlInstance('${i.id}', 'start')">Démarrer</button>`);
actions.push(`<button class="danger" onclick="controlInstance('${i.id}', 'delete')">Supprimer</button>`);
} else if (i.status === 'error') {
actions.push(`<button class="primary" onclick="controlInstance('${i.id}', 'reset')">Redémarrer</button>`);
actions.push(`<button class="danger" onclick="controlInstance('${i.id}', 'delete')">Supprimer</button>`);
}
const actionsHtml = actions.length ? `<div class="app-actions">${actions.join('')}</div>` : '';
return `
<div class="app-item" id="app-item-${i.id}">
<div class="app-meta">
<div class="app-name">${name}</div>
<div class="app-type">${type}</div>
${link ? '<div>' + link + '</div>' : ''}
${progressHtml}
</div>
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;margin-left:auto;">
<span class="badge ${badgeClass}">${statusLabel}</span>
${actionsHtml}
</div>
</div>
`;
}
function updateInstanceProgress(instanceId) {
const item = document.getElementById('app-item-' + instanceId);
if (!item) return;
const meta = item.querySelector('.app-meta');
const progress = instanceProgress[instanceId];
if (!progress) return;
// Remove existing progress elements
const existingBar = meta.querySelector('.progress-bar');
const existingText = meta.querySelector('.progress-text');
if (existingBar) existingBar.remove();
if (existingText) existingText.remove();
// Add updated progress
const bar = document.createElement('div');
bar.className = 'progress-bar';
bar.innerHTML = `<div class="progress-fill" style="width:${progress.percent}%"></div>`;
meta.appendChild(bar);
const text = document.createElement('div');
text.className = 'progress-text';
text.textContent = progress.message;
meta.appendChild(text);
}
// Technical details
function toggleDetails() {
const content = document.getElementById('details-content');
const chevron = document.getElementById('details-chevron');
content.classList.toggle('open');
chevron.textContent = content.classList.contains('open') ? '▲' : '▼';
}
function appendLog(message, level) {
const consoleEl = document.getElementById('log-console');
const line = document.createElement('div');
const time = new Date().toLocaleTimeString('fr-FR', {hour12: false});
let prefix = '';
if (level === 'error') prefix = '[ERR] ';
else if (level === 'warn') prefix = '[WARN] ';
else if (level === 'success') prefix = '[OK] ';
else prefix = '[INFO] ';
line.textContent = time + ' ' + prefix + message;
consoleEl.appendChild(line);
consoleEl.scrollTop = consoleEl.scrollHeight;
}
function runDiagnostic() {
ws.send(JSON.stringify({action: 'run_diagnostic'}));
appendLog('Diagnostic demandé...', 'info');
}
// Settings
async function loadSettings() {
try {
const res = await fetch('/api/config');
const cfg = await res.json();
fillSettings(cfg);
} catch (err) {
document.getElementById('settings-status').innerHTML =
'<span class="error">Erreur de chargement des paramètres</span>';
}
}
let currentConfig = {};
function getCurrentConfig() {
return currentConfig;
}
function fillSettings(cfg) {
currentConfig = cfg;
document.getElementById('cfg-server').value = cfg.server || '';
document.getElementById('cfg-node').value = cfg.node_id || '';
document.getElementById('cfg-headscale-url').value = cfg.headscale_url || '';
document.getElementById('cfg-headscale-key').value = cfg.headscale_auth_key || '';
document.getElementById('cfg-data-dir').value = cfg.data_dir || '';
document.getElementById('detail-version').textContent = cfg.version || 'dev';
document.getElementById('detail-nodeid').textContent = cfg.node_id || '-';
document.getElementById('detail-server').textContent = cfg.server || '-';
const updateBanner = document.getElementById('update-banner');
if (cfg.update_available) {
updateBanner.innerHTML = `
<div class="service-item" style="background: var(--warning-bg); border-radius: 8px; padding: 0.75rem; margin-bottom: 0.75rem;">
<div class="service-icon" style="background: var(--warning);">⬆️</div>
<div>
<div class="service-text">Mise à jour disponible</div>
<div class="service-detail">Version ${cfg.server_version} disponible. <button class="ghost" style="padding: 0; font-weight: 600;" onclick="startAgentUpdate()">Mettre à jour maintenant</button></div>
</div>
</div>
`;
} else {
updateBanner.innerHTML = '';
}
}
function showUpdateProgress(percent, message) {
const banner = document.getElementById('update-banner');
banner.innerHTML = `
<div class="service-item" style="background: var(--info-bg); border-radius: 8px; padding: 0.75rem; margin-bottom: 0.75rem;">
<div class="service-icon" style="background: var(--info);">⬇️</div>
<div>
<div class="service-text">Mise à jour en cours</div>
<div class="service-detail">${escapeHtml(message || '')} (${percent || 0}%)</div>
</div>
</div>
`;
}
async function startAgentUpdate() {
showUpdateProgress('10', 'Préparation de la mise à jour...');
try {
const res = await fetch('/api/update', {method: 'POST'});
if (!res.ok) {
showUpdateProgress('0', 'Erreur ' + res.status);
}
} catch (err) {
showUpdateProgress('0', escapeHtml(err.message));
}
}
async function saveSettings(event) {
event.preventDefault();
const status = document.getElementById('settings-status');
status.innerHTML = '<span class="info">Enregistrement...</span>';
const cfg = {
server: document.getElementById('cfg-server').value.trim(),
node_id: document.getElementById('cfg-node').value.trim(),
headscale_url: document.getElementById('cfg-headscale-url').value.trim(),
headscale_auth_key: document.getElementById('cfg-headscale-key').value.trim(),
data_dir: document.getElementById('cfg-data-dir').value.trim()
};
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(cfg)
});
if (res.ok) {
status.innerHTML = '<span class="success">✅ Enregistré. Redémarrage en cours...</span>';
await fetch('/api/restart', {method: 'POST'});
setTimeout(() => location.reload(), 3000);
} else {
status.innerHTML = '<span class="error">❌ Erreur ' + res.status + '</span>';
}
} catch (err) {
status.innerHTML = '<span class="error">❌ ' + escapeHtml(err.message) + '</span>';
}
}
function showDashboard() {
showView('dashboard');
ws.send(JSON.stringify({action: 'get_status'}));
loadSettings();
}
function controlInstance(instanceId, action) {
ws.send(JSON.stringify({action: action + '_instance', instanceId: instanceId}));
appendLog('Action "' + action + '" demandée pour ' + instanceId, 'info');
}
function showApplications() {
showView('applications');
ws.send(JSON.stringify({action: 'instances'}));
}
function showSettings() {
showView('settings');
loadSettings();
}
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
</script>
</body>
</html>