You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

526 lines
24 KiB

<!doctype html>
<html lang="fr" data-bs-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Game Server Control — Local</title>
<link rel="icon" type="image/png" href="/logo.png" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-gradient: linear-gradient(135deg, #ff77b6 0%, #7a5cff 50%, #5b2b8a 100%);
--accent-1: #ff77b6; /* pink */
--accent-2: #7a5cff; /* violet */
--panel-bg: rgba(12,14,24,0.45);
--card-bg: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(10,12,20,0.18));
--text: #eaf2ff;
--muted: #c3c8d8;
--success: #37d67a;
--danger: #ff6b6b;
}
/* Remove Bootstrap's horizontal gutters for our layout */
.container, .container-fluid, .container-lg, .container-md, .container-sm, .container-xl, .container-xxl {
--bs-gutter-x: 0; /* overrides Bootstrap spacing */
padding-left: 0 !important;
padding-right: 0 !important;
}
body { padding: 24px; background: var(--bg-gradient); color: var(--text); font-family: 'Outfit', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; min-height:100vh; }
.app-panel { background: var(--panel-bg); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-radius: 16px; padding: 20px; box-shadow: 0 6px 30px rgba(65,41,100,0.15); }
.server-card { transition: transform .12s ease; background: var(--card-bg); color: var(--text); border: none; }
.server-card:hover { transform: translateY(-6px); box-shadow: 0 10px 30px rgba(65,41,100,0.12); }
.controls button { min-width: 84px }
.muted-small { color:var(--muted); font-size:0.9rem }
.card-title { color: var(--text) }
.badge { font-weight:600 }
.brand-logo { width:72px; height:72px; object-fit:cover; object-position:center; border-radius:10px; background: rgba(255,255,255,0.02); padding:4px; border: 1px solid rgba(255,255,255,0.06) !important; }
.game-icon { width:40px; height:40px; object-fit:cover; border-radius:6px; background: rgba(255,255,255,0.02); padding:3px; }
/* Buttons and outlines to match gradient */
.btn-primary { background: var(--accent-2); border-color: var(--accent-2); color: #fff; }
.btn-primary:hover { background: linear-gradient(90deg,var(--accent-2),var(--accent-1)); border-color: transparent; }
.btn-outline-primary { color: #fff; border-color: rgba(255,255,255,0.18); }
.btn-outline-primary:hover { background: rgba(255,255,255,0.03); }
/* Subtle filled button for secondary actions (used for Logs) */
.btn-ghost {
background: rgba(255,255,255,0.04);
color: var(--text);
border: 1px solid rgba(255,255,255,0.06);
}
.btn-ghost:hover {
background: linear-gradient(90deg, rgba(122,92,255,0.12), rgba(255,119,182,0.08));
color: #fff;
border-color: rgba(255,255,255,0.12);
}
/* Ghost variants matching semantic colors */
.btn-ghost-success { background: rgba(55,214,122,0.14); color: var(--text); border-color: rgba(55,214,122,0.24); }
.btn-ghost-success:hover { background: rgba(55,214,122,0.22); color: #fff; }
.btn-ghost-warning { background: rgba(255,193,7,0.14); color: var(--text); border-color: rgba(255,193,7,0.24); }
.btn-ghost-warning:hover { background: rgba(255,193,7,0.22); color: #fff; }
.btn-ghost-danger { background: rgba(255,107,107,0.14); color: var(--text); border-color: rgba(255,107,107,0.24); }
.btn-ghost-danger:hover { background: rgba(255,107,107,0.22); color: #fff; }
/* Badges */
.bg-success { background: var(--success) !important; }
.bg-danger { background: var(--danger) !important; }
/* Cards and small elements */
.muted-small { color: var(--muted) }
/* Make bootstrap alerts slightly translucent to fit the panel */
.alert {
background-color: rgba(255,255,255,0.04) !important;
border: 1px solid rgba(255,255,255,0.06) !important;
color: var(--text) !important;
}
.alert.alert-success { background-color: rgba(55,214,122,0.10) !important; color: var(--text) !important; }
.alert.alert-danger { background-color: rgba(255,107,107,0.10) !important; color: var(--text) !important; }
.alert.alert-warning { background-color: rgba(255,193,7,0.10) !important; color: var(--text) !important; }
.alert.alert-info { background-color: rgba(123,97,255,0.08) !important; color: var(--text) !important; }
</style>
</head>
<body>
<div class="container">
<div class="app-panel">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center">
<img src="/logo.png" alt="logo" class="brand-logo me-3" />
<div>
<h1 class="h3 mb-0">Contrôle des serveurs</h1>
<div class="muted-small">Interface locale — utilisation en test / mock si Docker absent</div>
</div>
</div>
<div class="text-end">
<div class="btn-group" role="group" aria-label="actions">
<button id="refreshBtn" class="btn btn-outline-primary" title="Rafraîchir"><i class="bi-arrow-clockwise"></i> Rafraîchir</button>
<button id="mockToggle" class="btn btn-outline-secondary" title="Basculer mock">Mode mock</button>
</div>
</div>
</div>
<div id="alertArea"></div>
<div class="mb-3">
<h5 class="mb-2">Pods Docker</h5>
</div>
<div id="servers" class="row gy-3"></div>
<!-- System stats panel -->
<div class="mt-4">
<h5 class="mb-3">Statistiques machine</h5>
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="card server-card p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<strong>CPU %</strong>
<div class="muted-small">Utilisation processeur</div>
</div>
<div id="cpuNow" class="muted-small">--%</div>
</div>
<canvas id="cpuChart" height="100"></canvas>
</div>
</div>
<div class="col-12 col-md-6">
<div class="card server-card p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<strong>RAM %</strong>
<div class="muted-small">Mémoire utilisée</div>
</div>
<div id="memNow" class="muted-small">--%</div>
</div>
<canvas id="memChart" height="100"></canvas>
</div>
</div>
<div class="col-12 mt-3">
<div class="card server-card p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<strong>Températures</strong>
<div class="muted-small">Capteurs disponibles</div>
</div>
</div>
<div id="tempsArea" class="muted-small">--</div>
</div>
</div>
</div>
</div>
<!-- <footer class="mt-4 text-muted small">Ne pas exposer sur Internet — pas d'authentification.</footer> -->
</div>
</div>
<script>
// Simple, dependency-free UI. Uses Bootstrap for layout.
let useMock = false;
const sampleData = [
{ id: 'aaaaaaaaaaaa', short_id:'aaaaaaaaaaaa', name:'minecraft', image:'itzg/minecraft-server:latest', status:'running', players: 12, maxPlayers:20, ports:['25565'] },
{ id: 'bbbbbbbbbbbb', short_id:'bbbbbbbbbbbb', name:'csgo', image:'steamcmd/csgo:1.0', status:'exited', players:0, maxPlayers:16, ports:['27015'] },
{ id: 'cccccccccccc', short_id:'cccccccccccc', name:'valheim', image:'lloesche/valheim-server:latest', status:'running', players:3, maxPlayers:10, ports:['2456-2458'] },
];
function showAlert(msg, type='info', timeout=3500){
const id = 'a'+Date.now();
const el = document.createElement('div');
el.id = id;
el.className = `alert alert-${type} alert-dismissible fade show`;
el.role = 'alert';
el.innerHTML = `${msg} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`;
document.getElementById('alertArea').appendChild(el);
if (timeout) setTimeout(()=>{ const e=document.getElementById(id); if(e){ e.classList.remove('show'); e.classList.add('hide'); e.remove(); } }, timeout);
}
async function fetchContainers(){
try{
const resp = await fetch('/api/containers');
if (!resp.ok) throw new Error('API non disponible');
const data = await resp.json();
if (data.error) throw new Error(data.error);
return data;
}catch(e){
if (useMock) return sampleData;
throw e;
}
}
function slugify(name){
if(!name) return 'unknown';
let s = String(name).toLowerCase();
s = s.replace(/[^a-z0-9]+/g, '-');
s = s.replace(/-+/g, '-');
s = s.replace(/(^-|-$)/g, '');
if(!s) return 'unknown';
return s.slice(0,200);
}
function renderServers(list){
const container = document.getElementById('servers');
container.innerHTML = '';
if (!list || list.length===0){
container.innerHTML = '<div class="col-12"><div class="alert alert-secondary">Aucun conteneur trouvé.</div></div>';
return;
}
list.forEach(s => {
const col = document.createElement('div');
col.className = 'col-12 col-md-6 col-xl-4';
const card = document.createElement('div');
card.className = 'card server-card shadow-sm h-100';
card.innerHTML = `
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="d-flex align-items-center">
<img src="/game_logos/${slugify(s.name)}.png" class="game-icon me-2" onerror="this.onerror=null;this.src='/default_game.svg'" />
<div>
<h5 class="card-title mb-0">${s.name}</h5>
<div class="muted-small">${s.image} · <small><code>${s.short_id}</code></small></div>
</div>
</div>
<div class="text-end">
<span class="badge ${s.status==='running' ? 'bg-success' : 'bg-danger'} text-white">${s.status}</span>
</div>
</div>
<div class="mb-3">
<div class="d-flex gap-2 align-items-center">
<div><i class="bi-people-fill"></i> <strong>${s.players ?? 0}</strong>/<small>${s.maxPlayers ?? '-'}</small></div>
<div class="muted-small">Ports: ${ (s.ports || []).join(', ') || '-' }</div>
</div>
</div>
<div class="mt-auto d-flex justify-content-between align-items-center">
<div class="controls btn-group" role="group">
<button class="btn btn-sm btn-ghost btn-ghost-success" data-action="start"><i class="bi-play-fill"></i>&nbsp;Start</button>
<button class="btn btn-sm btn-ghost btn-ghost-warning" data-action="restart"><i class="bi-arrow-repeat"></i>&nbsp;Restart</button>
<button class="btn btn-sm btn-ghost btn-ghost-danger" data-action="stop"><i class="bi-stop-fill"></i>&nbsp;Stop</button>
</div>
<div class="d-flex align-items-center">
<button class="btn btn-sm btn-ghost ms-3" data-action="logs"><i class="bi-file-earmark-text"></i>&nbsp;Logs</button>
</div>
</div>
</div>
`;
// attach handlers for start/restart/stop
card.querySelectorAll('button[data-action]').forEach(btn => {
const action = btn.getAttribute('data-action');
if (action === 'logs') return; // handled separately
btn.addEventListener('click', async (ev)=>{
await doAction(s.short_id, action, btn);
});
});
// logs button
const logsBtn = card.querySelector('button[data-action="logs"]');
if (logsBtn){
logsBtn.addEventListener('click', ()=> openLogs(s));
}
col.appendChild(card);
container.appendChild(col);
});
}
async function doAction(id, action, btn){
btn.disabled = true;
try{
if (useMock){
showAlert(`(mock) ${action} pour ${id}`, 'info');
} else {
const resp = await fetch(`/api/containers/${id}/${action}`, { method:'POST' });
const data = await resp.json();
if (data.error) throw new Error(data.error);
showAlert(`${action} envoyé à ${id}`, 'success');
}
}catch(e){
showAlert(`Erreur: ${e.message}`, 'danger');
}finally{
btn.disabled = false;
setTimeout(load, 700);
}
}
async function load(){
try{
const containers = await fetchContainers();
renderServers(containers);
}catch(e){
showAlert('API Docker non disponible — bascule en mode mock possible.', 'warning', 5000);
renderServers(useMock ? sampleData : []);
}
}
document.getElementById('refreshBtn').addEventListener('click', ()=>load());
document.getElementById('mockToggle').addEventListener('click', function(){
useMock = !useMock;
this.classList.toggle('btn-primary', useMock);
this.classList.toggle('btn-outline-secondary', !useMock);
this.textContent = useMock ? 'Mock: ON' : 'Mode mock';
load();
});
// initial load
load();
// sample mock logs
const sampleLogs = {
'minecraft': `[2025-12-02 12:00:01] Server started\n[2025-12-02 12:01:34] Player1 joined\n[2025-12-02 12:05:12] Player2 left`,
'csgo': `[2025-12-01 20:12:11] Server launched\n[2025-12-01 20:15:02] Map change to de_dust2`,
'valheim': `[2025-11-30 18:02:55] World saved\n[2025-11-30 18:05:02] Player3 disconnected`
};
let currentLogId = null;
let _logFollowInterval = null;
function stopLogFollow(){
if (_logFollowInterval){
clearInterval(_logFollowInterval);
_logFollowInterval = null;
}
}
function startLogFollow(){
stopLogFollow();
const tail = document.getElementById('logsTail') ? parseInt(document.getElementById('logsTail').textContent||'200') : 200;
_logFollowInterval = setInterval(async ()=>{
if (!currentLogId) return;
try{
const resp = await fetch(`/api/containers/${currentLogId}/logs?tail=${tail}`);
if (!resp.ok) return;
const data = await resp.json();
if (!data.error) document.getElementById('logsContent').textContent = data.logs || '';
}catch(e){ /* ignore polling errors */ }
}, 2000);
}
async function openLogs(s){
currentLogId = s.short_id;
document.getElementById('logsModalLabel').textContent = `Logs — ${s.name}`;
const content = document.getElementById('logsContent');
content.textContent = 'Chargement...';
if (useMock){
content.textContent = sampleLogs[s.name] || `(mock) pas de logs pour ${s.name}`;
} else {
try{
const tail = document.getElementById('logsTail') ? parseInt(document.getElementById('logsTail').textContent||'200') : 200;
const resp = await fetch(`/api/containers/${s.short_id}/logs?tail=${tail}`);
if (!resp.ok) throw new Error('API logs non disponible');
const data = await resp.json();
if (data.error) content.textContent = `Erreur: ${data.error}`;
else content.textContent = data.logs || '';
}catch(e){
content.textContent = `Erreur: ${e.message || e}`;
}
}
const modalEl = document.getElementById('logsModal');
const modal = new bootstrap.Modal(modalEl);
modal.show();
// when modal shown, if follow checkbox is checked start polling
const followEl = document.getElementById('followLogs');
if (followEl && followEl.checked){
startLogFollow();
}
// stop follow when modal hidden
modalEl.addEventListener('hidden.bs.modal', ()=>{ stopLogFollow(); currentLogId = null; });
}
function appendLog(text){
const content = document.getElementById('logsContent');
content.textContent = (content.textContent || '') + '\n' + text;
content.scrollTop = content.scrollHeight;
}
async function runExec(){
const btn = document.getElementById('execRunBtn');
const cmdEl = document.getElementById('execCmd');
const cmd = (cmdEl.value || '').trim();
if (!cmd) return;
btn.disabled = true;
if (useMock){
appendLog(`(mock) $ ${cmd}\n> sortie simulée pour '${cmd}'`);
} else {
try{
const resp = await fetch(`/api/containers/${currentLogId}/exec`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({cmd}) });
const data = await resp.json();
if (data.error) appendLog(`Erreur: ${data.error}`);
else appendLog(`$ ${cmd}\n${data.output || ''}\n(exit ${data.exit_code})`);
}catch(e){
appendLog(`Erreur: ${e.message || e}`);
}
}
btn.disabled = false;
cmdEl.value = '';
// after running a command, if follow is enabled refresh immediately
if (document.getElementById('followLogs') && document.getElementById('followLogs').checked){
// trigger an immediate fetch
try{
const tail = document.getElementById('logsTail') ? parseInt(document.getElementById('logsTail').textContent||'200') : 200;
const resp2 = await fetch(`/api/containers/${currentLogId}/logs?tail=${tail}`);
if (resp2.ok){
const d2 = await resp2.json();
if (!d2.error) document.getElementById('logsContent').textContent = d2.logs || '';
}
}catch(e){ }
}
}
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
// System stats charts
const statsMaxPoints = 30;
const cpuData = Array(statsMaxPoints).fill(0);
const memData = Array(statsMaxPoints).fill(0);
const labels = Array.from({length: statsMaxPoints}, (_,i)=>'');
const ctxCpu = document.getElementById('cpuChart')?.getContext('2d');
const ctxMem = document.getElementById('memChart')?.getContext('2d');
let cpuChart = null;
let memChart = null;
function createCharts(){
if (ctxCpu){
cpuChart = new Chart(ctxCpu, {
type: 'line',
data: { labels, datasets: [{ label: 'CPU %', data: cpuData, borderColor: 'rgba(122,92,255,0.9)', backgroundColor: 'rgba(122,92,255,0.12)', tension: 0.25 }] },
options: { animation:false, responsive:true, plugins:{legend:{display:false}}, scales:{y:{min:0, max:100}} }
});
}
if (ctxMem){
memChart = new Chart(ctxMem, {
type: 'line',
data: { labels, datasets: [{ label: 'RAM %', data: memData, borderColor: 'rgba(55,214,122,0.9)', backgroundColor: 'rgba(55,214,122,0.08)', tension: 0.25 }] },
options: { animation:false, responsive:true, plugins:{legend:{display:false}}, scales:{y:{min:0, max:100}} }
});
}
}
// mock stats generator
function mockStats(){
return { cpu_percent: Math.round(20 + Math.random()*60), mem_percent: Math.round(30 + Math.random()*50), temperatures: {cpu:[{label:'CPU', current: 45 + Math.round(Math.random()*20)}]} };
}
async function fetchStats(){
if (useMock) {
return mockStats();
}
try{
const resp = await fetch('/api/stats');
if (!resp.ok) return null;
const data = await resp.json();
if (data.error) return null;
return data;
}catch(e){ return null }
}
async function updateStats(){
const s = await fetchStats();
if (!s) return;
// update displays
const cpu = Math.round(Number(s.cpu_percent) || 0);
const mem = Math.round(Number(s.mem_percent) || 0);
document.getElementById('cpuNow').textContent = cpu + '%';
document.getElementById('memNow').textContent = mem + '%';
cpuData.push(cpu); cpuData.shift();
memData.push(mem); memData.shift();
if (cpuChart) { cpuChart.data.datasets[0].data = cpuData; cpuChart.update('none'); }
if (memChart) { memChart.data.datasets[0].data = memData; memChart.update('none'); }
// temperatures
const ta = document.getElementById('tempsArea');
try{
const t = s.temperatures || {};
const parts = [];
for (const k of Object.keys(t)){
const arr = t[k] || [];
for (const it of arr){
parts.push(`${it.label || k}: ${it.current ?? '?'}°C`);
}
}
ta.textContent = parts.length ? parts.join(' · ') : 'Aucun capteur détecté';
}catch(e){ ta.textContent = 'N/A' }
}
// start charts and polling
createCharts();
setInterval(updateStats, 2000);
// initial fetch
updateStats();
</script>
<!-- Logs modal -->
<div class="modal fade" id="logsModal" tabindex="-1" aria-labelledby="logsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content bg-dark text-light" style="border:1px solid rgba(255,255,255,0.04)">
<div class="modal-header">
<h5 class="modal-title" id="logsModalLabel">Logs</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="followLogs" />
<label class="form-check-label muted-small" for="followLogs">Follow</label>
</div>
<div class="muted-small">Dernières lignes: <span id="logsTail">200</span></div>
</div>
<pre id="logsContent" style="white-space: pre-wrap; max-height:50vh; overflow:auto; background: rgba(0,0,0,0.6); padding:12px; border-radius:6px;">Logs...</pre>
<div class="input-group mt-3">
<input id="execCmd" class="form-control" placeholder="Commande à exécuter (ex: bash -lc 'echo hello')" />
<button id="execRunBtn" class="btn btn-primary" type="button" onclick="runExec()">Run</button>
</div>
</div>
<div class="modal-footer">
<small class="text-muted">Exécuter une commande utilisera Docker exec côté serveur. Mode mock affiche une sortie simulée.</small>
</div>
</div>
</div>
</div>
</body>
</html>