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.
243 lines
7.4 KiB
243 lines
7.4 KiB
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)
|
|
|