Initial setup
This commit is contained in:
1
.buildpacks
Normal file
1
.buildpacks
Normal file
@@ -0,0 +1 @@
|
||||
https://github.com/heroku/heroku-buildpack-python#archive/v210
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/.env
|
||||
__pycache__/
|
||||
1
Procfile
Normal file
1
Procfile
Normal file
@@ -0,0 +1 @@
|
||||
web: gunicorn app:app --bind 0.0.0.0:$PORT --workers 1 --threads 2 --timeout 30
|
||||
116
app.py
Normal file
116
app.py
Normal file
@@ -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 "<app>.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)
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Flask==2.2.2
|
||||
gunicorn==20.1.0
|
||||
1
runtime.txt
Normal file
1
runtime.txt
Normal file
@@ -0,0 +1 @@
|
||||
python-3.9.13
|
||||
40
templates/apps_table.html
Normal file
40
templates/apps_table.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<div class="muted">Generated at: {{ data.generated_at }}</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>App</th>
|
||||
<th>URLs</th>
|
||||
<th>Running</th>
|
||||
<th>CPU</th>
|
||||
<th>RAM</th>
|
||||
<th>Dokku status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in data.apps %}
|
||||
<tr>
|
||||
<td><strong>{{ r.app }}</strong></td>
|
||||
<td>
|
||||
{% if r.urls %}
|
||||
{% 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>
|
||||
{% if r.mem_used %}
|
||||
{{ r.mem_used }} / {{ r.mem_limit }} ({{ r.mem_pct }})
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="muted">{{ r.status_web_1 or "—" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
56
templates/index.html
Normal file
56
templates/index.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Status - peterstockings.com</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, Arial;
|
||||
margin: 24px;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eee;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Status</h1>
|
||||
<div class="muted">
|
||||
Auto-refresh every {{ poll_seconds }}s
|
||||
· <a href="/api/status">JSON</a>
|
||||
</div>
|
||||
|
||||
<div hx-get="/partial/apps" hx-trigger="load, every {{ poll_seconds }}s" hx-swap="innerHTML">
|
||||
Loading…
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user