Dokku uses sudo under the hood which isnt available in the python build pack, so just use docker to fetch info
This commit is contained in:
170
app.py
170
app.py
@@ -6,47 +6,33 @@ from flask import Flask, jsonify, render_template
|
|||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
DOKKU = "/usr/bin/dokku"
|
|
||||||
DOCKER = "/usr/bin/docker"
|
|
||||||
POLL_SECONDS = int(os.getenv("POLL_SECONDS", "10"))
|
POLL_SECONDS = int(os.getenv("POLL_SECONDS", "10"))
|
||||||
SHOW_CONTAINERS = os.getenv("SHOW_CONTAINERS", "0") == "1"
|
APP_DOMAIN = os.getenv("APP_DOMAIN", "peterstockings.com")
|
||||||
|
DOCKER = os.getenv("DOCKER_BIN", "/usr/bin/docker")
|
||||||
|
SHOW_INFRA = os.getenv("SHOW_INFRA", "1") == "1"
|
||||||
|
|
||||||
|
# Optional JSON map: {"gitea":"https://gitea.peterstockings.com", "bloodpressure":"https://bp.peterstockings.com"}
|
||||||
|
APP_URL_OVERRIDES = {}
|
||||||
|
try:
|
||||||
|
APP_URL_OVERRIDES = json.loads(os.getenv("APP_URL_OVERRIDES", "{}"))
|
||||||
|
except Exception:
|
||||||
|
APP_URL_OVERRIDES = {}
|
||||||
|
|
||||||
def sh(cmd: list[str]) -> str:
|
def sh(cmd: list[str]) -> str:
|
||||||
return subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True).strip()
|
return subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True).strip()
|
||||||
|
|
||||||
def dokku_apps() -> list[str]:
|
def docker_ps_all() -> list[dict]:
|
||||||
out = sh([DOKKU, "apps:list"])
|
# Name + Image + Status + Ports
|
||||||
lines = [l.strip() for l in out.splitlines()]
|
fmt = "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"
|
||||||
# Format:
|
out = sh([DOCKER, "ps", "--format", fmt])
|
||||||
# =====> My Apps
|
rows = []
|
||||||
# app1
|
for line in out.splitlines():
|
||||||
# app2
|
name, image, status, ports = line.split("\t")
|
||||||
return [l for l in lines if l and not l.startswith("====")]
|
rows.append({"name": name, "image": image, "status": status, "ports": ports})
|
||||||
|
return rows
|
||||||
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:
|
def docker_stats() -> dict:
|
||||||
"""
|
# Name + CPU + MemUsage + MemPerc
|
||||||
Returns a mapping of container_name -> {cpu, mem_used, mem_limit, mem_pct}
|
|
||||||
"""
|
|
||||||
fmt = "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
|
fmt = "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
|
||||||
out = sh([DOCKER, "stats", "--no-stream", "--format", fmt])
|
out = sh([DOCKER, "stats", "--no-stream", "--format", fmt])
|
||||||
stats = {}
|
stats = {}
|
||||||
@@ -62,52 +48,94 @@ def docker_stats() -> dict:
|
|||||||
}
|
}
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
def docker_ps() -> list[dict]:
|
def docker_inspect_restart_count(container_name: str) -> int:
|
||||||
fmt = "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"
|
# RestartCount is useful when stuff is flapping / OOMing
|
||||||
out = sh([DOCKER, "ps", "--format", fmt])
|
try:
|
||||||
rows = []
|
out = sh([DOCKER, "inspect", "-f", "{{.RestartCount}}", container_name])
|
||||||
for line in out.splitlines():
|
return int(out.strip())
|
||||||
name, image, status, ports = line.split("\t")
|
except Exception:
|
||||||
rows.append({"name": name, "image": image, "status": status, "ports": ports})
|
return 0
|
||||||
return rows
|
|
||||||
|
def is_app_web_container(name: str) -> bool:
|
||||||
|
# Dokku apps typically have containers like "<app>.web.1"
|
||||||
|
return name.endswith(".web.1") and not name.startswith("dokku.")
|
||||||
|
|
||||||
|
def infer_app_name(container_name: str) -> str:
|
||||||
|
# "<app>.web.1" -> "<app>"
|
||||||
|
return container_name.rsplit(".web.1", 1)[0]
|
||||||
|
|
||||||
|
def infer_url(app_name: str) -> str:
|
||||||
|
if app_name in APP_URL_OVERRIDES:
|
||||||
|
return APP_URL_OVERRIDES[app_name]
|
||||||
|
# default
|
||||||
|
return f"https://{app_name}.{APP_DOMAIN}"
|
||||||
|
|
||||||
|
def classify_infra(container_name: str) -> bool:
|
||||||
|
return (
|
||||||
|
container_name.startswith("dokku.postgres.")
|
||||||
|
or container_name.startswith("dokku.redis.")
|
||||||
|
or container_name.startswith("dokku.mysql.")
|
||||||
|
or container_name.startswith("dokku.mongodb.")
|
||||||
|
or container_name == "dokku.minio.storage"
|
||||||
|
or container_name == "logspout"
|
||||||
|
)
|
||||||
|
|
||||||
def collect():
|
def collect():
|
||||||
apps = dokku_apps()
|
ps_rows = docker_ps_all()
|
||||||
stats = docker_stats()
|
stats = docker_stats()
|
||||||
|
|
||||||
rows = []
|
apps = []
|
||||||
|
infra = []
|
||||||
|
|
||||||
|
for r in ps_rows:
|
||||||
|
name = r["name"]
|
||||||
|
s = stats.get(name, {})
|
||||||
|
row = {
|
||||||
|
"container": name,
|
||||||
|
"image": r["image"],
|
||||||
|
"status": r["status"],
|
||||||
|
"ports": r["ports"],
|
||||||
|
"cpu": s.get("cpu", ""),
|
||||||
|
"mem_used": s.get("mem_used", ""),
|
||||||
|
"mem_limit": s.get("mem_limit", ""),
|
||||||
|
"mem_pct": s.get("mem_pct", ""),
|
||||||
|
"restarts": docker_inspect_restart_count(name),
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_app_web_container(name):
|
||||||
|
app_name = infer_app_name(name)
|
||||||
|
row["app"] = app_name
|
||||||
|
row["url"] = infer_url(app_name)
|
||||||
|
apps.append(row)
|
||||||
|
elif SHOW_INFRA and classify_infra(name):
|
||||||
|
infra.append(row)
|
||||||
|
|
||||||
|
# Sort stable
|
||||||
|
apps.sort(key=lambda x: x["app"])
|
||||||
|
infra.sort(key=lambda x: x["container"])
|
||||||
|
|
||||||
|
# Simple top-line summary
|
||||||
|
warnings = []
|
||||||
for a in apps:
|
for a in apps:
|
||||||
urls = dokku_urls(a)
|
# mem_pct is like "15.32%"
|
||||||
ps = dokku_ps_report(a)
|
try:
|
||||||
# Your containers are typically named like "<app>.web.1"
|
pct = float(a["mem_pct"].replace("%", "")) if a["mem_pct"] else 0.0
|
||||||
container_name = f"{a}.web.1"
|
except Exception:
|
||||||
cstats = stats.get(container_name, None)
|
pct = 0.0
|
||||||
|
if pct >= 85:
|
||||||
rows.append({
|
warnings.append(f"{a['app']} RAM high ({a['mem_pct']})")
|
||||||
"app": a,
|
if a["restarts"] >= 3:
|
||||||
"urls": urls,
|
warnings.append(f"{a['app']} restarting (restarts={a['restarts']})")
|
||||||
"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 {
|
return {
|
||||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||||
"poll_seconds": POLL_SECONDS,
|
"poll_seconds": POLL_SECONDS,
|
||||||
"apps": sorted(rows, key=lambda r: r["app"]),
|
"domain": APP_DOMAIN,
|
||||||
"containers": extra_containers,
|
"apps": apps,
|
||||||
|
"infra": infra,
|
||||||
|
"warnings": warnings,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/status")
|
|
||||||
def api_status():
|
|
||||||
return jsonify(collect())
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template("index.html", poll_seconds=POLL_SECONDS)
|
return render_template("index.html", poll_seconds=POLL_SECONDS)
|
||||||
@@ -116,3 +144,7 @@ def index():
|
|||||||
def partial_apps():
|
def partial_apps():
|
||||||
data = collect()
|
data = collect()
|
||||||
return render_template("apps_table.html", data=data)
|
return render_template("apps_table.html", data=data)
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
def api_status():
|
||||||
|
return jsonify(collect())
|
||||||
|
|||||||
@@ -1,40 +1,76 @@
|
|||||||
<div class="muted">Generated at: {{ data.generated_at }}</div>
|
<div class="muted">Generated at: {{ data.generated_at }}</div>
|
||||||
|
|
||||||
|
{% if data.warnings %}
|
||||||
|
<div style="margin: 10px 0; padding: 10px; border: 1px solid #f2c037; background: #fff7db; border-radius: 8px;">
|
||||||
|
<strong>Warnings</strong>
|
||||||
|
<ul>
|
||||||
|
{% for w in data.warnings %}
|
||||||
|
<li>{{ w }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Apps</h2>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>App</th>
|
<th>App</th>
|
||||||
<th>URLs</th>
|
<th>URL</th>
|
||||||
<th>Running</th>
|
<th>Status</th>
|
||||||
<th>CPU</th>
|
<th>CPU</th>
|
||||||
<th>RAM</th>
|
<th>RAM</th>
|
||||||
<th>Dokku status</th>
|
<th>Restarts</th>
|
||||||
|
<th>Image</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for r in data.apps %}
|
{% for r in data.apps %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{ r.app }}</strong></td>
|
<td><strong>{{ r.app }}</strong></td>
|
||||||
<td>
|
<td><a href="{{ r.url }}">{{ r.url }}</a></td>
|
||||||
{% if r.urls %}
|
<td class="muted">{{ r.status }}</td>
|
||||||
{% for u in r.urls %}
|
|
||||||
<div><a href="{{ u }}">{{ u }}</a></div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<span class="muted">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ r.running or "—" }}</td>
|
|
||||||
<td>{{ r.cpu or "—" }}</td>
|
<td>{{ r.cpu or "—" }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if r.mem_used %}
|
{% if r.mem_used %}
|
||||||
{{ r.mem_used }} / {{ r.mem_limit }} ({{ r.mem_pct }})
|
{{ r.mem_used }} / {{ r.mem_limit }} ({{ r.mem_pct }})
|
||||||
{% else %}
|
{% else %} — {% endif %}
|
||||||
—
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="muted">{{ r.status_web_1 or "—" }}</td>
|
<td>{{ r.restarts }}</td>
|
||||||
|
<td class="muted">{{ r.image }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{% if data.infra %}
|
||||||
|
<h2 style="margin-top: 24px;">Infra</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Container</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>CPU</th>
|
||||||
|
<th>RAM</th>
|
||||||
|
<th>Restarts</th>
|
||||||
|
<th>Image</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in data.infra %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ r.container }}</strong></td>
|
||||||
|
<td class="muted">{{ r.status }}</td>
|
||||||
|
<td>{{ r.cpu or "—" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if r.mem_used %}
|
||||||
|
{{ r.mem_used }} / {{ r.mem_limit }} ({{ r.mem_pct }})
|
||||||
|
{% else %} — {% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ r.restarts }}</td>
|
||||||
|
<td class="muted">{{ r.image }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
Reference in New Issue
Block a user