/
Clown1331
/
Salt_GUI
Обзор
Документация
Войти
/
Clown1331
/
Salt_GUI
Код
Запросы
0
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
webui/app.py
1 507 строк
72 KB
Salt GUI
Replace lightning icon with logo.png in header and login page; add /static route
20 июл 2026, 14:10
Верифицирован
20 июл 2026, 14:10
9294722
Код
Авторство
О чём код?
#!/usr/bin/env python3 """SaltStack Web UI - lightweight Flask panel for managing minions. Configuration is read from environment variables (see .env.example): SALT_WEBUI_SECRET - Flask session signing key (REQUIRED in production) SALT_WEBUI_USER - login username (default: admin) SALT_WEBUI_PASS - login password (default: admin - CHANGE IT!) SALT_BIN / SALT_KEY_BIN / SALT_RUN_BIN - paths to salt binaries Runs with real Flask when available. If Flask is NOT installed (e.g. fully offline host), it transparently falls back to the bundled zero-dependency miniflask shim (pure standard library, no pip/network needed). """ import json import os import subprocess import functools try: from flask import Flask, request, session, redirect, url_for, Response, jsonify, render_template_string _USING_FLASK = True except ImportError: from miniflask import (Flask, request, session, redirect, url_for, Response, jsonify, render_template_string) _USING_FLASK = False app = Flask(__name__) app.secret_key = os.environ.get("SALT_WEBUI_SECRET", "change-me-in-production") if not _USING_FLASK: import miniflask miniflask.SECRET_KEY = app.secret_key LOGIN_USER = os.environ.get("SALT_WEBUI_USER", "admin") LOGIN_PASS = os.environ.get("SALT_WEBUI_PASS", "admin") SALT_BIN = os.environ.get("SALT_BIN", "/usr/local/bin/salt") SALT_KEY_BIN = os.environ.get("SALT_KEY_BIN", "/usr/local/bin/salt-key") SALT_RUN_BIN = os.environ.get("SALT_RUN_BIN", "/usr/local/bin/salt-run") def run_salt(args, timeout=60): """Run a salt command, return (ok, stdout, stderr).""" try: proc = subprocess.run( args, capture_output=True, text=True, timeout=timeout, ) return proc.returncode == 0, proc.stdout, proc.stderr except subprocess.TimeoutExpired: return False, "", "Timeout: команда выполнялась слишком долго" except Exception as e: return False, "", str(e) def salt_json(target, func, args=None, timeout=60): """Run salt with JSON output, parse result.""" cmd = [SALT_BIN, "--out=json", target, func] if args: cmd += args ok, out, err = run_salt(cmd, timeout=timeout) if not ok and not out.strip(): return None, err try: return json.loads(out), None except (json.JSONDecodeError, ValueError): return None, out or err def login_required(f): @functools.wraps(f) def wrapper(*a, **kw): if not session.get("logged_in"): return redirect(url_for("login")) return f(*a, **kw) return wrapper # ---------------- Routes ---------------- @app.route("/login", methods=["GET", "POST"]) def login(): error = None if request.method == "POST": if request.form.get("username") == LOGIN_USER and request.form.get("password") == LOGIN_PASS: session["logged_in"] = True return redirect(url_for("index")) error = "Неверный логин или пароль" return render_template_string(LOGIN_HTML, error=error) @app.route("/logout") def logout(): session.clear() return redirect(url_for("login")) @app.route("/") @login_required def index(): return render_template_string(BASE_HTML, page="minions", body=MINIONS_HTML) @app.route("/keys") @login_required def keys_page(): return render_template_string(BASE_HTML, page="keys", body=KEYS_HTML) @app.route("/exec") @login_required def exec_page(): return render_template_string(BASE_HTML, page="exec", body=EXEC_HTML) @app.route("/services") @login_required def services_page(): return render_template_string(BASE_HTML, page="services", body=SERVICES_HTML) @app.route("/commands") @login_required def commands_page(): return render_template_string(BASE_HTML, page="commands", body=COMMANDS_HTML) @app.route("/jobs") @login_required def jobs_page(): return render_template_string(BASE_HTML, page="jobs", body=JOBS_HTML) @app.route("/groups") @login_required def groups_page(): return render_template_string(BASE_HTML, page="groups", body=GROUPS_HTML) @app.route("/state") @login_required def state_page(): return render_template_string(BASE_HTML, page="state", body=STATE_HTML) @app.route("/packages") @login_required def packages_page(): return render_template_string(BASE_HTML, page="packages", body=PACKAGES_HTML) @app.route("/files") @login_required def files_page(): return render_template_string(BASE_HTML, page="files", body=FILES_HTML) @app.route("/dashboard") @login_required def dashboard_page(): return render_template_string(BASE_HTML, page="dashboard", body=DASHBOARD_HTML) # ---------------- Static files ---------------- import os as _os STATIC_DIR = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "static") _EXT_CT = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".css": "text/css", ".js": "application/javascript", } @app.route("/static/<path:filename>") def static_files(filename): full = _os.path.normpath(_os.path.join(STATIC_DIR, filename)) if not full.startswith(STATIC_DIR) or not _os.path.isfile(full): return Response("Not Found", status=404) ext = _os.path.splitext(full)[1].lower() ctype = _EXT_CT.get(ext, "application/octet-stream") try: with open(full, "rb") as f: data = f.read() except Exception: return Response("Not Found", status=404) return Response(data, content_type=ctype) # ---------------- API ---------------- @app.route("/api/minions") @login_required def api_minions(): data, err = salt_json("*", "test.ping", timeout=30) ping = data if isinstance(data, dict) else {} _ok, manage, _err = run_salt([SALT_RUN_BIN, "--out=json", "manage.status"], timeout=30) up, down = [], [] if manage: try: st = json.loads(manage) up = st.get("up", []) down = st.get("down", []) except Exception: pass minions = [] ids = set(list(ping.keys()) + up + down) for mid in sorted(ids): status = "up" if (mid in up or ping.get(mid) is True) else "down" minions.append({"id": mid, "status": status, "ping": ping.get(mid)}) return jsonify(minions) @app.route("/api/grains/<minion>") @login_required def api_grains(minion): keys = ["os", "osrelease", "kernel", "cpuarch", "num_cpus", "mem_total", "fqdn", "localhost", "saltversion", "biosversion"] data, err = salt_json(minion, "grains.item", keys, timeout=30) if data is None: return jsonify({"error": err or "нет данных"}), 500 grains = data.get(minion, {}) up, _ = salt_json(minion, "status.uptime", timeout=30) uptime = up.get(minion, {}) if isinstance(up, dict) else {} ip, _ = salt_json(minion, "network.ip_addrs", timeout=30) ips = ip.get(minion, []) if isinstance(ip, dict) else [] disk, _ = salt_json(minion, "disk.usage", timeout=30) root = {} if isinstance(disk, dict): root = disk.get(minion, {}).get("/", {}) mac, _ = salt_json(minion, "grains.get", ["hwaddr_interfaces"], timeout=30) macs = {} if isinstance(mac, dict): raw = mac.get(minion, {}) or {} for iface, addr in raw.items(): if iface == "lo" or addr in ("00:00:00:00:00:00", ""): continue macs[iface] = addr return jsonify({"grains": grains, "uptime": uptime, "ips": ips, "disk_root": root, "macs": macs}) @app.route("/api/exec", methods=["POST"]) @login_required def api_exec(): func = request.json.get("func", "").strip() args_raw = request.json.get("args", "").strip() minions = request.json.get("minions", []) target = request.json.get("target", "*").strip() or "*" if not func: return jsonify({"error": "Не указана функция"}), 400 cmd = [SALT_BIN, "--out=json"] if isinstance(minions, list) and minions: cmd += ["-L", ",".join(minions)] else: cmd.append(target) cmd.append(func) if args_raw: if func in ("cmd.run", "cmd.shell", "cmd.run_all"): cmd.append(args_raw) else: try: import shlex cmd += shlex.split(args_raw) except ValueError: cmd.append(args_raw) ok, out, err = run_salt(cmd, timeout=120) try: parsed = json.loads(out) return jsonify({"ok": True, "result": parsed}) except Exception: pass merged = {} decoder = json.JSONDecoder() idx = 0 text = out.strip() try: while idx < len(text): while idx < len(text) and text[idx] in " \t\r\n": idx += 1 if idx >= len(text): break obj, end = decoder.raw_decode(text, idx) if isinstance(obj, dict): merged.update(obj) idx = end if merged: return jsonify({"ok": True, "result": merged}) except Exception: pass return jsonify({"ok": ok, "raw": out or err}) @app.route("/api/keys") @login_required def api_keys(): ok, out, err = run_salt([SALT_KEY_BIN, "--out=json", "-L"], timeout=30) try: return jsonify(json.loads(out)) except Exception: return jsonify({"error": out or err}), 500 @app.route("/api/keys/accept", methods=["POST"]) @login_required def api_key_accept(): mid = request.json.get("minion", "").strip() if not mid: return jsonify({"error": "нет minion"}), 400 ok, out, err = run_salt([SALT_KEY_BIN, "-y", "-a", mid], timeout=30) return jsonify({"ok": ok, "out": out, "err": err}) @app.route("/api/keys/delete", methods=["POST"]) @login_required def api_key_delete(): mid = request.json.get("minion", "").strip() if not mid: return jsonify({"error": "нет minion"}), 400 ok, out, err = run_salt([SALT_KEY_BIN, "-y", "-d", mid], timeout=30) return jsonify({"ok": ok, "out": out, "err": err}) @app.route("/api/keys/reject", methods=["POST"]) @login_required def api_key_reject(): mid = request.json.get("minion", "").strip() if not mid: return jsonify({"error": "нет minion"}), 400 ok, out, err = run_salt([SALT_KEY_BIN, "-y", "-r", mid], timeout=30) return jsonify({"ok": ok, "out": out, "err": err}) # ---------------- API for Services ---------------- SERVICE_ACTIONS = { "status": "service.status", "start": "service.start", "stop": "service.stop", "restart": "service.restart", "enable": "service.enable", "disable": "service.disable", } @app.route("/api/services/<action>", methods=["POST"]) @login_required def api_service_action(action): data = request.json or {} minions = data.get("minions", []) name = (data.get("name") or "").strip() if not minions: return jsonify({"error": "нет миньонов"}), 400 func = SERVICE_ACTIONS.get(action) if not func: return jsonify({"error": "неизвестное действие"}), 400 cmd = [SALT_BIN, "--out=json", "-L", ",".join(minions), func] if action == "status" and not name: cmd = [SALT_BIN, "--out=json", "-L", ",".join(minions), "service.get_all"] elif name: cmd.append(name) elif action != "status": return jsonify({"error": "укажите имя сервиса"}), 400 ok, out, err = run_salt(cmd, timeout=120) if ok and out.strip(): try: return jsonify({"ok": True, "result": json.loads(out)}) except Exception: return jsonify({"ok": ok, "raw": out}) return jsonify({"ok": ok, "raw": out or err}) # ---------------- API for Saved Commands ---------------- COMMANDS_FILE = "/opt/salt-webui/commands.json" def load_commands(): try: with open(COMMANDS_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception: return [] def save_commands(cmds): try: with open(COMMANDS_FILE, "w", encoding="utf-8") as f: json.dump(cmds, f, ensure_ascii=False, indent=2) return True except Exception: return False @app.route("/api/commands", methods=["GET"]) @login_required def api_commands_list(): return jsonify(load_commands()) @app.route("/api/commands", methods=["POST"]) @login_required def api_commands_add(): data = request.json or {} name = (data.get("name") or "").strip() func = (data.get("func") or "").strip() args = (data.get("args") or "").strip() group = (data.get("group") or "Прочее").strip() if not name or not func: return jsonify({"error": "нужно имя и функция"}), 400 cmds = load_commands() new_id = (max([c.get("id", 0) for c in cmds]) + 1) if cmds else 1 cmds.append({"id": new_id, "name": name, "func": func, "args": args, "group": group}) if not save_commands(cmds): return jsonify({"error": "не удалось сохранить"}), 500 return jsonify({"ok": True, "id": new_id}) @app.route("/api/commands/<int:cmd_id>", methods=["DELETE"]) @login_required def api_commands_delete(cmd_id): cmds = load_commands() cmds = [c for c in cmds if c.get("id") != cmd_id] if not save_commands(cmds): return jsonify({"error": "не удалось сохранить"}), 500 return jsonify({"ok": True}) # ---------------- API for Jobs ---------------- @app.route("/api/jobs") @login_required def api_jobs_list(): ok, out, err = run_salt([SALT_RUN_BIN, "--out=json", "jobs.list_jobs"], timeout=60) if not out.strip(): return jsonify({"error": err or "нет данных"}), 500 try: data = json.loads(out) except Exception: return jsonify({"error": out or err}), 500 jobs = [] for jid, info in (data or {}).items(): jobs.append({ "jid": jid, "function": info.get("Function", "?"), "target": info.get("Target", "?"), "user": info.get("User", "?"), "arguments": info.get("Arguments", []), "start": info.get("StartTime", ""), }) jobs.sort(key=lambda j: j["jid"], reverse=True) return jsonify(jobs[:100]) @app.route("/api/jobs/<jid>") @login_required def api_job_detail(jid): ok, out, err = run_salt([SALT_RUN_BIN, "--out=json", "jobs.lookup_jid", jid], timeout=60) if not out.strip(): return jsonify({"error": err or "нет данных"}), 500 try: return jsonify({"ok": True, "result": json.loads(out)}) except Exception: return jsonify({"ok": ok, "raw": out or err}) # ---------------- API for Groups ---------------- GROUPS_FILE = "/opt/salt-webui/groups.json" def load_groups(): try: with open(GROUPS_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception: return [] def save_groups(groups): try: with open(GROUPS_FILE, "w", encoding="utf-8") as f: json.dump(groups, f, ensure_ascii=False, indent=2) return True except Exception: return False @app.route("/api/groups", methods=["GET"]) @login_required def api_groups_list(): return jsonify(load_groups()) @app.route("/api/groups", methods=["POST"]) @login_required def api_groups_add(): data = request.json or {} name = (data.get("name") or "").strip() minions = data.get("minions", []) if not name: return jsonify({"error": "нужно имя группы"}), 400 if not isinstance(minions, list): return jsonify({"error": "minions должен быть списком"}), 400 groups = load_groups() groups = [g for g in groups if g.get("name") != name] groups.append({"name": name, "minions": minions}) if not save_groups(groups): return jsonify({"error": "не удалось сохранить"}), 500 return jsonify({"ok": True}) @app.route("/api/groups/<name>", methods=["DELETE"]) @login_required def api_groups_delete(name): groups = load_groups() groups = [g for g in groups if g.get("name") != name] if not save_groups(groups): return jsonify({"error": "не удалось сохранить"}), 500 return jsonify({"ok": True}) # ---------------- API for State ---------------- @app.route("/api/state", methods=["POST"]) @login_required def api_state_apply(): data = request.json or {} minions = data.get("minions", []) mode = (data.get("mode") or "highstate").strip() sls = (data.get("sls") or "").strip() test = bool(data.get("test")) if not minions: return jsonify({"error": "нет миньонов"}), 400 cmd = [SALT_BIN, "--out=json", "-L", ",".join(minions)] if mode == "highstate": cmd += ["state.highstate"] elif mode == "sls": if not sls: return jsonify({"error": "укажите имя SLS"}), 400 cmd += ["state.sls", sls] elif mode == "apply": cmd += ["state.apply"] if sls: cmd.append(sls) else: return jsonify({"error": "неизвестный режим"}), 400 if test: cmd.append("test=True") ok, out, err = run_salt(cmd, timeout=300) if ok and out.strip(): try: return jsonify({"ok": True, "result": json.loads(out)}) except Exception: return jsonify({"ok": ok, "raw": out}) return jsonify({"ok": ok, "raw": out or err}) # ---------------- API for Packages ---------------- @app.route("/api/packages/<action>", methods=["POST"]) @login_required def api_packages_action(action): data = request.json or {} minions = data.get("minions", []) name = (data.get("name") or "").strip() if not minions: return jsonify({"error": "нет миньонов"}), 400 if action == "list": cmd = [SALT_BIN, "--out=json", "-L", ",".join(minions), "pkg.list_pkgs"] elif action == "install": if not name: return jsonify({"error": "укажите имя пакета"}), 400 cmd = [SALT_BIN, "--out=json", "-L", ",".join(minions), "pkg.install", name] elif action == "remove": if not name: return jsonify({"error": "укажите имя пакета"}), 400 cmd = [SALT_BIN, "--out=json", "-L", ",".join(minions), "pkg.remove", name] elif action == "upgrade": cmd = [SALT_BIN, "--out=json", "-L", ",".join(minions), "pkg.upgrade"] elif action == "refresh": cmd = [SALT_BIN, "--out=json", "-L", ",".join(minions), "pkg.refresh_db"] else: return jsonify({"error": "неизвестное действие"}), 400 ok, out, err = run_salt(cmd, timeout=600) if ok and out.strip(): try: return jsonify({"ok": True, "result": json.loads(out)}) except Exception: return jsonify({"ok": ok, "raw": out}) return jsonify({"ok": ok, "raw": out or err}) # ---------------- API for Files ---------------- @app.route("/api/files/read", methods=["POST"]) @login_required def api_files_read(): data = request.json or {} minion = (data.get("minion") or "").strip() path = (data.get("path") or "").strip() if not minion or not path: return jsonify({"error": "нужен миньон и путь"}), 400 result, err = salt_json(minion, "file.read", [path], timeout=60) if result is None: return jsonify({"error": err or "нет данных"}), 500 return jsonify({"ok": True, "content": result.get(minion, "")}) @app.route("/api/files/write", methods=["POST"]) @login_required def api_files_write(): data = request.json or {} minion = (data.get("minion") or "").strip() path = (data.get("path") or "").strip() content = data.get("content", "") if not minion or not path: return jsonify({"error": "нужен миньон и путь"}), 400 cmd = [SALT_BIN, "--out=json", minion, "file.write", path, content] ok, out, err = run_salt(cmd, timeout=60) if ok and out.strip(): try: return jsonify({"ok": True, "result": json.loads(out)}) except Exception: return jsonify({"ok": ok, "raw": out}) return jsonify({"ok": ok, "raw": out or err}) @app.route("/api/files/list", methods=["POST"]) @login_required def api_files_list(): data = request.json or {} minion = (data.get("minion") or "").strip() path = (data.get("path") or "/").strip() if not minion: return jsonify({"error": "нужен миньон"}), 400 result, err = salt_json(minion, "cmd.run", ["ls -la " + path], timeout=60) if result is None: return jsonify({"error": err or "нет данных"}), 500 return jsonify({"ok": True, "listing": result.get(minion, "")}) # ---------------- API for Dashboard ---------------- @app.route("/api/dashboard/<minion>") @login_required def api_dashboard(minion): load, _ = salt_json(minion, "status.loadavg", timeout=30) mem, _ = salt_json(minion, "status.meminfo", timeout=30) disk, _ = salt_json(minion, "disk.usage", timeout=30) cpustats, _ = salt_json(minion, "status.cpustats", timeout=30) loadavg = load.get(minion, {}) if isinstance(load, dict) else {} meminfo = mem.get(minion, {}) if isinstance(mem, dict) else {} diskinfo = disk.get(minion, {}) if isinstance(disk, dict) else {} mem_total = 0 mem_free = 0 mem_avail = 0 try: mem_total = int(meminfo.get("MemTotal", {}).get("value", 0)) mem_free = int(meminfo.get("MemFree", {}).get("value", 0)) mem_avail = int(meminfo.get("MemAvailable", {}).get("value", mem_free)) except Exception: pass return jsonify({ "loadavg": loadavg, "mem_total": mem_total, "mem_free": mem_free, "mem_avail": mem_avail, "disk": diskinfo, }) # ---------------- Templates ---------------- LOGIN_HTML = """<!doctype html> <html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <title>Вход — Salt WebUI</title> <style> :root{--bg:#0a0e1a;--card:rgba(30,41,59,.7);--accent:#38bdf8;--accent2:#818cf8} *{box-sizing:border-box} body{font-family:'Segoe UI',-apple-system,Roboto,sans-serif;margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;color:#e2e8f0; background:radial-gradient(circle at 20% 20%,#1e3a5f 0,transparent 40%),radial-gradient(circle at 80% 80%,#3b2f6b 0,transparent 40%),#0a0e1a;overflow:hidden} .blob{position:fixed;border-radius:50%;filter:blur(80px);opacity:.35;z-index:0} .blob.a{width:400px;height:400px;background:#0ea5e9;top:-100px;left:-100px} .blob.b{width:350px;height:350px;background:#8b5cf6;bottom:-120px;right:-80px} .card{position:relative;z-index:1;background:var(--card);backdrop-filter:blur(20px);padding:44px 38px;border-radius:20px; box-shadow:0 20px 60px rgba(0,0,0,.5);width:340px;border:1px solid rgba(148,163,184,.15)} .logo{text-align:center;margin-bottom:8px} h1{margin:0 0 4px;font-size:24px;text-align:center;font-weight:700; background:linear-gradient(90deg,var(--accent),var(--accent2));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent} .sub{text-align:center;color:#64748b;font-size:13px;margin-bottom:28px} label{display:block;font-size:12px;color:#94a3b8;margin:0 0 6px 4px;font-weight:500} input{width:100%;box-sizing:border-box;padding:13px 15px;margin-bottom:18px;border:1px solid rgba(148,163,184,.2);border-radius:12px; background:rgba(15,23,42,.6);color:#e2e8f0;font-size:15px;transition:.2s;outline:none} input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(56,189,248,.15)} button{width:100%;padding:13px;border:0;border-radius:12px;background:linear-gradient(90deg,#0ea5e9,#6366f1);color:#fff;font-size:16px;cursor:pointer;font-weight:600;transition:.2s;margin-top:6px} button:hover{filter:brightness(1.1);transform:translateY(-1px);box-shadow:0 8px 20px rgba(14,165,233,.4)} .err{background:rgba(127,29,29,.6);color:#fecaca;padding:11px;border-radius:12px;margin-bottom:18px;text-align:center;font-size:14px;border:1px solid rgba(220,38,38,.3)} </style></head><body> <div class="blob a"></div><div class="blob b"></div> <form class="card" method="post"> <div class="logo"><img src="/static/logo.jpeg" alt="Salt WebUI" style="width:64px;height:64px;border-radius:16px;object-fit:cover"></div> <h1>Salt WebUI</h1> <div class="sub">Панель управления миньонами</div> {% if error %}<div class="err">{{ error }}</div>{% endif %} <label>Логин</label> <input name="username" placeholder="admin" autofocus> <label>Пароль</label> <input name="password" type="password" placeholder="••••••••"> <button type="submit">Войти →</button> </form></body></html>""" BASE_HTML = """<!doctype html> <html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <title>Salt WebUI</title> <style> :root{--bg:#0a0e1a;--panel:#131a2b;--card:#1a2337;--line:rgba(148,163,184,.12);--accent:#38bdf8;--accent2:#818cf8;--txt:#e2e8f0;--mut:#64748b} *{box-sizing:border-box} body{font-family:'Segoe UI',-apple-system,Roboto,sans-serif;margin:0;color:var(--txt);min-height:100vh; background:radial-gradient(circle at 15% 0,#182844 0,transparent 35%),radial-gradient(circle at 90% 10%,#241d47 0,transparent 30%),var(--bg)} header{background:rgba(19,26,43,.8);backdrop-filter:blur(16px);padding:0 28px;display:flex;align-items:center;gap:6px; border-bottom:1px solid var(--line);height:62px;position:sticky;top:0;z-index:50} header .logo{font-size:19px;font-weight:700;margin-right:28px;display:flex;align-items:center} header a.nav{color:#94a3b8;text-decoration:none;padding:9px 16px;border-radius:10px;font-size:14px;font-weight:500;display:flex;align-items:center;gap:7px;transition:.15s} header a.nav:hover{background:rgba(148,163,184,.1);color:var(--txt)} header a.nav.active{background:linear-gradient(90deg,rgba(14,165,233,.9),rgba(99,102,241,.9));color:#fff;box-shadow:0 4px 14px rgba(14,165,233,.35)} header .spacer{flex:1} header a.out{color:#94a3b8;text-decoration:none;padding:8px 14px;border-radius:10px;font-size:14px;border:1px solid var(--line)} header a.out:hover{background:rgba(220,38,38,.15);color:#fca5a5;border-color:rgba(220,38,38,.3)} svg.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:-3px} .container{max-width:1120px;margin:28px auto;padding:0 28px} h2{font-size:22px;margin:0 0 20px;font-weight:700;display:flex;align-items:center;gap:10px} h2 svg.ic{width:22px;height:22px;stroke:var(--accent)} table{width:100%;border-collapse:collapse;background:var(--card);border-radius:14px;overflow:hidden;border:1px solid var(--line)} th,td{padding:14px 16px;text-align:left;border-bottom:1px solid var(--line);font-size:14px} th{background:rgba(255,255,255,.02);color:#94a3b8;font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.5px} tr:last-child td{border-bottom:0} tbody tr{transition:.12s} tbody tr:hover{background:rgba(56,189,248,.04)} .badge{display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:20px;font-size:12px;font-weight:600} .badge::before{content:'';width:7px;height:7px;border-radius:50%;background:currentColor} .badge.up{background:rgba(20,83,45,.4);color:#4ade80} .badge.down{background:rgba(127,29,29,.4);color:#f87171} .btn{padding:8px 15px;border:0;border-radius:10px;background:linear-gradient(90deg,#0ea5e9,#6366f1);color:#fff;cursor:pointer;font-size:13px;font-weight:600;margin-right:5px;transition:.15s;font-family:inherit} .btn:hover{filter:brightness(1.12);transform:translateY(-1px)} .btn.gray{background:rgba(71,85,105,.5);color:#cbd5e1}.btn.gray:hover{background:rgba(71,85,105,.8)} .btn.red{background:linear-gradient(90deg,#dc2626,#e11d48)} .btn.green{background:linear-gradient(90deg,#16a34a,#059669)} h2 .cnt{font-size:13px;font-weight:500;color:var(--mut);background:rgba(148,163,184,.1);padding:3px 10px;border-radius:20px} input,select,textarea{padding:11px 13px;border:1px solid var(--line);border-radius:10px;background:rgba(15,23,42,.6);color:var(--txt);font-size:14px;font-family:inherit;outline:none;transition:.2s} input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(56,189,248,.12)} .card{background:var(--card);padding:22px;border-radius:16px;margin-bottom:20px;border:1px solid var(--line)} .card>b:first-child{font-size:15px;color:var(--accent);display:block;margin-bottom:4px} pre{background:#060a14;padding:18px;border-radius:12px;overflow:auto;font-size:13px;color:#7dd3fc;max-height:500px;border:1px solid var(--line);line-height:1.5;font-family:'Cascadia Code','Consolas',monospace} .muted{color:var(--mut);font-size:13px} .grid{display:grid;grid-template-columns:1fr 1fr;gap:2px 20px} .grid .row{display:flex;align-items:center;gap:8px;padding:11px 4px;border-bottom:1px solid var(--line);font-size:14px} .grid .row svg.ic{stroke:var(--accent);flex-shrink:0} .grid .row b{color:#94a3b8;font-weight:500;min-width:110px} .grid .row span{color:var(--txt);word-break:break-all} .modal{display:none;position:fixed;inset:0;background:rgba(5,8,18,.7);backdrop-filter:blur(4px);align-items:center;justify-content:center;z-index:100;padding:20px} .modal.show{display:flex} .modal .inner{background:var(--panel);padding:28px;border-radius:20px;max-width:640px;width:100%;max-height:88vh;overflow:auto;border:1px solid var(--line);box-shadow:0 30px 80px rgba(0,0,0,.6)} .modal h2{margin-bottom:18px} .close{float:right;cursor:pointer;color:#94a3b8;font-size:26px;line-height:1;transition:.15s;padding:0 4px} .close:hover{color:#f87171} label{display:block;margin:14px 0 6px;font-size:13px;color:#94a3b8;font-weight:500} .chip{display:inline-flex;align-items:center;gap:7px;margin:5px 14px 5px 0;padding:8px 14px;border-radius:10px;background:rgba(15,23,42,.5);border:1px solid var(--line);cursor:pointer;transition:.15s;font-size:14px} .chip:hover{border-color:var(--accent)} .chip input{margin:0;width:16px;height:16px;accent-color:var(--accent)} </style></head><body> <header> <span class="logo"><img src="/static/logo.jpeg" alt="Salt WebUI" style="width:26px;height:26px;border-radius:6px;object-fit:cover"><span style="margin-left:8px">Salt WebUI</span></span> <a href="/dashboard" class="nav {{ 'active' if page=='dashboard' else '' }}"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>Дашборд</a> <a href="/" class="nav {{ 'active' if page=='minions' else '' }}"><svg class="ic" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>Миньоны</a> <a href="/exec" class="nav {{ 'active' if page=='exec' else '' }}"><svg class="ic" viewBox="0 0 24 24"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>Команды</a> <a href="/services" class="nav {{ 'active' if page=='services' else '' }}"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Сервисы</a> <a href="/commands" class="nav {{ 'active' if page=='commands' else '' }}"><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>Пресеты</a> <a href="/jobs" class="nav {{ 'active' if page=='jobs' else '' }}"><svg class="ic" viewBox="0 0 24 24"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>Джобы</a> <a href="/groups" class="nav {{ 'active' if page=='groups' else '' }}"><svg class="ic" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>Группы</a> <a href="/state" class="nav {{ 'active' if page=='state' else '' }}"><svg class="ic" viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>States</a> <a href="/packages" class="nav {{ 'active' if page=='packages' else '' }}"><svg class="ic" viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>Пакеты</a> <a href="/files" class="nav {{ 'active' if page=='files' else '' }}"><svg class="ic" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>Файлы</a> <a href="/keys" class="nav {{ 'active' if page=='keys' else '' }}"><svg class="ic" viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>Ключи</a> <span class="spacer"></span> <a href="/logout" class="out">Выход</a> </header> <div class="container">""" + "{{ body|safe }}" + """</div> </body></html>""" MINIONS_HTML = """ <h2><svg class="ic" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>Миньоны <span class="cnt" id="cnt">…</span></h2> <div class="muted" style="margin-bottom:16px;display:flex;align-items:center;gap:6px"> <svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px;stroke:#64748b"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Автообновление каждые 10 сек</div> <div id="mgrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px"> <div class="muted">Загрузка…</div></div> <div class="modal" id="modal"><div class="inner"> <span class="close" onclick="document.getElementById('modal').classList.remove('show')">×</span> <h2 id="mtitle"><svg class="ic" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg><span id="mtname"></span></h2> <div id="mbody" class="muted">Загрузка…</div> </div></div> <script> const ic={ os:'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10 15 15 0 0 1 4-10z"/></svg>', cpu:'<svg class="ic" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"/></svg>', mem:'<svg class="ic" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M6 7v10M10 7v10M14 7v10M18 7v10"/></svg>', disk:'<svg class="ic" viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="8" cy="12" r="2"/><path d="M14 10h4M14 14h4"/></svg>', net:'<svg class="ic" viewBox="0 0 24 24"><path d="M5 12.55a11 11 0 0 1 14.08 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>', mac:'<svg class="ic" viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10v4M10 10v4M14 10v4M18 10v4"/></svg>', kern:'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 1v6M12 17v6M4.2 4.2l4.3 4.3M15.5 15.5l4.3 4.3M1 12h6M17 12h6M4.2 19.8l4.3-4.3M15.5 8.5l4.3-4.3"/></svg>', salt:'<svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>', clock:'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>', fqdn:'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20z"/></svg>' }; async function loadMinions(){ const r = await fetch('/api/minions'); const d = await r.json(); const box = document.getElementById('mgrid'); const up=d.filter(m=>m.status==='up').length; document.getElementById('cnt').textContent=up+' / '+d.length+' в сети'; if(!d.length){ box.innerHTML='<div class=muted>Нет миньонов</div>'; return; } box.innerHTML = d.map(m=>` <div class="card" style="margin:0;cursor:pointer;transition:.15s" onclick="showGrains('${m.id}')" onmouseover="this.style.borderColor='rgba(56,189,248,.4)';this.style.transform='translateY(-2px)'" onmouseout="this.style.borderColor='';this.style.transform=''"> <div style="display:flex;justify-content:space-between;align-items:flex-start"> <div style="display:flex;align-items:center;gap:10px"> <div style="width:40px;height:40px;border-radius:12px;background:linear-gradient(135deg,rgba(14,165,233,.25),rgba(99,102,241,.25));display:flex;align-items:center;justify-content:center;color:#38bdf8"> <svg class="ic" viewBox="0 0 24 24" style="width:20px;height:20px"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg> </div> <div><div style="font-weight:600;font-size:15px">${m.id}</div> <div class="muted" style="font-size:12px">нажмите для деталей</div></div> </div> <span class="badge ${m.status}">${m.status==='up'?'В сети':'Нет связи'}</span> </div> </div>`).join(''); } function row(icon,label,val){return `<div class="row">${icon}<b>${label}</b><span>${val}</span></div>`;} async function showGrains(id){ document.getElementById('modal').classList.add('show'); document.getElementById('mtname').textContent = id; document.getElementById('mbody').innerHTML = '<span class=muted>Загрузка…</span>'; const r = await fetch('/api/grains/'+id); const d = await r.json(); if(d.error){ document.getElementById('mbody').innerHTML='<span class=muted>Ошибка: '+d.error+'</span>'; return; } const g=d.grains||{}, u=d.uptime||{}, disk=d.disk_root||{}, macs=d.macs||{}; const gb = disk['1K-blocks']?(disk['1K-blocks']/1048576).toFixed(1):'?'; const ga = disk.available?(disk.available/1048576).toFixed(1):'?'; const macStr = Object.keys(macs).length? Object.entries(macs).map(([i,a])=>`${a} <span class="muted">(${i})</span>`).join('<br>') : '?'; document.getElementById('mbody').innerHTML = `<div class="grid"> ${row(ic.os,'ОС',(g.os||'?')+' '+(g.osrelease||''))} ${row(ic.kern,'Ядро',g.kernel||'?')} ${row(ic.cpu,'Архитектура',g.cpuarch||'?')} ${row(ic.cpu,'CPU',(g.num_cpus||'?')+' ядер')} ${row(ic.mem,'Память',(g.mem_total||'?')+' MB')} ${row(ic.salt,'Salt',g.saltversion||'?')} ${row(ic.fqdn,'FQDN',g.fqdn||'?')} ${row(ic.net,'IP адрес',(d.ips||[]).join(', ')||'?')} ${row(ic.mac,'MAC адрес',macStr)} ${row(ic.clock,'Uptime',(u.days||0)+'д '+(u.time||''))} ${row(ic.disk,'Диск /',(disk.capacity||'?')+' занято ('+ga+'/'+gb+' GB своб.)')} </div>`; } loadMinions(); setInterval(loadMinions, 10000); </script> """ EXEC_HTML = """ <h2><svg class="ic" viewBox="0 0 24 24"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>Выполнение команд</h2> <div class="card"> <b>① Выберите миньоны</b> <div style="margin:10px 0"> <button class="btn gray" onclick="selectAll(true)">✓ Выбрать все</button> <button class="btn gray" onclick="selectAll(false)">✕ Снять все</button> </div> <div id="mchecks" class="muted">Загрузка миньонов…</div> </div> <div class="card"> <b>② Шаблоны команд</b> <div style="margin:12px 0 6px;font-size:12px;color:#94a3b8;text-transform:uppercase;letter-spacing:.5px">Диагностика</div> <div id="tpl-diag"></div> <div style="margin:16px 0 6px;font-size:12px;color:#94a3b8;text-transform:uppercase;letter-spacing:.5px">Система</div> <div id="tpl-sys"></div> <div style="margin:16px 0 6px;font-size:12px;color:#94a3b8;text-transform:uppercase;letter-spacing:.5px">Salt-модули</div> <div id="tpl-salt"></div> </div> <div class="card"> <b>③ Команда</b> <label>Функция Salt</label> <input id="func" value="cmd.run" style="width:100%" placeholder="напр. cmd.run, test.ping, grains.items"> <label>Аргумент</label> <input id="args" style="width:100%" placeholder="напр. 'uptime' или 'hostname'"> <div style="margin-top:18px"><button class="btn green" onclick="runCmd()" style="font-size:15px;padding:11px 24px">▶ Выполнить</button></div> </div> <div class="card"><b>Результат</b><pre id="out" class="muted" style="margin-top:12px">—</pre></div> <script> const TEMPLATES = { diag: [ ["Ping", "test.ping", ""], ["Версия Salt", "test.version", ""], ["Uptime", "cmd.run", "uptime"], ["Нагрузка (top)", "cmd.run", "top -bn1 | head -15"], ["Список процессов", "cmd.run", "ps aux --sort=-%cpu | head -15"], ], sys: [ ["Диск (df -h)", "cmd.run", "df -h"], ["Память (free -m)", "cmd.run", "free -m"], ["Использование диска", "disk.usage", ""], ["Uptime (модуль)", "status.uptime", ""], ["Загрузка CPU", "status.loadavg", ""], ["Инфо о памяти", "status.meminfo", ""], ["Сетевые интерфейсы", "network.interfaces", ""], ["IP адреса", "network.ip_addrs", ""], ["Дата/время", "cmd.run", "date"], ["Ядро (uname -a)", "cmd.run", "uname -a"], ], salt: [ ["Все grains", "grains.items", ""], ["ОС и версия", "grains.item", "os osrelease"], ["Список сервисов", "service.get_all", ""], ["Установленные пакеты", "pkg.list_pkgs", ""], ["Список пользователей", "user.list_users", ""], ["Синхр. модулей", "saltutil.sync_all", ""], ], }; function makeButtons(boxId, list){ const box=document.getElementById(boxId); box.innerHTML=''; list.forEach(t=>{ const b=document.createElement('button'); b.className='btn gray'; b.style.margin='3px'; b.textContent=t[0]; b.addEventListener('click',()=>preset(t[1],t[2])); box.appendChild(b); }); } function preset(f,a){document.getElementById('func').value=f;document.getElementById('args').value=a;} async function loadChecks(){ const r=await fetch('/api/minions'); const d=await r.json(); const box=document.getElementById('mchecks'); if(!d.length){box.innerHTML='<span class=muted>Нет миньонов</span>';return;} let preset=null; const pm=sessionStorage.getItem('preset_minions'); if(pm){try{preset=JSON.parse(pm);}catch(e){} sessionStorage.removeItem('preset_minions');} box.innerHTML = d.map(m=>{ const checked = preset ? preset.includes(m.id) : (m.status==='up'); return `<label class="chip"> <input type="checkbox" class="mchk" value="${m.id}" ${checked?'checked':''}> <span>${m.id}</span> <span class="badge ${m.status}">${m.status==='up'?'В сети':'Нет связи'}</span> </label>`;}).join(''); } function selectAll(v){document.querySelectorAll('.mchk').forEach(c=>c.checked=v);} function getSelected(){return Array.from(document.querySelectorAll('.mchk:checked')).map(c=>c.value);} async function runCmd(){ const out=document.getElementById('out'); const sel=getSelected(); const func=document.getElementById('func').value.trim(); const args=document.getElementById('args').value.trim(); if(!sel.length){out.textContent='⚠ Выберите хотя бы одного миньона';return;} if(!func){out.textContent='⚠ Укажите функцию Salt';return;} if((func==='cmd.run'||func==='cmd.shell')&&!args){out.textContent='⚠ Для '+func+' нужно ввести команду в поле «Аргумент» (напр. uptime)';return;} out.textContent='Выполняется на: '+sel.join(', ')+' …'; const body={minions:sel,func:func,args:args}; const r=await fetch('/api/exec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); const d=await r.json(); if(d.error){out.textContent='Ошибка: '+d.error;return;} out.textContent = d.result?JSON.stringify(d.result,null,2):(d.raw||'нет вывода'); } makeButtons('tpl-diag', TEMPLATES.diag); makeButtons('tpl-sys', TEMPLATES.sys); makeButtons('tpl-salt', TEMPLATES.salt); loadChecks(); (function(){ const pf=sessionStorage.getItem('preset_func'); if(pf){preset(pf, sessionStorage.getItem('preset_args')||''); sessionStorage.removeItem('preset_func');sessionStorage.removeItem('preset_args');} })(); </script> """ KEYS_HTML = """ <h2><svg class="ic" viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>Управление ключами</h2> <div id="keys" class="muted">Загрузка…</div> <script> async function loadKeys(){ const r=await fetch('/api/keys'); const d=await r.json(); const box=document.getElementById('keys'); if(d.error){box.innerHTML='Ошибка: '+d.error;return;} function section(title,list,actions){ let rows=(list||[]).map(k=>`<tr><td><b>${k}</b></td><td>${actions(k)}</td></tr>`).join('') || '<tr><td colspan=2 class=muted>пусто</td></tr>'; return `<div class="card"><b>${title}</b><table style="margin-top:10px"><tbody>${rows}</tbody></table></div>`; } box.innerHTML = section('✓ Принятые', d.minions, k=>`<button class="btn red" onclick="act('delete','${k}')">Удалить</button>`) + section('⏳ Ожидающие', d.minions_pre, k=>`<button class="btn green" onclick="act('accept','${k}')">Принять</button> <button class="btn red" onclick="act('reject','${k}')">Отклонить</button>`) + section('✗ Отклонённые', d.minions_rejected, k=>`<button class="btn green" onclick="act('accept','${k}')">Принять</button> <button class="btn red" onclick="act('delete','${k}')">Удалить</button>`) + section('⛔ Запрещённые', d.minions_denied, k=>`<button class="btn red" onclick="act('delete','${k}')">Удалить</button>`); } async function act(a,m){ if(!confirm(a+' ключ '+m+'?'))return; await fetch('/api/keys/'+a,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({minion:m})}); loadKeys(); } loadKeys(); </script> """ SERVICES_HTML = """ <h2><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Управление сервисами</h2> <div class="card"> <b>① Выберите миньоны</b> <div style="margin:10px 0"> <button class="btn gray" onclick="selectAll(true)">✓ Выбрать все</button> <button class="btn gray" onclick="selectAll(false)">✕ Снять все</button> </div> <div id="mchecks" class="muted">Загрузка миньонов…</div> </div> <div class="card"> <b>② Имя сервиса</b> <label>Название юнита (напр. ssh, cron, salt-minion)</label> <input id="svcname" style="width:100%" placeholder="ssh"> <div class="muted" style="margin-top:8px">Для «Статус» без имени будет показан список всех сервисов.</div> </div> <div class="card"> <b>③ Действие</b> <div style="margin-top:10px"> <button class="btn" onclick="svcAction('status')">Статус</button> <button class="btn green" onclick="svcAction('start')">Запустить</button> <button class="btn red" onclick="svcAction('stop')">Остановить</button> <button class="btn" onclick="svcAction('restart')">Перезапустить</button> <button class="btn gray" onclick="svcAction('enable')">Включить автозапуск</button> <button class="btn gray" onclick="svcAction('disable')">Отключить автозапуск</button> </div> </div> <div class="card"><b>Результат</b><pre id="out" class="muted" style="margin-top:12px">—</pre></div> <script> async function loadChecks(){ const r=await fetch('/api/minions'); const d=await r.json(); const box=document.getElementById('mchecks'); if(!d.length){box.innerHTML='<span class=muted>Нет миньонов</span>';return;} box.innerHTML = d.map(m=>`<label class="chip"> <input type="checkbox" class="mchk" value="${m.id}" ${m.status==='up'?'checked':''}> <span>${m.id}</span> <span class="badge ${m.status}">${m.status==='up'?'В сети':'Нет связи'}</span> </label>`).join(''); } function selectAll(v){document.querySelectorAll('.mchk').forEach(c=>c.checked=v);} function getSelected(){return Array.from(document.querySelectorAll('.mchk:checked')).map(c=>c.value);} async function svcAction(action){ const out=document.getElementById('out'); const sel=getSelected(); const name=document.getElementById('svcname').value.trim(); if(!sel.length){out.textContent='⚠ Выберите хотя бы одного миньона';return;} if(action!=='status' && !name){out.textContent='⚠ Укажите имя сервиса';return;} const labels={status:'Статус',start:'Запуск',stop:'Остановка',restart:'Перезапуск',enable:'Включение автозапуска',disable:'Отключение автозапуска'}; out.textContent=labels[action]+' сервиса '+(name||'(все)')+' на: '+sel.join(', ')+' …'; const r=await fetch('/api/services/'+action,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({minions:sel,name:name})}); const d=await r.json(); if(d.error){out.textContent='Ошибка: '+d.error;return;} out.textContent = d.result?JSON.stringify(d.result,null,2):(d.raw||'нет вывода'); } loadChecks(); </script> """ COMMANDS_HTML = """ <h2><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>Сохранённые команды</h2> <div class="card"> <b>Добавить пресет</b> <div class="grid" style="margin-top:10px;grid-template-columns:1fr 1fr"> <div><label>Название</label><input id="c_name" style="width:100%" placeholder="Перезагрузка nginx"></div> <div><label>Группа</label><input id="c_group" style="width:100%" placeholder="Веб-серверы"></div> <div><label>Функция Salt</label><input id="c_func" style="width:100%" placeholder="cmd.run"></div> <div><label>Аргумент</label><input id="c_args" style="width:100%" placeholder="systemctl restart nginx"></div> </div> <div style="margin-top:14px"><button class="btn green" onclick="addCmd()">+ Сохранить</button></div> </div> <div id="clist" class="muted">Загрузка…</div> <script> async function loadCmds(){ const r=await fetch('/api/commands'); const d=await r.json(); const box=document.getElementById('clist'); if(!d.length){box.innerHTML='<div class="card muted">Пресетов пока нет. Добавьте первый выше.</div>';return;} const groups={}; d.forEach(c=>{(groups[c.group]=groups[c.group]||[]).push(c);}); box.innerHTML = Object.keys(groups).map(g=>` <div class="card"><b>${g}</b> <table style="margin-top:10px"><thead><tr><th>Название</th><th>Функция</th><th>Аргумент</th><th></th></tr></thead> <tbody>${groups[g].map(c=>`<tr> <td><b>${c.name}</b></td> <td><code>${c.func}</code></td> <td class="muted">${c.args||'—'}</td> <td style="text-align:right;white-space:nowrap"> <button class="btn" onclick="useCmd('${c.func}','${(c.args||'').replace(/'/g,"\\\\'")}')">▶ В команды</button> <button class="btn red" onclick="delCmd(${c.id})">Удалить</button> </td></tr>`).join('')}</tbody></table></div>`).join(''); } async function addCmd(){ const name=document.getElementById('c_name').value.trim(); const func=document.getElementById('c_func').value.trim(); const args=document.getElementById('c_args').value.trim(); const group=document.getElementById('c_group').value.trim()||'Прочее'; if(!name||!func){alert('Нужно указать название и функцию');return;} const r=await fetch('/api/commands',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,func,args,group})}); const d=await r.json(); if(d.error){alert('Ошибка: '+d.error);return;} document.getElementById('c_name').value='';document.getElementById('c_func').value=''; document.getElementById('c_args').value='';document.getElementById('c_group').value=''; loadCmds(); } async function delCmd(id){ if(!confirm('Удалить пресет?'))return; await fetch('/api/commands/'+id,{method:'DELETE'}); loadCmds(); } function useCmd(func,args){ sessionStorage.setItem('preset_func',func); sessionStorage.setItem('preset_args',args); location.href='/exec'; } loadCmds(); </script> """ JOBS_HTML = """ <h2><svg class="ic" viewBox="0 0 24 24"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>История джобов <span class="cnt" id="jcnt">…</span></h2> <div style="margin-bottom:16px"><button class="btn gray" onclick="loadJobs()">↻ Обновить</button></div> <table><thead><tr><th>JID</th><th>Функция</th><th>Цель</th><th>Пользователь</th><th></th></tr></thead> <tbody id="jlist"><tr><td colspan="5" class="muted">Загрузка…</td></tr></tbody></table> <div class="modal" id="jmodal"><div class="inner"> <span class="close" onclick="document.getElementById('jmodal').classList.remove('show')">×</span> <h2><svg class="ic" viewBox="0 0 24 24"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg><span id="jtitle"></span></h2> <div id="jbody" class="muted">Загрузка…</div> </div></div> <script> async function loadJobs(){ const tb=document.getElementById('jlist'); tb.innerHTML='<tr><td colspan=5 class=muted>Загрузка…</td></tr>'; const r=await fetch('/api/jobs'); const d=await r.json(); if(d.error){tb.innerHTML='<tr><td colspan=5 class=muted>Ошибка: '+d.error+'</td></tr>';return;} document.getElementById('jcnt').textContent=d.length+' записей'; if(!d.length){tb.innerHTML='<tr><td colspan=5 class=muted>Нет джобов</td></tr>';return;} tb.innerHTML=d.map(j=>`<tr> <td><code>${j.jid}</code></td> <td>${j.function}</td> <td class="muted">${j.target}</td> <td class="muted">${j.user}</td> <td style="text-align:right;white-space:nowrap"> <button class="btn" onclick="showJob('${j.jid}')">Результат</button> <button class="btn green" onclick="rerun('${j.function}',${JSON.stringify(JSON.stringify(j.arguments))})">▶ Повторить</button> </td></tr>`).join(''); } async function showJob(jid){ document.getElementById('jmodal').classList.add('show'); document.getElementById('jtitle').textContent='JID '+jid; document.getElementById('jbody').innerHTML='<span class=muted>Загрузка…</span>'; const r=await fetch('/api/jobs/'+jid); const d=await r.json(); if(d.error){document.getElementById('jbody').innerHTML='<span class=muted>Ошибка: '+d.error+'</span>';return;} document.getElementById('jbody').innerHTML='<pre>'+JSON.stringify(d.result||d.raw,null,2)+'</pre>'; } function rerun(func,argsJson){ let args=''; try{const a=JSON.parse(argsJson); if(Array.isArray(a)) args=a.map(x=>typeof x==='object'?JSON.stringify(x):x).join(' ');}catch(e){} sessionStorage.setItem('preset_func',func); sessionStorage.setItem('preset_args',args); location.href='/exec'; } loadJobs(); </script> """ GROUPS_HTML = """ <h2><svg class="ic" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>Группы миньонов</h2> <div class="card"> <b>Создать / обновить группу</b> <label>Название группы</label> <input id="g_name" style="width:100%" placeholder="Веб-серверы"> <label>Миньоны в группе</label> <div id="g_checks" class="muted">Загрузка миньонов…</div> <div style="margin-top:14px"><button class="btn green" onclick="saveGroup()">+ Сохранить группу</button></div> </div> <div id="glist" class="muted">Загрузка…</div> <script> let ALL_MINIONS=[]; async function loadMinionChecks(){ const r=await fetch('/api/minions'); ALL_MINIONS=await r.json(); const box=document.getElementById('g_checks'); if(!ALL_MINIONS.length){box.innerHTML='<span class=muted>Нет миньонов</span>';return;} box.innerHTML=ALL_MINIONS.map(m=>`<label class="chip"> <input type="checkbox" class="gchk" value="${m.id}"> <span>${m.id}</span> <span class="badge ${m.status}">${m.status==='up'?'В сети':'Нет связи'}</span> </label>`).join(''); } async function saveGroup(){ const name=document.getElementById('g_name').value.trim(); if(!name){alert('Укажите название группы');return;} const mins=Array.from(document.querySelectorAll('.gchk:checked')).map(c=>c.value); const r=await fetch('/api/groups',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,minions:mins})}); const d=await r.json(); if(d.error){alert('Ошибка: '+d.error);return;} document.getElementById('g_name').value=''; document.querySelectorAll('.gchk').forEach(c=>c.checked=false); loadGroups(); } async function loadGroups(){ const r=await fetch('/api/groups'); const d=await r.json(); const box=document.getElementById('glist'); if(!d.length){box.innerHTML='<div class="card muted">Групп пока нет.</div>';return;} box.innerHTML=d.map(g=>`<div class="card"> <div style="display:flex;justify-content:space-between;align-items:center"> <b style="margin:0">${g.name} <span class="muted" style="font-weight:400">(${g.minions.length})</span></b> <div> <button class="btn" onclick="runGroup('${g.name}')">▶ В команды</button> <button class="btn red" onclick="delGroup('${g.name}')">Удалить</button> </div> </div> <div style="margin-top:10px">${g.minions.map(m=>`<span class="chip" style="cursor:default">${m}</span>`).join('')||'<span class=muted>пусто</span>'}</div> </div>`).join(''); } async function delGroup(name){ if(!confirm('Удалить группу '+name+'?'))return; await fetch('/api/groups/'+encodeURIComponent(name),{method:'DELETE'}); loadGroups(); } function runGroup(name){ fetch('/api/groups').then(r=>r.json()).then(d=>{ const g=d.find(x=>x.name===name); if(g){sessionStorage.setItem('preset_minions',JSON.stringify(g.minions));location.href='/exec';} }); } loadMinionChecks(); loadGroups(); </script> """ STATE_HTML = """ <h2><svg class="ic" viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>Управление состояниями (States)</h2> <div class="card"> <b>① Выберите миньоны</b> <div style="margin:10px 0"> <button class="btn gray" onclick="selectAll(true)">✓ Выбрать все</button> <button class="btn gray" onclick="selectAll(false)">✕ Снять все</button> </div> <div id="mchecks" class="muted">Загрузка миньонов…</div> </div> <div class="card"> <b>② Режим</b> <label>Действие</label> <select id="mode" style="width:100%" onchange="toggleSls()"> <option value="highstate">state.highstate (применить всё)</option> <option value="sls">state.sls (конкретный SLS)</option> <option value="apply">state.apply</option> </select> <div id="slsbox" style="display:none"> <label>Имя SLS (напр. webserver или nginx.config)</label> <input id="sls" style="width:100%" placeholder="webserver"> </div> <label class="chip" style="margin-top:14px"><input type="checkbox" id="testmode"> <span>Тестовый прогон (test=True, без изменений)</span></label> <div style="margin-top:16px"><button class="btn green" onclick="applyState()" style="font-size:15px;padding:11px 24px">▶ Применить</button></div> </div> <div class="card"><b>Результат</b><pre id="out" class="muted" style="margin-top:12px">—</pre></div> <script> async function loadChecks(){ const r=await fetch('/api/minions'); const d=await r.json(); const box=document.getElementById('mchecks'); if(!d.length){box.innerHTML='<span class=muted>Нет миньонов</span>';return;} box.innerHTML=d.map(m=>`<label class="chip"><input type="checkbox" class="mchk" value="${m.id}" ${m.status==='up'?'checked':''}><span>${m.id}</span> <span class="badge ${m.status}">${m.status==='up'?'В сети':'Нет связи'}</span></label>`).join(''); } function selectAll(v){document.querySelectorAll('.mchk').forEach(c=>c.checked=v);} function getSelected(){return Array.from(document.querySelectorAll('.mchk:checked')).map(c=>c.value);} function toggleSls(){document.getElementById('slsbox').style.display=(document.getElementById('mode').value==='highstate')?'none':'block';} async function applyState(){ const out=document.getElementById('out'); const sel=getSelected(); if(!sel.length){out.textContent='⚠ Выберите миньонов';return;} const mode=document.getElementById('mode').value; const sls=document.getElementById('sls').value.trim(); const test=document.getElementById('testmode').checked; out.textContent='Применение состояния на: '+sel.join(', ')+' … (может занять время)'; const r=await fetch('/api/state',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({minions:sel,mode,sls,test})}); const d=await r.json(); if(d.error){out.textContent='Ошибка: '+d.error;return;} out.textContent=d.result?JSON.stringify(d.result,null,2):(d.raw||'нет вывода'); } loadChecks(); </script> """ PACKAGES_HTML = """ <h2><svg class="ic" viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>Управление пакетами</h2> <div class="card"> <b>① Выберите миньоны</b> <div style="margin:10px 0"> <button class="btn gray" onclick="selectAll(true)">✓ Выбрать все</button> <button class="btn gray" onclick="selectAll(false)">✕ Снять все</button> </div> <div id="mchecks" class="muted">Загрузка миньонов…</div> </div> <div class="card"> <b>② Пакет</b> <label>Имя пакета (для установки/удаления)</label> <input id="pkgname" style="width:100%" placeholder="htop"> <div style="margin-top:14px"> <button class="btn" onclick="pkgAction('list')">Список установленных</button> <button class="btn green" onclick="pkgAction('install')">Установить</button> <button class="btn red" onclick="pkgAction('remove')">Удалить</button> <button class="btn" onclick="pkgAction('upgrade')">Обновить все</button> <button class="btn gray" onclick="pkgAction('refresh')">Обновить кэш (apt update)</button> </div> </div> <div class="card"><b>Результат</b><pre id="out" class="muted" style="margin-top:12px">—</pre></div> <script> async function loadChecks(){ const r=await fetch('/api/minions'); const d=await r.json(); const box=document.getElementById('mchecks'); if(!d.length){box.innerHTML='<span class=muted>Нет миньонов</span>';return;} box.innerHTML=d.map(m=>`<label class="chip"><input type="checkbox" class="mchk" value="${m.id}" ${m.status==='up'?'checked':''}><span>${m.id}</span> <span class="badge ${m.status}">${m.status==='up'?'В сети':'Нет связи'}</span></label>`).join(''); } function selectAll(v){document.querySelectorAll('.mchk').forEach(c=>c.checked=v);} function getSelected(){return Array.from(document.querySelectorAll('.mchk:checked')).map(c=>c.value);} async function pkgAction(action){ const out=document.getElementById('out'); const sel=getSelected(); const name=document.getElementById('pkgname').value.trim(); if(!sel.length){out.textContent='⚠ Выберите миньонов';return;} if((action==='install'||action==='remove')&&!name){out.textContent='⚠ Укажите имя пакета';return;} out.textContent='Выполняется на: '+sel.join(', ')+' … (может занять время)'; const r=await fetch('/api/packages/'+action,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({minions:sel,name})}); const d=await r.json(); if(d.error){out.textContent='Ошибка: '+d.error;return;} out.textContent=d.result?JSON.stringify(d.result,null,2):(d.raw||'нет вывода'); } loadChecks(); </script> """ FILES_HTML = """ <h2><svg class="ic" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>Файловый менеджер</h2> <div class="card"> <b>① Миньон и путь</b> <div class="grid" style="margin-top:10px;grid-template-columns:1fr 2fr"> <div><label>Миньон</label><select id="fminion" style="width:100%"></select></div> <div><label>Путь к файлу/каталогу</label><input id="fpath" style="width:100%" placeholder="/etc/hostname"></div> </div> <div style="margin-top:14px"> <button class="btn" onclick="listDir()">Список каталога (ls)</button> <button class="btn green" onclick="readFile()">Прочитать файл</button> </div> </div> <div class="card" id="editcard" style="display:none"> <b>② Содержимое файла</b> <textarea id="fcontent" style="width:100%;min-height:300px;font-family:monospace;margin-top:10px"></textarea> <div style="margin-top:14px"><button class="btn green" onclick="writeFile()">💾 Сохранить</button></div> </div> <div class="card"><b>Вывод</b><pre id="out" class="muted" style="margin-top:12px">—</pre></div> <script> async function loadMinionSelect(){ const r=await fetch('/api/minions'); const d=await r.json(); const sel=document.getElementById('fminion'); sel.innerHTML=d.map(m=>`<option value="${m.id}">${m.id}</option>`).join(''); } async function listDir(){ const out=document.getElementById('out'); const minion=document.getElementById('fminion').value; const path=document.getElementById('fpath').value.trim()||'/'; out.textContent='Загрузка…'; const r=await fetch('/api/files/list',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({minion,path})}); const d=await r.json(); if(d.error){out.textContent='Ошибка: '+d.error;return;} out.textContent=d.listing||'(пусто)'; } async function readFile(){ const out=document.getElementById('out'); const minion=document.getElementById('fminion').value; const path=document.getElementById('fpath').value.trim(); if(!path){out.textContent='⚠ Укажите путь к файлу';return;} out.textContent='Чтение…'; const r=await fetch('/api/files/read',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({minion,path})}); const d=await r.json(); if(d.error){out.textContent='Ошибка: '+d.error;document.getElementById('editcard').style.display='none';return;} document.getElementById('fcontent').value=d.content||''; document.getElementById('editcard').style.display='block'; out.textContent='Файл загружен. Отредактируйте и сохраните.'; } async function writeFile(){ const out=document.getElementById('out'); const minion=document.getElementById('fminion').value; const path=document.getElementById('fpath').value.trim(); const content=document.getElementById('fcontent').value; if(!confirm('Записать файл '+path+' на '+minion+'?'))return; out.textContent='Запись…'; const r=await fetch('/api/files/write',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({minion,path,content})}); const d=await r.json(); if(d.error){out.textContent='Ошибка: '+d.error;return;} out.textContent='Сохранено: '+JSON.stringify(d.result||d.raw,null,2); } loadMinionSelect(); </script> """ DASHBOARD_HTML = """ <h2><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>Дашборд <span class="cnt" id="dcnt">…</span></h2> <div class="muted" style="margin-bottom:16px">Мониторинг CPU / память / диск. Автообновление каждые 15 сек.</div> <div id="dgrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px"> <div class="muted">Загрузка…</div></div> <script> function bar(pct,color){ pct=Math.max(0,Math.min(100,pct)); return `<div style="background:rgba(15,23,42,.6);border-radius:8px;height:10px;overflow:hidden;margin:4px 0 10px"> <div style="height:100%;width:${pct}%;background:${color};transition:width .5s"></div></div>`; } async function loadDash(){ const r=await fetch('/api/minions'); const mins=await r.json(); const up=mins.filter(m=>m.status==='up'); document.getElementById('dcnt').textContent=up.length+' активных'; const box=document.getElementById('dgrid'); if(!up.length){box.innerHTML='<div class=muted>Нет активных миньонов</div>';return;} box.innerHTML=up.map(m=>`<div class="card" id="d_${m.id.replace(/[^a-z0-9]/gi,'_')}"><b>${m.id}</b><div class="muted">Загрузка метрик…</div></div>`).join(''); up.forEach(async m=>{ const cid='d_'+m.id.replace(/[^a-z0-9]/gi,'_'); const rr=await fetch('/api/dashboard/'+m.id); const d=await rr.json(); const card=document.getElementById(cid); if(!card)return; if(d.error){card.innerHTML=`<b>${m.id}</b><div class=muted>Ошибка: ${d.error}</div>`;return;} const la=d.loadavg||{}; const l1=la['1-min']!==undefined?la['1-min']:'?'; const memTotal=d.mem_total||0, memAvail=d.mem_avail||0; const memUsedPct=memTotal?Math.round((memTotal-memAvail)/memTotal*100):0; const memUsedMb=Math.round((memTotal-memAvail)/1024), memTotalMb=Math.round(memTotal/1024); const disk=d.disk||{}; let diskHtml=''; Object.keys(disk).slice(0,4).forEach(mp=>{ const cap=parseInt((disk[mp].capacity||'0').replace('%',''))||0; diskHtml+=`<div style="font-size:13px;color:#94a3b8">${mp} — ${disk[mp].capacity||'?'}</div>`+bar(cap,'linear-gradient(90deg,#f59e0b,#ef4444)'); }); card.innerHTML=`<b>${m.id}</b> <div style="font-size:13px;color:#94a3b8;margin-top:8px">Load average (1m): <b style="color:#e2e8f0">${l1}</b></div> <div style="font-size:13px;color:#94a3b8;margin-top:8px">Память: ${memUsedMb} / ${memTotalMb} MB (${memUsedPct}%)</div> ${bar(memUsedPct,'linear-gradient(90deg,#0ea5e9,#6366f1)')} <div style="font-size:13px;color:#94a3b8;margin-top:4px">Диски:</div> ${diskHtml||'<div class=muted>нет данных</div>'}`; }); } loadDash(); setInterval(loadDash, 15000); </script> """ if __name__ == "__main__": port = int(os.environ.get("PORT", "8080")) app.run(host="0.0.0.0", port=port, debug=False)