1092 lines
34 KiB
HTML
1092 lines
34 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);
|
|
}
|
|
|
|
.spin {
|
|
display: inline-block;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
.progress-bar {
|
|
background: rgba(0,0,0,0.1);
|
|
border-radius: 4px;
|
|
height: 6px;
|
|
margin-top: 0.5rem;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-bar > div {
|
|
background: var(--info);
|
|
height: 100%;
|
|
transition: width 0.3s ease;
|
|
}
|
|
</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>
|
|
|
|
<label for="cfg-proxy-mode">Mode proxy</label>
|
|
<select id="cfg-proxy-mode">
|
|
<option value="disabled">Désactivé</option>
|
|
<option value="auto">Automatique (recommandé)</option>
|
|
<option value="enabled">Activé</option>
|
|
</select>
|
|
|
|
<label for="cfg-proxy-url">URL du proxy</label>
|
|
<input type="text" id="cfg-proxy-url" placeholder="http://10.0.0.5:3128">
|
|
|
|
<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>
|
|
let ws;
|
|
let currentView = 'activation';
|
|
let studentName = '';
|
|
let updateInProgress = false;
|
|
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);
|
|
}
|
|
}
|
|
|
|
function connectWebSocket() {
|
|
ws = new WebSocket('ws://' + location.host + '/ws');
|
|
|
|
ws.onopen = () => {
|
|
if (updateInProgress) {
|
|
hideUpdateOverlay();
|
|
updateInProgress = false;
|
|
}
|
|
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 = () => {
|
|
if (updateInProgress) {
|
|
setTimeout(connectWebSocket, 2000);
|
|
} else {
|
|
showView('disconnected');
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
if (!updateInProgress) {
|
|
showView('disconnected');
|
|
}
|
|
};
|
|
}
|
|
|
|
connectWebSocket();
|
|
|
|
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('cfg-proxy-mode').value = cfg.proxy_mode || 'disabled';
|
|
document.getElementById('cfg-proxy-url').value = cfg.proxy_url || '';
|
|
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');
|
|
const pct = parseInt(percent || '0', 10);
|
|
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);"><span class="spin">↻</span></div>
|
|
<div style="flex: 1;">
|
|
<div class="service-text">Mise à jour en cours</div>
|
|
<div class="service-detail">${escapeHtml(message || '')}</div>
|
|
<div class="progress-bar"><div style="width: ${pct}%"></div></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.getElementById('update-overlay-bar').style.width = pct + '%';
|
|
document.getElementById('update-overlay-message').textContent = message || 'Mise à jour en cours...';
|
|
}
|
|
|
|
function showUpdateOverlay() {
|
|
updateInProgress = true;
|
|
document.getElementById('update-overlay').classList.remove('hidden');
|
|
}
|
|
|
|
function hideUpdateOverlay() {
|
|
updateInProgress = false;
|
|
document.getElementById('update-overlay').classList.add('hidden');
|
|
}
|
|
|
|
async function startAgentUpdate() {
|
|
showUpdateOverlay();
|
|
showUpdateProgress('10', 'Préparation de la mise à jour...');
|
|
try {
|
|
const res = await fetch('/api/update', {method: 'POST'});
|
|
if (!res.ok) {
|
|
hideUpdateOverlay();
|
|
showUpdateProgress('0', 'Erreur ' + res.status);
|
|
}
|
|
} catch (err) {
|
|
hideUpdateOverlay();
|
|
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(),
|
|
proxy_mode: document.getElementById('cfg-proxy-mode').value,
|
|
proxy_url: document.getElementById('cfg-proxy-url').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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
</script>
|
|
|
|
<!-- Update in progress overlay -->
|
|
<div id="update-overlay" class="hidden" style="position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: flex; align-items: center; justify-content: center; z-index: 1000;">
|
|
<div class="card" style="max-width: 400px; text-align: center;">
|
|
<div class="service-icon" style="background: var(--info); margin: 0 auto 1rem; width: 48px; height: 48px; font-size: 1.5rem;"><span class="spin">↻</span></div>
|
|
<h2>Mise à jour en cours</h2>
|
|
<p class="subtitle">L'agent se redémarre. Cette page se reconnectera automatiquement.</p>
|
|
<div class="progress-bar"><div id="update-overlay-bar" style="width: 0%"></div></div>
|
|
<p id="update-overlay-message" class="service-detail" style="margin-top: 0.75rem;">Préparation...</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|