feat: add web dashboard with slide-in drawer navigation
- Flask backend with 23 API routes (entertainment, telegram, docker, system) - Alpine.js + Tailwind CSS dark-mode SPA with 4 tabs - pos-dashboard CLI tool with port/config management - Mobile slide-in drawer with swipe-to-close - Sticky header stays pinned on scroll - Tab completion fixes for category-less tools - POST /api/telegram/commands endpoint for adding commands
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Docker API blueprint.
|
||||
|
||||
Routes under ``/api/docker/`` for container and stack management.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.runner import run_pos, run_cmd
|
||||
from lib.parsers import parse_docker_ps, parse_docker_health, parse_docker_stacks
|
||||
|
||||
docker_bp = Blueprint("docker", __name__, url_prefix="/api/docker")
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _check_docker() -> str | None:
|
||||
"""Return an error string if docker is unavailable, else None."""
|
||||
result = run_cmd(["docker", "info"], timeout=10)
|
||||
if result.get("error") or result["returncode"] != 0:
|
||||
return result.get("error", "docker daemon not reachable")
|
||||
return None
|
||||
|
||||
|
||||
def _validate_service_name(name: str) -> str | None:
|
||||
"""Return None if valid, otherwise an error message."""
|
||||
if not name:
|
||||
return "missing 'service' in request body"
|
||||
if not re.match(r"^[a-zA-Z0-9._-]+$", name):
|
||||
return f"Invalid service name '{name}'"
|
||||
return None
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@docker_bp.route("/ps", methods=["GET"])
|
||||
def docker_ps():
|
||||
"""List containers: ``docker ps -a --format ...`` or ``pos docker ps``."""
|
||||
docker_err = _check_docker()
|
||||
if docker_err:
|
||||
return jsonify({"error": docker_err}), 503
|
||||
|
||||
# Use docker inspect directly for reliable JSON output, fall back to pos docker ps
|
||||
fmt = "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}\t{{.ID}}"
|
||||
result = run_cmd(
|
||||
["docker", "ps", "-a", "--format", fmt],
|
||||
timeout=15,
|
||||
)
|
||||
if result.get("error") or result["returncode"] != 0:
|
||||
# Fallback to pos docker ps
|
||||
result = run_pos(["docker", "ps"], timeout=30)
|
||||
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({"error": result["error"]}), 500
|
||||
|
||||
# Try structured parsing via pos docker ps
|
||||
containers = parse_docker_ps(result["stdout"])
|
||||
|
||||
# If pos docker ps didn't parse well, parse docker ps --format output directly
|
||||
if not containers and result["stdout"].strip():
|
||||
containers = _parse_raw_docker_ps(result["stdout"])
|
||||
|
||||
return jsonify({"containers": containers})
|
||||
|
||||
|
||||
def _parse_raw_docker_ps(text: str) -> list[dict]:
|
||||
"""Parse ``docker ps -a --format`` tab-separated output."""
|
||||
containers = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
name, image, status, ports = parts[0], parts[1], parts[2], parts[3]
|
||||
cid = parts[4] if len(parts) > 4 else ""
|
||||
containers.append({
|
||||
"name": name,
|
||||
"image": image,
|
||||
"status": status,
|
||||
"health": "",
|
||||
"uptime": "",
|
||||
"ips": "",
|
||||
"ports": ports,
|
||||
"id": cid,
|
||||
})
|
||||
return containers
|
||||
|
||||
|
||||
@docker_bp.route("/health", methods=["GET"])
|
||||
def docker_health():
|
||||
"""Get container health summary."""
|
||||
docker_err = _check_docker()
|
||||
if docker_err:
|
||||
return jsonify({"error": docker_err}), 503
|
||||
|
||||
result = run_pos(["docker", "health"], timeout=30)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
# health exits 1 when unhealthy — that's still valid output
|
||||
if not result["stdout"]:
|
||||
return jsonify({"error": result.get("error", "health check failed")}), 500
|
||||
|
||||
summary = parse_docker_health(result["stdout"])
|
||||
return jsonify(summary)
|
||||
|
||||
|
||||
@docker_bp.route("/stacks", methods=["GET"])
|
||||
def docker_stacks():
|
||||
"""Get containers grouped by compose stack."""
|
||||
docker_err = _check_docker()
|
||||
if docker_err:
|
||||
return jsonify({"error": docker_err}), 503
|
||||
|
||||
result = run_pos(["docker", "stack"], timeout=30)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({"error": result["error"]}), 500
|
||||
|
||||
stacks = parse_docker_stacks(result["stdout"])
|
||||
return jsonify({"stacks": stacks})
|
||||
|
||||
|
||||
@docker_bp.route("/compose/up", methods=["POST"])
|
||||
def docker_compose_up():
|
||||
"""Bring up a compose service."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
service = body.get("service", "")
|
||||
err = _validate_service_name(service)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
result = run_pos(["docker", "compose", "up", service], timeout=120)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stdout": result.get("stdout", ""),
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
"message": f"compose up: {service}",
|
||||
"output": result["stdout"].strip(),
|
||||
})
|
||||
|
||||
|
||||
@docker_bp.route("/compose/down", methods=["POST"])
|
||||
def docker_compose_down():
|
||||
"""Bring down a compose service."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
service = body.get("service", "")
|
||||
err = _validate_service_name(service)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
result = run_pos(["docker", "compose", "down", service], timeout=120)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stdout": result.get("stdout", ""),
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
"message": f"compose down: {service}",
|
||||
"output": result["stdout"].strip(),
|
||||
})
|
||||
|
||||
|
||||
@docker_bp.route("/compose/restart", methods=["POST"])
|
||||
def docker_compose_restart():
|
||||
"""Restart a compose service."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
service = body.get("service", "")
|
||||
err = _validate_service_name(service)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
result = run_pos(["docker", "compose", "restart", service], timeout=120)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stdout": result.get("stdout", ""),
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
"message": f"compose restart: {service}",
|
||||
"output": result["stdout"].strip(),
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Entertainment API blueprint.
|
||||
|
||||
Routes under ``/api/entertainment/`` that wrap the entertainment CLI tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.runner import run_pos, run_cmd
|
||||
from lib.parsers import parse_entertainment_status
|
||||
from lib.config import load_entertainment_env, mask_secrets
|
||||
|
||||
entertainment_bp = Blueprint("entertainment", __name__, url_prefix="/api/entertainment")
|
||||
|
||||
# Directories to search for installed plugins
|
||||
_PLUGIN_SEARCH_DIRS = [
|
||||
Path("/usr/local/share/linux_post_install/entertainment"),
|
||||
Path(__file__).resolve().parents[2] / "entertainment",
|
||||
]
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _plugin_dirs():
|
||||
"""Yield existing plugin directories."""
|
||||
for d in _PLUGIN_SEARCH_DIRS:
|
||||
if d.is_dir():
|
||||
yield d
|
||||
|
||||
|
||||
def _discover_plugins() -> list[dict]:
|
||||
"""Read ``# POS_PLUGIN:`` / ``# POS_KEYS:`` headers from installed plugins."""
|
||||
plugins: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for d in _plugin_dirs():
|
||||
for script in sorted(d.glob("*.sh")):
|
||||
name = ""
|
||||
keys: list[dict] = []
|
||||
description = ""
|
||||
with open(script, "r", encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
if line.startswith("# POS_PLUGIN:"):
|
||||
name = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("# POS_KEYS:"):
|
||||
raw = line.split(":", 1)[1].strip()
|
||||
# Format: KEY description (required|optional)
|
||||
parts = raw.split(None, 1)
|
||||
if parts:
|
||||
key_entry = {"key": parts[0]}
|
||||
if len(parts) > 1:
|
||||
rest = parts[1]
|
||||
if "(required)" in rest:
|
||||
key_entry["required"] = True
|
||||
key_entry["description"] = rest.replace("(required)", "").strip()
|
||||
elif "(optional)" in rest:
|
||||
key_entry["required"] = False
|
||||
key_entry["description"] = rest.replace("(optional)", "").strip()
|
||||
else:
|
||||
key_entry["description"] = rest.strip()
|
||||
keys.append(key_entry)
|
||||
elif line.startswith("# Entertainment plugin:") and not description:
|
||||
description = line.split(":", 1)[1].strip()
|
||||
|
||||
# Stop reading after code starts
|
||||
if name and (line.startswith("usage()") or line.startswith("main()")):
|
||||
break
|
||||
|
||||
if name and name not in seen:
|
||||
seen.add(name)
|
||||
plugins.append({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"path": str(script),
|
||||
"keys": keys,
|
||||
})
|
||||
|
||||
return plugins
|
||||
|
||||
|
||||
def _plugin_names() -> set[str]:
|
||||
"""Set of installed plugin names."""
|
||||
return {p["name"] for p in _discover_plugins()}
|
||||
|
||||
|
||||
def _validate_plugin_name(name: str) -> str | None:
|
||||
"""Return None if valid, otherwise an error message."""
|
||||
if not name or not re.match(r"^[a-zA-Z0-9_-]+$", name):
|
||||
return "Invalid plugin name"
|
||||
if name not in _plugin_names():
|
||||
return f"Plugin '{name}' not found"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_interval(interval: str) -> str | None:
|
||||
"""Return None if valid, otherwise an error message."""
|
||||
pattern = r"^[0-9]+[mhd]$|^(hourly|daily|weekly)$|^OnCalendar="
|
||||
if not re.match(pattern, interval):
|
||||
return f"Invalid interval '{interval}' (allowed: 5m, 10m, 15m, 30m, 45m, hourly, 2h, 6h, 12h, daily, weekly)"
|
||||
return None
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@entertainment_bp.route("/status", methods=["GET"])
|
||||
def entertainment_status():
|
||||
"""Run ``pos entertainment status`` and return parsed JSON."""
|
||||
result = run_pos(["entertainment", "status"], timeout=15)
|
||||
if result.get("error"):
|
||||
return jsonify({"error": result["error"]}), 500
|
||||
parsed = parse_entertainment_status(result["stdout"])
|
||||
return jsonify(parsed)
|
||||
|
||||
|
||||
@entertainment_bp.route("/plugins", methods=["GET"])
|
||||
def entertainment_plugins():
|
||||
"""Discover installed entertainment plugins and return their metadata."""
|
||||
plugins = _discover_plugins()
|
||||
return jsonify({"plugins": plugins})
|
||||
|
||||
|
||||
@entertainment_bp.route("/send/<plugin>", methods=["POST"])
|
||||
def entertainment_send(plugin: str):
|
||||
"""Run ``pos entertainment send <plugin> --print`` and return output."""
|
||||
err = _validate_plugin_name(plugin)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
result = run_pos(["entertainment", "send", plugin, "--print"], timeout=60)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
"returncode": result["returncode"],
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
"output": result["stdout"].strip(),
|
||||
"returncode": result["returncode"],
|
||||
})
|
||||
|
||||
|
||||
@entertainment_bp.route("/enable", methods=["POST"])
|
||||
def entertainment_enable():
|
||||
"""Enable an auto-trigger: ``pos entertainment enable <plugin> <interval>``."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
plugin = body.get("plugin", "")
|
||||
interval = body.get("interval", "")
|
||||
|
||||
if not plugin:
|
||||
return jsonify({"error": "missing 'plugin' in request body"}), 400
|
||||
|
||||
err = _validate_plugin_name(plugin)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
if interval:
|
||||
err = _validate_interval(interval)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
args = ["entertainment", "enable", plugin]
|
||||
if interval:
|
||||
args.append(interval)
|
||||
|
||||
result = run_pos(args, timeout=30)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({"message": result["stdout"].strip() or f"enabled {plugin}"})
|
||||
|
||||
|
||||
@entertainment_bp.route("/disable", methods=["POST"])
|
||||
def entertainment_disable():
|
||||
"""Disable an auto-trigger: ``pos entertainment disable <plugin>``."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
plugin = body.get("plugin", "")
|
||||
|
||||
if not plugin:
|
||||
return jsonify({"error": "missing 'plugin' in request body"}), 400
|
||||
|
||||
err = _validate_plugin_name(plugin)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
result = run_pos(["entertainment", "disable", plugin], timeout=30)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({"message": result["stdout"].strip() or f"disabled {plugin}"})
|
||||
|
||||
|
||||
@entertainment_bp.route("/config", methods=["GET"])
|
||||
def entertainment_config():
|
||||
"""Load entertainment.env, mask secrets, return key-value pairs."""
|
||||
data = load_entertainment_env()
|
||||
masked = mask_secrets(data)
|
||||
return jsonify({"config": masked})
|
||||
|
||||
|
||||
@entertainment_bp.route("/config/set", methods=["POST"])
|
||||
def entertainment_config_set():
|
||||
"""Set a config value: ``pos entertainment config set KEY=VALUE``."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
key = body.get("key", "")
|
||||
value = body.get("value")
|
||||
|
||||
if not key:
|
||||
return jsonify({"error": "missing 'key' in request body"}), 400
|
||||
if value is None:
|
||||
return jsonify({"error": "missing 'value' in request body"}), 400
|
||||
|
||||
# Validate key format
|
||||
if not re.match(r"^[A-Z][A-Z0-9_]*$", key):
|
||||
return jsonify({"error": f"Invalid key '{key}' (expected UPPER_SNAKE)"}), 400
|
||||
|
||||
pair = f"{key}={value}"
|
||||
result = run_pos(["entertainment", "config", "set", pair], timeout=15)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({"message": result["stdout"].strip() or f"set {key}"})
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
System API blueprint.
|
||||
|
||||
Routes under ``/api/system/`` for host health, info, and dashboard settings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.runner import run_pos, run_cmd
|
||||
from lib.parsers import parse_system_health
|
||||
|
||||
system_bp = Blueprint("system", __name__, url_prefix="/api/system")
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _config_dir() -> Path:
|
||||
return Path(os.environ.get(
|
||||
"XDG_CONFIG_HOME", os.path.expanduser("~/.config")
|
||||
)) / "linux_post_install"
|
||||
|
||||
|
||||
def _dashboard_env_path() -> Path:
|
||||
return _config_dir() / "dashboard.env"
|
||||
|
||||
|
||||
def _read_dashboard_env() -> dict[str, str]:
|
||||
"""Read dashboard.env into a dict (no shell expansion)."""
|
||||
path = _dashboard_env_path()
|
||||
data: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return data
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
data[k.strip()] = v.strip().strip('"').strip("'")
|
||||
return data
|
||||
|
||||
|
||||
def _write_dashboard_env(data: dict[str, str]) -> None:
|
||||
"""Write dashboard.env preserving comments."""
|
||||
path = _dashboard_env_path()
|
||||
lines: list[str] = []
|
||||
for k, v in data.items():
|
||||
lines.append(f"{k}={v}")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@system_bp.route("/health", methods=["GET"])
|
||||
def system_health():
|
||||
"""Run ``pos system health`` and return structured checks."""
|
||||
result = run_pos(["system", "health"], timeout=30)
|
||||
if result.get("error") and not result["stdout"]:
|
||||
return jsonify({"error": result["error"]}), 500
|
||||
|
||||
checks = parse_system_health(result["stdout"])
|
||||
return jsonify({"checks": checks})
|
||||
|
||||
|
||||
@system_bp.route("/info", methods=["GET"])
|
||||
def system_info():
|
||||
"""Collect basic system information.
|
||||
|
||||
Returns::
|
||||
|
||||
{
|
||||
"hostname": "...",
|
||||
"uptime": "...",
|
||||
"load": "...",
|
||||
"ip": "...",
|
||||
"os": { "name": "...", "version": "...", "id": "..." }
|
||||
}
|
||||
"""
|
||||
info: dict = {}
|
||||
|
||||
# Hostname
|
||||
res = run_cmd(["hostname", "-s"], timeout=5)
|
||||
info["hostname"] = res["stdout"].strip() if not res.get("error") else "unknown"
|
||||
|
||||
# Uptime
|
||||
res = run_cmd(["uptime", "-p"], timeout=5)
|
||||
info["uptime"] = res["stdout"].strip() if not res.get("error") else "unknown"
|
||||
|
||||
# Load average
|
||||
try:
|
||||
with open("/proc/loadavg", "r") as f:
|
||||
parts = f.read().strip().split()
|
||||
info["load"] = " ".join(parts[:3]) if len(parts) >= 3 else "unknown"
|
||||
except (OSError, IndexError):
|
||||
info["load"] = "unknown"
|
||||
|
||||
# Public IP (best-effort, fast timeout)
|
||||
res = run_cmd(["curl", "-fsS", "-m", "5", "https://api.ipify.org"], timeout=10)
|
||||
info["ip"] = res["stdout"].strip() if not res.get("error") else "unreachable"
|
||||
|
||||
# OS release info
|
||||
os_info: dict = {}
|
||||
try:
|
||||
with open("/etc/os-release", "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
v = v.strip('"')
|
||||
k_lower = k.lower()
|
||||
if k_lower == "name":
|
||||
os_info["name"] = v
|
||||
elif k_lower == "version":
|
||||
os_info["version"] = v
|
||||
elif k_lower == "id":
|
||||
os_info["id"] = v
|
||||
except OSError:
|
||||
os_info = {"name": "unknown", "version": "unknown", "id": "unknown"}
|
||||
|
||||
info["os"] = os_info
|
||||
|
||||
return jsonify(info)
|
||||
|
||||
|
||||
# ── Dashboard settings ─────────────────────────────────────────────────────────
|
||||
|
||||
@system_bp.route("/settings", methods=["GET"])
|
||||
def dashboard_settings():
|
||||
"""Return current dashboard config (secrets masked)."""
|
||||
data = _read_dashboard_env()
|
||||
# Mask secrets
|
||||
for k in list(data.keys()):
|
||||
if any(s in k.upper() for s in ("TOKEN", "KEY", "SECRET", "PASSWORD")):
|
||||
val = data[k]
|
||||
data[k] = val[:4] + "****" if len(val) > 4 else "****"
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@system_bp.route("/settings", methods=["POST"])
|
||||
def dashboard_settings_update():
|
||||
"""Update dashboard config values.
|
||||
|
||||
Expects JSON body: ``{"key": "DASHBOARD_PORT", "value": "9090"}``
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
key = (body.get("key") or "").strip()
|
||||
value = (body.get("value") or "").strip()
|
||||
|
||||
if not key:
|
||||
return jsonify({"error": "missing key"}), 400
|
||||
|
||||
# Validate key name (UPPER_SNAKE only)
|
||||
if not re.match(r"^[A-Z][A-Z0-9_]*$", key):
|
||||
return jsonify({"error": f"invalid key format: {key}"}), 400
|
||||
|
||||
allowed_keys = {"DASHBOARD_PORT", "DASHBOARD_HOST", "DASHBOARD_LOG_LEVEL"}
|
||||
if key not in allowed_keys:
|
||||
return jsonify({"error": f"key not allowed: {key} — allowed: {', '.join(sorted(allowed_keys))}"}), 400
|
||||
|
||||
# Validate port specifically
|
||||
if key == "DASHBOARD_PORT":
|
||||
if not value.isdigit() or not (1 <= int(value) <= 65535):
|
||||
return jsonify({"error": "port must be a number between 1 and 65535"}), 400
|
||||
|
||||
data = _read_dashboard_env()
|
||||
old_value = data.get(key)
|
||||
data[key] = value
|
||||
_write_dashboard_env(data)
|
||||
|
||||
restarted = False
|
||||
# Restart the dashboard service if it's running and port/host changed
|
||||
if key in ("DASHBOARD_PORT", "DASHBOARD_HOST") and old_value != value:
|
||||
svc = "pos-dashboard.service"
|
||||
try:
|
||||
import subprocess
|
||||
res = subprocess.run(
|
||||
["systemctl", "--user", "is-active", "--quiet", svc],
|
||||
timeout=5,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "restart", svc],
|
||||
timeout=10,
|
||||
)
|
||||
restarted = True
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
resp: dict = {"ok": True, "key": key, "value": value}
|
||||
if restarted:
|
||||
resp["restarted"] = True
|
||||
resp["message"] = f"Updated {key} and restarted dashboard"
|
||||
else:
|
||||
resp["message"] = f"Updated {key} — restart with: pos dashboard start"
|
||||
return jsonify(resp)
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Telegram Listener API blueprint.
|
||||
|
||||
Routes under ``/api/telegram/`` that manage the Telegram bot listener service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.runner import run_pos, run_cmd
|
||||
from lib.parsers import parse_telegram_commands
|
||||
from lib.config import load_telegram_env, mask_secrets
|
||||
|
||||
telegram_bp = Blueprint("telegram", __name__, url_prefix="/api/telegram")
|
||||
|
||||
_SERVICE = "pos-telegram-listener.service"
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _systemd_user(*args: str, timeout: int = 15) -> dict:
|
||||
"""Run a ``systemctl --user`` command."""
|
||||
return run_cmd(["systemctl", "--user"] + list(args), timeout=timeout)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@telegram_bp.route("/status", methods=["GET"])
|
||||
def telegram_status():
|
||||
"""Check the systemd user service status for the Telegram listener.
|
||||
|
||||
Returns::
|
||||
|
||||
{"running": bool, "active_state": str, "sub_state": str}
|
||||
"""
|
||||
# Check active state
|
||||
res = _systemd_user("is-active", _SERVICE)
|
||||
running = res["returncode"] == 0 and res["stdout"].strip() == "active"
|
||||
|
||||
# Get detailed properties
|
||||
props: dict = {"active_state": res["stdout"].strip(), "sub_state": ""}
|
||||
detail = _systemd_user("show", _SERVICE, "--property=ActiveState,SubState,MainPID")
|
||||
if detail["returncode"] == 0:
|
||||
for line in detail["stdout"].splitlines():
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
props[k.strip().lower()] = v.strip()
|
||||
|
||||
return jsonify({
|
||||
"running": running,
|
||||
"active_state": props.get("activestate", "unknown"),
|
||||
"sub_state": props.get("substate", "unknown"),
|
||||
"pid": props.get("mainpid", ""),
|
||||
})
|
||||
|
||||
|
||||
@telegram_bp.route("/enable", methods=["POST"])
|
||||
def telegram_enable():
|
||||
"""Enable the Telegram listener service."""
|
||||
result = run_pos(["communication", "telegram-listener", "--enable"], timeout=30)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({"message": result["stdout"].strip() or "telegram listener enabled"})
|
||||
|
||||
|
||||
@telegram_bp.route("/disable", methods=["POST"])
|
||||
def telegram_disable():
|
||||
"""Disable the Telegram listener service."""
|
||||
result = run_pos(["communication", "telegram-listener", "--disable"], timeout=30)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({"message": result["stdout"].strip() or "telegram listener disabled"})
|
||||
|
||||
|
||||
@telegram_bp.route("/commands", methods=["GET"])
|
||||
def telegram_commands():
|
||||
"""Read the telegram_commands.env and return parsed command map."""
|
||||
config_dir = Path(
|
||||
__import__("os").environ.get(
|
||||
"XDG_CONFIG_HOME",
|
||||
__import__("os").path.expanduser("~/.config"),
|
||||
)
|
||||
) / "linux_post_install"
|
||||
map_file = config_dir / "telegram_commands.env"
|
||||
|
||||
if not map_file.is_file():
|
||||
return jsonify({"commands": [], "map_file": str(map_file)})
|
||||
|
||||
text = map_file.read_text(encoding="utf-8", errors="replace")
|
||||
commands = parse_telegram_commands(text)
|
||||
return jsonify({"commands": commands, "map_file": str(map_file)})
|
||||
|
||||
|
||||
@telegram_bp.route("/commands", methods=["POST"])
|
||||
def telegram_commands_add():
|
||||
"""Add a command to the telegram_commands.env map file.
|
||||
|
||||
Expects JSON body: ``{"command": "/status", "description": "...", "script": "..."}``
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
command = (body.get("command") or "").strip()
|
||||
description = (body.get("description") or "").strip()
|
||||
script = (body.get("script") or "").strip()
|
||||
|
||||
if not command:
|
||||
return jsonify({"error": "missing command name"}), 400
|
||||
if not script:
|
||||
return jsonify({"error": "missing bash script"}), 400
|
||||
|
||||
# Validate command starts with /
|
||||
if not command.startswith("/"):
|
||||
command = "/" + command
|
||||
|
||||
# Validate command name (alphanumeric, hyphens, underscores)
|
||||
if not re.match(r"^/[a-zA-Z0-9_-]+$", command):
|
||||
return jsonify({"error": f"invalid command name: {command} — use only letters, numbers, hyphens"}), 400
|
||||
|
||||
# Build the map line
|
||||
if description:
|
||||
line = f"{command}::{description}={script}"
|
||||
else:
|
||||
line = f"{command}={script}"
|
||||
|
||||
# Write to the map file
|
||||
config_dir = Path(
|
||||
os.environ.get(
|
||||
"XDG_CONFIG_HOME",
|
||||
os.path.expanduser("~/.config"),
|
||||
)
|
||||
) / "linux_post_install"
|
||||
map_file = config_dir / "telegram_commands.env"
|
||||
|
||||
# Check if command already exists
|
||||
if map_file.is_file():
|
||||
existing = map_file.read_text(encoding="utf-8", errors="replace")
|
||||
for existing_line in existing.splitlines():
|
||||
existing_line = existing_line.strip()
|
||||
if not existing_line or existing_line.startswith("#"):
|
||||
continue
|
||||
existing_cmd = existing_line.split("=")[0].split("::")[0].strip()
|
||||
if existing_cmd == command:
|
||||
return jsonify({"error": f"command {command} already exists — delete it first"}), 409
|
||||
|
||||
# Append the new command
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(map_file, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
return jsonify({"ok": True, "command": command, "message": f"Command {command} added"})
|
||||
|
||||
|
||||
@telegram_bp.route("/commands/sync", methods=["POST"])
|
||||
def telegram_commands_sync():
|
||||
"""Push mapped commands to the bot's "/" menu (setMyCommands)."""
|
||||
result = run_pos(
|
||||
["communication", "telegram-listener", "--sync-commands"],
|
||||
timeout=30,
|
||||
)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({"message": result["stdout"].strip() or "commands synced"})
|
||||
Reference in New Issue
Block a user