commit 0810acb7e9d83cd7e7e2938eb91158d06a4dce19 Author: Peter Stockings Date: Mon Dec 22 12:14:37 2025 +1100 Initial setup diff --git a/.buildpacks b/.buildpacks new file mode 100644 index 0000000..6558b71 --- /dev/null +++ b/.buildpacks @@ -0,0 +1 @@ +https://github.com/heroku/heroku-buildpack-python#archive/v210 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e8e158 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.env +__pycache__/ diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..867a9ed --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app --bind 0.0.0.0:$PORT --workers 1 --threads 2 --timeout 30 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..904bcc3 --- /dev/null +++ b/app.py @@ -0,0 +1,116 @@ +import os +import json +import subprocess +from datetime import datetime +from flask import Flask, jsonify, render_template + +app = Flask(__name__) + +POLL_SECONDS = int(os.getenv("POLL_SECONDS", "10")) +SHOW_CONTAINERS = os.getenv("SHOW_CONTAINERS", "0") == "1" + +def sh(cmd: list[str]) -> str: + return subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True).strip() + +def dokku_apps() -> list[str]: + out = sh(["dokku", "apps:list"]) + lines = [l.strip() for l in out.splitlines()] + # Format: + # =====> My Apps + # app1 + # app2 + return [l for l in lines if l and not l.startswith("====")] + +def dokku_urls(app_name: str) -> list[str]: + try: + out = sh(["dokku", "urls:report", app_name, "--urls"]) + return [u.strip() for u in out.split() if u.strip()] + except Exception: + return [] + +def dokku_ps_report(app_name: str) -> dict: + try: + out = sh(["dokku", "ps:report", app_name]) + # crude parse: "Key: value" + data = {} + for line in out.splitlines(): + if ":" in line: + k, v = line.split(":", 1) + data[k.strip()] = v.strip() + return data + except Exception as e: + return {"error": str(e)} + +def docker_stats() -> dict: + """ + Returns a mapping of container_name -> {cpu, mem_used, mem_limit, mem_pct} + """ + fmt = "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}" + out = sh(["docker", "stats", "--no-stream", "--format", fmt]) + stats = {} + for line in out.splitlines(): + name, cpu, mem_usage, mem_pct = line.split("\t") + # mem_usage like: "58.84MiB / 384MiB" + mem_used, mem_limit = [s.strip() for s in mem_usage.split("/", 1)] + stats[name] = { + "cpu": cpu, + "mem_used": mem_used, + "mem_limit": mem_limit, + "mem_pct": mem_pct, + } + return stats + +def docker_ps() -> list[dict]: + fmt = "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" + out = sh(["docker", "ps", "--format", fmt]) + rows = [] + for line in out.splitlines(): + name, image, status, ports = line.split("\t") + rows.append({"name": name, "image": image, "status": status, "ports": ports}) + return rows + +def collect(): + apps = dokku_apps() + stats = docker_stats() + + rows = [] + for a in apps: + urls = dokku_urls(a) + ps = dokku_ps_report(a) + # Your containers are typically named like ".web.1" + container_name = f"{a}.web.1" + cstats = stats.get(container_name, None) + + rows.append({ + "app": a, + "urls": urls, + "running": ps.get("Running", ""), + "processes": ps.get("Processes", ""), + "status_web_1": ps.get("Status web 1", ""), + "cpu": cstats["cpu"] if cstats else "", + "mem_used": cstats["mem_used"] if cstats else "", + "mem_limit": cstats["mem_limit"] if cstats else "", + "mem_pct": cstats["mem_pct"] if cstats else "", + }) + + extra_containers = docker_ps() if SHOW_CONTAINERS else [] + + return { + "generated_at": datetime.utcnow().isoformat() + "Z", + "poll_seconds": POLL_SECONDS, + "apps": sorted(rows, key=lambda r: r["app"]), + "containers": extra_containers, + } + +@app.get("/api/status") +def api_status(): + return jsonify(collect()) + +@app.get("/") +def index(): + return render_template("index.html", poll_seconds=POLL_SECONDS) + +@app.get("/partial/apps") +def partial_apps(): + data = collect() + return render_template("apps_table.html", data=data) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b4fa998 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask==2.2.2 +gunicorn==20.1.0 \ No newline at end of file diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..47c0b10 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.9.13 \ No newline at end of file diff --git a/templates/apps_table.html b/templates/apps_table.html new file mode 100644 index 0000000..cf3e794 --- /dev/null +++ b/templates/apps_table.html @@ -0,0 +1,40 @@ +
Generated at: {{ data.generated_at }}
+ + + + + + + + + + + + + + {% for r in data.apps %} + + + + + + + + + {% endfor %} + +
AppURLsRunningCPURAMDokku status
{{ r.app }} + {% if r.urls %} + {% for u in r.urls %} + + {% endfor %} + {% else %} + + {% endif %} + {{ r.running or "—" }}{{ r.cpu or "—" }} + {% if r.mem_used %} + {{ r.mem_used }} / {{ r.mem_limit }} ({{ r.mem_pct }}) + {% else %} + — + {% endif %} + {{ r.status_web_1 or "—" }}
\ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..11a0c08 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,56 @@ + + + + + + Status - peterstockings.com + + + + + +

Status

+
+ Auto-refresh every {{ poll_seconds }}s + · JSON +
+ +
+ Loading… +
+ + + \ No newline at end of file