commit
7d938b4526
15 changed files with 967 additions and 0 deletions
@ -0,0 +1,60 @@ |
|||||||
|
# docker-web-ui |
||||||
|
|
||||||
|
Interface web locale pour démarrer / arrêter / redémarrer des conteneurs Docker (pratique pour des serveurs de jeu locaux). |
||||||
|
|
||||||
|
Prérequis |
||||||
|
- Docker installé et le démon en cours d'exécution |
||||||
|
- Soit exécuter l'app avec un utilisateur dans le groupe `docker`, soit lancer avec `sudo` pour avoir accès au socket Docker |
||||||
|
|
||||||
|
Installation |
||||||
|
|
||||||
|
```bash |
||||||
|
cd /home/wsl/docker-web-ui |
||||||
|
python3 -m venv .venv |
||||||
|
source .venv/bin/activate |
||||||
|
pip install -r requirements.txt |
||||||
|
``` |
||||||
|
|
||||||
|
Lancement |
||||||
|
|
||||||
|
```bash |
||||||
|
# lancer le serveur Flask (local) |
||||||
|
python app.py |
||||||
|
``` |
||||||
|
|
||||||
|
Tester l'interface sans Docker (mock) |
||||||
|
|
||||||
|
La nouvelle interface détecte si l'API Docker n'est pas disponible et peut fonctionner en **mode mock** pour la démo. Active le mode mock en cliquant sur le bouton "Mode mock" en haut à droite. |
||||||
|
|
||||||
|
Ouvrez http://localhost:5000 dans votre navigateur. |
||||||
|
|
||||||
|
Sécurité |
||||||
|
|
||||||
|
- Cette interface n'a aucune authentification/autorisation. NE PAS l'exposer sur Internet. |
||||||
|
- Usage prévu : réseau local de confiance ou machine locale. |
||||||
|
|
||||||
|
Authentification (mot de passe) |
||||||
|
|
||||||
|
La version actuelle peut être configurée pour exiger un mot de passe. Deux options : |
||||||
|
|
||||||
|
- Créer un hash de mot de passe persistant (recommandé) : |
||||||
|
|
||||||
|
```bash |
||||||
|
cd /home/wsl/docker-web-ui |
||||||
|
python set_password.py |
||||||
|
# entrez et confirmez le mot de passe — ceci crée auth.json |
||||||
|
``` |
||||||
|
|
||||||
|
- Ou définir la variable d'environnement `ADMIN_PASSWORD` avant de lancer l'app (moins sûr) : |
||||||
|
|
||||||
|
```bash |
||||||
|
export ADMIN_PASSWORD='votre_mot_de_passe' |
||||||
|
python app.py |
||||||
|
``` |
||||||
|
|
||||||
|
Le hash peut aussi être fourni directement via `ADMIN_PASSWORD_HASH`. |
||||||
|
|
||||||
|
Remarques de sécurité |
||||||
|
|
||||||
|
- Le serveur utilise des sessions signées par `FLASK_SECRET_KEY` (généré et persisté automatiquement dans `.secret_key` si absent). Pour plus de sécurité, définissez `FLASK_SECRET_KEY` dans l'environnement. |
||||||
|
- Pour une sécurité réelle, exécutez derrière un reverse-proxy TLS (HTTPS) et n'exposez pas l'interface sur Internet. |
||||||
@ -0,0 +1,243 @@ |
|||||||
|
from flask import Flask, jsonify, request, redirect, url_for, render_template, session |
||||||
|
from flask_cors import CORS |
||||||
|
import docker |
||||||
|
import os |
||||||
|
import json |
||||||
|
from functools import wraps |
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash |
||||||
|
import psutil |
||||||
|
import logging |
||||||
|
|
||||||
|
|
||||||
|
# Helper: load password hash from auth file or env |
||||||
|
PROJECT_ROOT = os.path.dirname(__file__) |
||||||
|
AUTH_FILE = os.path.join(PROJECT_ROOT, 'auth.json') |
||||||
|
SECRET_FILE = os.path.join(PROJECT_ROOT, '.secret_key') |
||||||
|
|
||||||
|
# Setup logging (console + file) — placed after PROJECT_ROOT is known |
||||||
|
LOGFILE = os.path.join(PROJECT_ROOT, 'webui.log') |
||||||
|
logger = logging.getLogger('docker-web-ui') |
||||||
|
if not logger.handlers: |
||||||
|
logger.setLevel(logging.INFO) |
||||||
|
fh = logging.FileHandler(LOGFILE) |
||||||
|
fh.setLevel(logging.INFO) |
||||||
|
ch = logging.StreamHandler() |
||||||
|
ch.setLevel(logging.INFO) |
||||||
|
fmt = logging.Formatter('%(asctime)s %(levelname)s %(message)s') |
||||||
|
fh.setFormatter(fmt) |
||||||
|
ch.setFormatter(fmt) |
||||||
|
logger.addHandler(fh) |
||||||
|
logger.addHandler(ch) |
||||||
|
|
||||||
|
|
||||||
|
def load_password_hash(): |
||||||
|
# priority: auth.json file, then env ADMIN_PASSWORD_HASH, then env ADMIN_PASSWORD |
||||||
|
if os.path.exists(AUTH_FILE): |
||||||
|
try: |
||||||
|
with open(AUTH_FILE, 'r') as f: |
||||||
|
data = json.load(f) |
||||||
|
return data.get('password_hash') |
||||||
|
except Exception: |
||||||
|
return None |
||||||
|
ph = os.environ.get('ADMIN_PASSWORD_HASH') |
||||||
|
if ph: |
||||||
|
return ph |
||||||
|
pwd = os.environ.get('ADMIN_PASSWORD') |
||||||
|
if pwd: |
||||||
|
return generate_password_hash(pwd) |
||||||
|
return None |
||||||
|
|
||||||
|
|
||||||
|
def get_secret_key(): |
||||||
|
# persistent secret key for sessions |
||||||
|
if os.path.exists(SECRET_FILE): |
||||||
|
with open(SECRET_FILE, 'rb') as f: |
||||||
|
return f.read() |
||||||
|
# generate and persist |
||||||
|
v = os.urandom(24) |
||||||
|
try: |
||||||
|
with open(SECRET_FILE, 'wb') as f: |
||||||
|
f.write(v) |
||||||
|
except Exception: |
||||||
|
pass |
||||||
|
return v |
||||||
|
|
||||||
|
app = Flask(__name__, static_folder='static', static_url_path='/') |
||||||
|
CORS(app) |
||||||
|
|
||||||
|
# set secret key |
||||||
|
app.secret_key = os.environ.get('FLASK_SECRET_KEY') or get_secret_key() |
||||||
|
|
||||||
|
# load password hash |
||||||
|
PASSWORD_HASH = load_password_hash() |
||||||
|
|
||||||
|
# NOTE: dynamic logo lookup and external API usage removed. |
||||||
|
# The frontend will use local static files in `static/game_logos/<slug>.png` and |
||||||
|
# fall back to `/default_game.svg` when a local image is not present. |
||||||
|
|
||||||
|
|
||||||
|
def login_required(f): |
||||||
|
@wraps(f) |
||||||
|
def wrapper(*args, **kwargs): |
||||||
|
if not session.get('logged_in'): |
||||||
|
return redirect(url_for('login', next=request.path)) |
||||||
|
return f(*args, **kwargs) |
||||||
|
return wrapper |
||||||
|
|
||||||
|
try: |
||||||
|
client = docker.from_env() |
||||||
|
except Exception: |
||||||
|
client = None |
||||||
|
|
||||||
|
|
||||||
|
@app.route('/') |
||||||
|
@login_required |
||||||
|
def index(): |
||||||
|
return app.send_static_file('index.html') |
||||||
|
|
||||||
|
|
||||||
|
@app.route('/login', methods=['GET', 'POST']) |
||||||
|
def login(): |
||||||
|
global PASSWORD_HASH |
||||||
|
if request.method == 'POST': |
||||||
|
pwd = request.form.get('password', '') |
||||||
|
if PASSWORD_HASH and check_password_hash(PASSWORD_HASH, pwd): |
||||||
|
session['logged_in'] = True |
||||||
|
nxt = request.args.get('next') or url_for('index') |
||||||
|
return redirect(nxt) |
||||||
|
else: |
||||||
|
return render_template('login.html', error='Mot de passe invalide') |
||||||
|
return render_template('login.html', error=None) |
||||||
|
|
||||||
|
|
||||||
|
@app.route('/logout') |
||||||
|
def logout(): |
||||||
|
session.pop('logged_in', None) |
||||||
|
return redirect(url_for('login')) |
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/containers', methods=['GET']) |
||||||
|
@login_required |
||||||
|
def list_containers(): |
||||||
|
if client is None: |
||||||
|
return jsonify({'error': 'Docker client not available'}), 500 |
||||||
|
containers = client.containers.list(all=True) |
||||||
|
res = [] |
||||||
|
for c in containers: |
||||||
|
tag = None |
||||||
|
try: |
||||||
|
tag = c.image.tags[0] if c.image.tags else None |
||||||
|
except Exception: |
||||||
|
tag = None |
||||||
|
res.append({ |
||||||
|
'id': c.id, |
||||||
|
'short_id': c.id[:12], |
||||||
|
'name': c.name, |
||||||
|
'status': c.status, |
||||||
|
'image': tag or str(c.image), |
||||||
|
}) |
||||||
|
return jsonify(res) |
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/containers/<id_or_name>/<action>', methods=['POST']) |
||||||
|
@login_required |
||||||
|
def container_action(id_or_name, action): |
||||||
|
if client is None: |
||||||
|
return jsonify({'error': 'Docker client not available'}), 500 |
||||||
|
try: |
||||||
|
container = client.containers.get(id_or_name) |
||||||
|
except Exception as e: |
||||||
|
return jsonify({'error': f'Container not found: {e}'}), 404 |
||||||
|
|
||||||
|
try: |
||||||
|
if action == 'start': |
||||||
|
container.start() |
||||||
|
elif action == 'stop': |
||||||
|
container.stop() |
||||||
|
elif action == 'restart': |
||||||
|
container.restart() |
||||||
|
else: |
||||||
|
return jsonify({'error': 'Invalid action'}), 400 |
||||||
|
except Exception as e: |
||||||
|
return jsonify({'error': str(e)}), 500 |
||||||
|
|
||||||
|
return jsonify({'result': 'ok', 'id': container.id, 'status': container.status}) |
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/containers/<id_or_name>/logs', methods=['GET']) |
||||||
|
@login_required |
||||||
|
def container_logs(id_or_name): |
||||||
|
if client is None: |
||||||
|
return jsonify({'error': 'Docker client not available'}), 500 |
||||||
|
tail = request.args.get('tail') or request.args.get('lines') or '200' |
||||||
|
try: |
||||||
|
c = client.containers.get(id_or_name) |
||||||
|
# get last `tail` lines (docker SDK accepts tail as int) |
||||||
|
logs = c.logs(tail=int(tail), stdout=True, stderr=True) |
||||||
|
if isinstance(logs, bytes): |
||||||
|
logs = logs.decode('utf-8', errors='replace') |
||||||
|
return jsonify({'logs': logs}) |
||||||
|
except Exception as e: |
||||||
|
return jsonify({'error': str(e)}), 500 |
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/containers/<id_or_name>/exec', methods=['POST']) |
||||||
|
@login_required |
||||||
|
def container_exec(id_or_name): |
||||||
|
if client is None: |
||||||
|
return jsonify({'error': 'Docker client not available'}), 500 |
||||||
|
data = request.get_json() or {} |
||||||
|
cmd = data.get('cmd') or data.get('command') |
||||||
|
if not cmd: |
||||||
|
return jsonify({'error': 'No command provided'}), 400 |
||||||
|
try: |
||||||
|
c = client.containers.get(id_or_name) |
||||||
|
# exec_run may return an ExecResult or (exit_code, output) |
||||||
|
res = c.exec_run(cmd, stdout=True, stderr=True) |
||||||
|
exit_code = None |
||||||
|
output = '' |
||||||
|
try: |
||||||
|
exit_code = getattr(res, 'exit_code', None) |
||||||
|
output = getattr(res, 'output', None) |
||||||
|
except Exception: |
||||||
|
pass |
||||||
|
if exit_code is None: |
||||||
|
# try tuple form |
||||||
|
try: |
||||||
|
exit_code, output = res |
||||||
|
except Exception: |
||||||
|
exit_code = -1 |
||||||
|
if isinstance(output, bytes): |
||||||
|
output = output.decode('utf-8', errors='replace') |
||||||
|
return jsonify({'exit_code': exit_code, 'output': output}) |
||||||
|
except Exception as e: |
||||||
|
return jsonify({'error': str(e)}), 500 |
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/stats', methods=['GET']) |
||||||
|
@login_required |
||||||
|
def system_stats(): |
||||||
|
# return CPU%, RAM%, and temperatures (if available) |
||||||
|
try: |
||||||
|
cpu = psutil.cpu_percent(interval=0.1) |
||||||
|
vm = psutil.virtual_memory() |
||||||
|
mem = vm.percent |
||||||
|
temps = {} |
||||||
|
try: |
||||||
|
t = psutil.sensors_temperatures() |
||||||
|
for k, v in (t or {}).items(): |
||||||
|
# take first readable entry |
||||||
|
if v: |
||||||
|
temps[k] = [] |
||||||
|
for si in v: |
||||||
|
temps[k].append({'label': si.label or k, 'current': getattr(si, 'current', None)}) |
||||||
|
except Exception: |
||||||
|
temps = {} |
||||||
|
|
||||||
|
return jsonify({'cpu_percent': cpu, 'mem_percent': mem, 'temperatures': temps}) |
||||||
|
except Exception as e: |
||||||
|
return jsonify({'error': str(e)}), 500 |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': |
||||||
|
app.run(host='0.0.0.0', port=5000, debug=True) |
||||||
@ -0,0 +1 @@ |
|||||||
|
{"password_hash": "scrypt:32768:8:1$7i80I5RvRIL8VNBI$f7025803cea855aa2959533d285bbf26caca76ed13ac9e56c4882efac07a45418ba9d79ba9f1dae555d20c6d30a6b9010e67bb2b01a68be1ab2dbd29c84cd6b0"} |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
Flask>=2.0 |
||||||
|
docker>=6.0 |
||||||
|
Flask-Cors>=3.0 |
||||||
|
psutil>=5.9 |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Utility to set the admin password for docker-web-ui. |
||||||
|
It writes a JSON file `auth.json` containing a PBKDF2-SHA256 password hash. |
||||||
|
|
||||||
|
Usage: python set_password.py |
||||||
|
Then enter the desired password when prompted. |
||||||
|
""" |
||||||
|
import getpass |
||||||
|
import json |
||||||
|
import os |
||||||
|
from werkzeug.security import generate_password_hash |
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(__file__) |
||||||
|
AUTH_FILE = os.path.join(PROJECT_ROOT, 'auth.json') |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
print('Create admin password for docker-web-ui') |
||||||
|
while True: |
||||||
|
pwd = getpass.getpass('Password: ') |
||||||
|
if not pwd: |
||||||
|
print('Empty password not allowed') |
||||||
|
continue |
||||||
|
pwd2 = getpass.getpass('Confirm: ') |
||||||
|
if pwd != pwd2: |
||||||
|
print('Passwords do not match, try again.') |
||||||
|
continue |
||||||
|
break |
||||||
|
|
||||||
|
ph = generate_password_hash(pwd) |
||||||
|
data = {'password_hash': ph} |
||||||
|
with open(AUTH_FILE, 'w') as f: |
||||||
|
json.dump(data, f) |
||||||
|
print(f'Wrote password hash to {AUTH_FILE}') |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': |
||||||
|
main() |
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 141 KiB |
@ -0,0 +1,526 @@ |
|||||||
|
<!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> Start</button> |
||||||
|
<button class="btn btn-sm btn-ghost btn-ghost-warning" data-action="restart"><i class="bi-arrow-repeat"></i> Restart</button> |
||||||
|
<button class="btn btn-sm btn-ghost btn-ghost-danger" data-action="stop"><i class="bi-stop-fill"></i> 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> 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> |
||||||
|
After Width: | Height: | Size: 1.5 MiB |
@ -0,0 +1,56 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html lang="fr" data-bs-theme="dark"> |
||||||
|
<!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>Connexion — Game Server Control</title> |
||||||
|
<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%); --card: rgba(10,12,20,0.6); --muted: #9aa4b2; } |
||||||
|
body { font-family: 'Outfit', system-ui, -apple-system, 'Segoe UI', Roboto; background: var(--bg-gradient); color:#e6eef8; min-height:100vh; } |
||||||
|
.card { background: var(--card); backdrop-filter: blur(6px); border: none; border-radius: 12px; } |
||||||
|
.logo-circle { width:84px; height:84px; border-radius:50%; background: linear-gradient(135deg,#0d6efd22,#6f42c122); display:flex; align-items:center; justify-content:center; margin:0 auto 12px auto; } |
||||||
|
.logo-circle .bi { font-size:36px; color:#cfe8ff; } |
||||||
|
.small-muted { color: var(--muted); font-size:0.92rem } |
||||||
|
.form-control:focus { box-shadow: 0 0 0 .2rem rgba(13,110,253,.12); } |
||||||
|
.btn-primary { background:#0d6efd; border-color:#0d6efd } |
||||||
|
.help-inline { font-size:0.9rem; color:var(--muted); text-align:center } |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="d-flex align-items-center justify-content-center" style="min-height:100vh; padding:24px;"> |
||||||
|
<div class="card p-4 shadow" style="max-width:420px; width:100%;"> |
||||||
|
<div class="text-center mb-1"> |
||||||
|
<img src="{{ url_for('static', filename='logo.png') }}" alt="logo" style="width:96px;height:96px;object-fit:contain;border-radius:12px;background:transparent;padding:6px;"/> |
||||||
|
</div> |
||||||
|
<div class="text-center"> |
||||||
|
<h3 class="mb-1">Gaming room</h3> |
||||||
|
<div class="small-muted mb-3">Connecte-toi pour gérer les serveurs locaux</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if error %} |
||||||
|
<div class="alert alert-danger">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form method="post"> |
||||||
|
<div class="mb-3"> |
||||||
|
<label class="form-label visually-hidden">Mot de passe</label> |
||||||
|
<div class="input-group"> |
||||||
|
<span class="input-group-text bg-transparent text-muted"><i class="bi-key-fill"></i></span> |
||||||
|
<input name="password" type="password" class="form-control" placeholder="Mot de passe" autocomplete="current-password" required autofocus /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="d-grid mb-2"> |
||||||
|
<button class="btn btn-primary">Se connecter</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
2025-12-02 00:46:48,793 INFO logo request: name="mc-creative", slug="mc-creative" |
||||||
|
2025-12-02 00:46:48,793 INFO cache hit for mc-creative -> mc-creative.png |
||||||
|
2025-12-02 00:46:48,796 INFO logo request: name="csgo-eu-1", slug="csgo-eu-1" |
||||||
|
2025-12-02 00:46:48,797 INFO cache hit for csgo-eu-1 -> csgo-eu-1.png |
||||||
|
2025-12-02 00:46:48,798 INFO logo request: name="valheim", slug="valheim" |
||||||
|
2025-12-02 00:46:48,800 INFO cache hit for valheim -> valheim.png |
||||||
|
2025-12-02 00:48:35,694 INFO force refresh requested for NomDuJeu |
||||||
|
2025-12-02 00:48:35,695 INFO logo request: name="NomDuJeu", slug="nomdujeu" |
||||||
|
2025-12-02 00:48:35,695 INFO trying Steam store search for "NomDuJeu" |
||||||
|
2025-12-02 00:48:45,918 INFO logo request: name="valheim", slug="valheim" |
||||||
|
2025-12-02 00:48:45,919 INFO cache hit for valheim -> valheim.png |
||||||
|
2025-12-02 00:48:45,931 INFO logo request: name="csgo-eu-1", slug="csgo-eu-1" |
||||||
|
2025-12-02 00:48:45,931 INFO cache hit for csgo-eu-1 -> csgo-eu-1.png |
||||||
|
2025-12-02 00:48:45,933 INFO logo request: name="mc-creative", slug="mc-creative" |
||||||
|
2025-12-02 00:48:45,934 INFO cache hit for mc-creative -> mc-creative.png |
||||||
|
2025-12-02 00:49:29,350 INFO logo request: name="mc-creative", slug="mc-creative" |
||||||
|
2025-12-02 00:49:29,350 INFO trying Steam store search for "mc-creative" |
||||||
|
2025-12-02 00:49:29,354 INFO logo request: name="csgo-eu-1", slug="csgo-eu-1" |
||||||
|
2025-12-02 00:49:29,355 INFO trying Steam store search for "csgo-eu-1" |
||||||
|
2025-12-02 00:49:29,358 INFO logo request: name="valheim", slug="valheim" |
||||||
|
2025-12-02 00:49:29,358 INFO trying Steam store search for "valheim" |
||||||
|
2025-12-02 00:49:29,707 INFO steam store search found appid=892970 for "valheim" |
||||||
|
2025-12-02 00:49:30,052 INFO downloaded steam header for appid=892970 |
||||||
|
2025-12-02 00:49:30,275 INFO falling back to RAWG image for "mc-creative": https://media.rawg.io/media/screenshots/ff0/ff06832f9468edafdf4d5b3c45131545.jpg |
||||||
|
2025-12-02 00:49:30,357 INFO falling back to RAWG image for "csgo-eu-1": https://media.rawg.io/media/screenshots/9f2/9f2e19a20ca0354e52c82c28a9266b24.jpg |
||||||
|
2025-12-02 00:49:30,445 INFO downloaded RAWG image for "mc-creative" |
||||||
|
2025-12-02 00:49:30,497 INFO downloaded RAWG image for "csgo-eu-1" |
||||||
Loading…
Reference in new issue