Files
deploy-panel/app.py
T
2026-09-23 16:33:44 +03:00

421 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import os
import re
import shutil
import subprocess
import time
from datetime import datetime, timezone
import requests
from flask import Flask, jsonify, redirect, render_template_string, request
app = Flask(__name__)
CF_API_TOKEN = os.environ["CF_API_TOKEN"]
CF_ZONE_ID = os.environ["CF_ZONE_ID"]
SELF_NAME = os.environ.get("SELF_CONTAINER_NAME", "")
API_TOKEN = os.environ["PANEL_API_TOKEN"]
SERVER_IP = os.environ.get("SERVER_IP", "148.135.181.126")
ROUTER_RULE_RE = re.compile(r"^traefik\.http\.routers\.[^.]+\.rule$")
def check_auth():
auth = request.headers.get("Authorization", "")
return auth == f"Bearer {API_TOKEN}"
def extract_hostname(labels):
"""Scan ALL traefik router rule labels, not just one matching the
container's own name — compose-based containers get auto-generated
docker names that no longer match the Traefik router name."""
for key, value in labels.items():
if ROUTER_RULE_RE.match(key) and "`" in value:
return value.split("`")[1]
return None
def human_relative(iso_ts):
if not iso_ts or iso_ts.startswith("0001-01-01"):
return None
try:
dt = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
except ValueError:
return None
secs = int((datetime.now(timezone.utc) - dt).total_seconds())
if secs < 60:
return f"{secs} sn önce"
mins = secs // 60
if mins < 60:
return f"{mins} dk önce"
hours = mins // 60
if hours < 24:
return f"{hours} sa önce"
days = hours // 24
return f"{days} gün önce"
def _cpu_times():
with open("/proc/stat") as f:
parts = f.readline().split()[1:]
nums = [int(x) for x in parts]
idle = nums[3] + nums[4]
total = sum(nums)
return idle, total
def server_stats():
stats = {}
# CPU: kisa bir ornekleme araligiyla kullanim yuzdesi
idle1, total1 = _cpu_times()
time.sleep(0.3)
idle2, total2 = _cpu_times()
d_idle = idle2 - idle1
d_total = total2 - total1
cpu_percent = round((1 - d_idle / d_total) * 100, 1) if d_total > 0 else 0.0
stats["cpu_percent"] = cpu_percent
stats["cpu_cores"] = os.cpu_count()
with open("/proc/loadavg") as f:
stats["load"] = " / ".join(f.read().split()[:3])
meminfo = {}
with open("/proc/meminfo") as f:
for line in f:
key, _, rest = line.partition(":")
meminfo[key] = int(rest.strip().split()[0]) # kB
mem_total = meminfo.get("MemTotal", 0)
mem_available = meminfo.get("MemAvailable", 0)
mem_used = mem_total - mem_available
stats["mem_used_gb"] = round(mem_used / 1024 / 1024, 1)
stats["mem_total_gb"] = round(mem_total / 1024 / 1024, 1)
stats["mem_percent"] = round(mem_used / mem_total * 100, 1) if mem_total else 0.0
disk = shutil.disk_usage("/")
stats["disk_used_gb"] = round(disk.used / 1024**3, 1)
stats["disk_total_gb"] = round(disk.total / 1024**3, 1)
stats["disk_percent"] = round(disk.used / disk.total * 100, 1) if disk.total else 0.0
with open("/proc/uptime") as f:
up_secs = int(float(f.read().split()[0]))
days, rem = divmod(up_secs, 86400)
hours, _ = divmod(rem, 3600)
stats["uptime"] = f"{days} gün {hours} saat" if days else f"{hours} saat"
all_containers = subprocess.run(
["docker", "ps", "-a", "--format", "{{.State}}"],
capture_output=True, text=True,
).stdout.split()
stats["containers_running"] = sum(1 for s in all_containers if s == "running")
stats["containers_total"] = len(all_containers)
return stats
def inspect_all(cid):
return json.loads(subprocess.run(
["docker", "inspect", cid], capture_output=True, text=True, check=True
).stdout)[0]
def list_projects():
out = subprocess.run(
["docker", "ps", "-a", "--filter", "label=traefik.enable=true", "--format", "{{.ID}}"],
capture_output=True, text=True, check=True,
).stdout.split()
projects = []
for cid in out:
info = inspect_all(cid)
name = info["Name"].lstrip("/")
if name == SELF_NAME:
continue
labels = info["Config"]["Labels"] or {}
state = info["State"]
status = state["Status"]
when = human_relative(state.get("StartedAt") if status == "running" else state.get("FinishedAt"))
projects.append({
"name": name,
"hostname": extract_hostname(labels),
"status": status,
"since": when,
"image": info["Config"]["Image"],
})
projects.sort(key=lambda p: p["name"])
return projects
def find_container_hostname(name):
result = subprocess.run(["docker", "inspect", name], capture_output=True, text=True)
if result.returncode != 0:
return None
info = json.loads(result.stdout)[0]
labels = info["Config"]["Labels"] or {}
return extract_hostname(labels)
def ensure_dns(hostname):
existing = requests.get(
f"https://api.cloudflare.com/client/v4/zones/{CF_ZONE_ID}/dns_records",
params={"type": "A", "name": hostname},
headers={"Authorization": f"Bearer {CF_API_TOKEN}"},
timeout=10,
).json()
if existing.get("result"):
return {"created": False}
requests.post(
f"https://api.cloudflare.com/client/v4/zones/{CF_ZONE_ID}/dns_records",
headers={"Authorization": f"Bearer {CF_API_TOKEN}", "Content-Type": "application/json"},
json={"type": "A", "name": hostname, "content": SERVER_IP, "ttl": 1, "proxied": True},
timeout=10,
)
return {"created": True}
def delete_dns_record(hostname):
if not hostname:
return
r = requests.get(
f"https://api.cloudflare.com/client/v4/zones/{CF_ZONE_ID}/dns_records",
params={"name": hostname},
headers={"Authorization": f"Bearer {CF_API_TOKEN}"},
timeout=10,
).json()
for rec in r.get("result", []):
requests.delete(
f"https://api.cloudflare.com/client/v4/zones/{CF_ZONE_ID}/dns_records/{rec['id']}",
headers={"Authorization": f"Bearer {CF_API_TOKEN}"},
timeout=10,
)
TEMPLATE = """
<!doctype html>
<html>
<head>
<title>Deploy Panel</title>
<meta charset="utf-8">
<meta http-equiv="refresh" content="20">
<style>
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2.5rem; }
h1 { color: #38bdf8; margin-bottom: 0.2rem; }
.sub { opacity: 0.55; font-size: 0.85rem; margin-bottom: 1.5rem; }
table { width: 100%; border-collapse: collapse; }
td, th { padding: 0.7rem; border-bottom: 1px solid #334155; text-align: left; vertical-align: middle; }
th { opacity: 0.7; font-weight: 600; font-size: 0.85rem; text-transform: uppercase; letter-spacing: .03em; }
a { color: #38bdf8; text-decoration: none; }
a:hover { text-decoration: underline; }
.badge { padding: 0.25rem 0.65rem; border-radius: 999px; font-size: 0.78rem; font-weight: 600; }
.running { background: #065f46; color: #d1fae5; }
.exited { background: #7f1d1d; color: #fecaca; }
.since { opacity: 0.55; font-size: 0.8rem; display: block; }
.actions { display: flex; gap: 0.4rem; }
button, .btn { border: none; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer;
font-size: 0.85rem; color: white; }
.btn-restart { background: #1e3a8a; }
.btn-restart:hover { background: #1e40af; }
.btn-logs { background: #334155; }
.btn-logs:hover { background: #475569; }
.btn-delete { background: #7f1d1d; }
.btn-delete:hover { background: #991b1b; }
.empty { opacity: 0.6; margin-top: 1.5rem; }
form { display: inline; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem; margin-bottom: 2rem; }
.stat-card { background: #1e293b; border-radius: 10px; padding: 1rem 1.2rem; }
.stat-label { font-size: 0.75rem; opacity: 0.6; text-transform: uppercase; letter-spacing: .03em; }
.stat-value { font-size: 1.5rem; font-weight: 700; margin: 0.15rem 0 0.5rem; }
.stat-sub { font-size: 0.78rem; opacity: 0.55; }
.bar { height: 6px; border-radius: 999px; background: #334155; overflow: hidden; margin-top: 0.5rem; }
.bar-fill { height: 100%; background: #38bdf8; }
.bar-fill.warn { background: #f59e0b; }
.bar-fill.crit { background: #ef4444; }
</style>
</head>
<body>
<h1>Deploy Panel</h1>
<div class="sub">{{ projects|length }} proje · 20 saniyede bir otomatik yenilenir · <a href="/">şimdi yenile</a></div>
<div class="stats">
<div class="stat-card">
<div class="stat-label">CPU ({{ s.cpu_cores }} çekirdek)</div>
<div class="stat-value">%{{ s.cpu_percent }}</div>
<div class="bar"><div class="bar-fill {{ 'crit' if s.cpu_percent > 85 else ('warn' if s.cpu_percent > 60 else '') }}" style="width: {{ s.cpu_percent }}%"></div></div>
<div class="stat-sub">yük: {{ s.load }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Bellek</div>
<div class="stat-value">%{{ s.mem_percent }}</div>
<div class="bar"><div class="bar-fill {{ 'crit' if s.mem_percent > 85 else ('warn' if s.mem_percent > 60 else '') }}" style="width: {{ s.mem_percent }}%"></div></div>
<div class="stat-sub">{{ s.mem_used_gb }} / {{ s.mem_total_gb }} GB</div>
</div>
<div class="stat-card">
<div class="stat-label">Disk</div>
<div class="stat-value">%{{ s.disk_percent }}</div>
<div class="bar"><div class="bar-fill {{ 'crit' if s.disk_percent > 85 else ('warn' if s.disk_percent > 60 else '') }}" style="width: {{ s.disk_percent }}%"></div></div>
<div class="stat-sub">{{ s.disk_used_gb }} / {{ s.disk_total_gb }} GB</div>
</div>
<div class="stat-card">
<div class="stat-label">Sunucu</div>
<div class="stat-value" style="font-size:1.1rem;">{{ s.uptime }}</div>
<div class="stat-sub">çalışma süresi</div>
</div>
<div class="stat-card">
<div class="stat-label">Container</div>
<div class="stat-value" style="font-size:1.1rem;">{{ s.containers_running }} / {{ s.containers_total }}</div>
<div class="stat-sub">çalışan / toplam</div>
</div>
</div>
{% if projects %}
<table>
<tr><th>Proje</th><th>Adres</th><th>Durum</th><th>İmaj</th><th></th></tr>
{% for p in projects %}
<tr>
<td>{{ p.name }}</td>
<td>{% if p.hostname %}<a href="https://{{ p.hostname }}" target="_blank">{{ p.hostname }}</a>{% else %}—{% endif %}</td>
<td>
<span class="badge {{ p.status }}">{{ p.status }}</span>
{% if p.since %}<span class="since">{{ p.since }}</span>{% endif %}
</td>
<td>{{ p.image }}</td>
<td>
<div class="actions">
<a class="btn btn-logs" href="/logs/{{ p.name }}">Loglar</a>
<form method="post" action="/restart/{{ p.name }}">
<button class="btn-restart" type="submit">Yeniden Başlat</button>
</form>
<form method="post" action="/delete/{{ p.name }}"
onsubmit="return confirm('{{ p.name }} silinsin mi? Container ve DNS kaydı kalıcı olarak kaldırılacak.');">
<button class="btn-delete" type="submit">Sil</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">Henüz deploy edilmiş bir proje yok.</p>
{% endif %}
</body>
</html>
"""
LOGS_TEMPLATE = """
<!doctype html>
<html>
<head>
<title>{{ name }} - Loglar</title>
<meta charset="utf-8">
<style>
body { font-family: ui-monospace, monospace; background: #0f172a; color: #e2e8f0; padding: 2rem; }
a { color: #38bdf8; }
h1 { font-family: system-ui, sans-serif; color: #38bdf8; font-size: 1.2rem; }
pre { background: #1e293b; padding: 1rem; border-radius: 8px; overflow-x: auto;
white-space: pre-wrap; word-break: break-word; font-size: 0.85rem; line-height: 1.4; }
</style>
</head>
<body>
<a href="/">&larr; panele dön</a>
<h1>{{ name }} — son {{ lines }} satır</h1>
<pre>{{ logs }}</pre>
</body>
</html>
"""
@app.route("/")
def index():
return render_template_string(TEMPLATE, projects=list_projects(), s=server_stats())
@app.route("/logs/<name>")
def logs_ui(name):
result = subprocess.run(
["docker", "logs", "--tail", "150", name],
capture_output=True, text=True,
)
output = (result.stdout or "") + (result.stderr or "")
return render_template_string(LOGS_TEMPLATE, name=name, lines=150, logs=output or "(log yok)")
@app.route("/restart/<name>", methods=["POST"])
def restart_ui(name):
subprocess.run(["docker", "restart", name])
return redirect("/")
@app.route("/delete/<name>", methods=["POST"])
def delete_ui(name):
projects = {p["name"]: p for p in list_projects()}
target = projects.get(name)
if target:
subprocess.run(["docker", "rm", "-f", name])
delete_dns_record(target["hostname"])
return redirect("/")
@app.route("/api/ensure-dns", methods=["POST"])
def api_ensure_dns():
if not check_auth():
return jsonify({"error": "unauthorized"}), 401
body = request.get_json(force=True)
hostname = body.get("hostname")
if not hostname:
return jsonify({"error": "hostname required"}), 400
result = ensure_dns(hostname)
return jsonify(result), 200
@app.route("/api/deploy", methods=["POST"])
def api_deploy():
if not check_auth():
return jsonify({"error": "unauthorized"}), 401
body = request.get_json(force=True)
name = body.get("name")
hostname = body.get("hostname")
port = str(body.get("port", "80"))
if not name or not hostname:
return jsonify({"error": "name and hostname required"}), 400
current_hostname = find_container_hostname(name)
if current_hostname is not None and current_hostname != hostname:
return jsonify({
"error": "name_conflict",
"message": f"'{name}' is already deployed with a different hostname ({current_hostname}).",
}), 409
ensure_dns(hostname)
subprocess.run(["docker", "rm", "-f", name])
rule = f"Host(`{hostname}`)"
subprocess.run([
"docker", "run", "-d",
"--name", name,
"--network", "proxy",
"--restart", "always",
"-l", "traefik.enable=true",
"-l", f"traefik.http.routers.{name}.rule={rule}",
"-l", f"traefik.http.routers.{name}.entrypoints=websecure",
"-l", f"traefik.http.routers.{name}.tls.certresolver=letsencrypt",
"-l", f"traefik.http.services.{name}.loadbalancer.server.port={port}",
f"{name}:latest",
], check=True)
return jsonify({"status": "deployed", "url": f"https://{hostname}"}), 200
@app.route("/api/deploy/<name>", methods=["DELETE"])
def api_delete(name):
if not check_auth():
return jsonify({"error": "unauthorized"}), 401
hostname = find_container_hostname(name)
subprocess.run(["docker", "rm", "-f", name])
delete_dns_record(hostname)
return jsonify({"status": "deleted"}), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)