Add app.py
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
"""CoyoteOS Dashboard — API backend"""
|
||||
import json, os, time, re, ssl, socket, subprocess, urllib.request, urllib.error
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
_ssl_ctx = ssl.create_default_context()
|
||||
_ssl_ctx.check_hostname = False
|
||||
_ssl_ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
PROM = "http://prometheus:9090"
|
||||
|
||||
# Container groups for AdminSys
|
||||
GROUPS = [
|
||||
{"id": "infra", "name": "Infrastructure", "icon": "🔧", "protected": True,
|
||||
"containers": ["nginx-proxy-manager","gitea","wiki-serve","prometheus","grafana","dash","node-exporter","chromadb"]},
|
||||
{"id": "multimedia", "name": "Multimédia", "icon": "🎵", "protected": False,
|
||||
"containers": ["navidrome","metube","beets"]},
|
||||
{"id": "famille", "name": "Sites famille", "icon": "🏠", "protected": False,
|
||||
"containers": ["voyage-famille","voyage-famille-test","CoyoteOS_db_container"]},
|
||||
{"id": "corback", "name": "Corback Studio", "icon": "🎹", "protected": False,
|
||||
"containers": ["corback-studio","php-fpm-corback"]},
|
||||
{"id": "sites", "name": "Sites perso / PHP", "icon": "🌐", "protected": False,
|
||||
"containers": ["coyote-site","php-fpm","php-fpm-test"]},
|
||||
{"id": "fallout", "name": "Fallout JDR", "icon": "☢️", "protected": False,
|
||||
"containers": ["chromadb"]},
|
||||
]
|
||||
|
||||
# ── /proc helpers (Ampere) ─────────────────────────────────────────────────
|
||||
def get_cpu():
|
||||
def read():
|
||||
with open("/proc/stat") as f: p = f.readline().split()
|
||||
t = sum(int(x) for x in p[1:]); i = int(p[4]); return t, i
|
||||
t1,i1 = read(); time.sleep(0.5); t2,i2 = read()
|
||||
dt = t2-t1
|
||||
return round((1-((i2-i1)/dt))*100,1) if dt>0 else 0.0
|
||||
|
||||
def get_mem():
|
||||
d={}
|
||||
with open("/proc/meminfo") as f:
|
||||
for l in f:
|
||||
k,v = l.split(":")[0], l.split(":")[1].strip().split()[0]
|
||||
d[k]=int(v)
|
||||
total=d["MemTotal"]*1024; avail=d["MemAvailable"]*1024; used=total-avail
|
||||
return {"total":total,"used":used,"free":avail,"percent":round(used/total*100,1)}
|
||||
|
||||
def get_disk():
|
||||
s=os.statvfs("/"); total=s.f_blocks*s.f_frsize; free=s.f_bavail*s.f_frsize; used=total-free
|
||||
return {"total":total,"used":used,"free":free,"percent":round(used/total*100,1)}
|
||||
|
||||
def get_uptime():
|
||||
with open("/proc/uptime") as f: secs=float(f.read().split()[0])
|
||||
d=int(secs//86400); h=int((secs%86400)//3600); m=int((secs%3600)//60)
|
||||
return {"seconds":int(secs),"days":d,"hours":h,"minutes":m,"human":f"{d}j {h}h {m}m"}
|
||||
|
||||
def get_network():
|
||||
def read():
|
||||
r={}
|
||||
with open("/proc/net/dev") as f:
|
||||
for l in f.readlines()[2:]:
|
||||
p=l.split(); iface=p[0].rstrip(":")
|
||||
if iface in ("lo",) or iface.startswith(("docker","br-","veth")): continue
|
||||
r[iface]={"rx":int(p[1]),"tx":int(p[9])}
|
||||
return r
|
||||
n1=read(); time.sleep(1); n2=read()
|
||||
rx=tx=0
|
||||
for i in n1:
|
||||
if i in n2: rx+=n2[i]["rx"]-n1[i]["rx"]; tx+=n2[i]["tx"]-n1[i]["tx"]
|
||||
return {"rx_bps":max(0,rx),"tx_bps":max(0,tx)}
|
||||
|
||||
# ── Docker socket ──────────────────────────────────────────────────────────
|
||||
def docker_call(method, path, data=None):
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(10)
|
||||
sock.connect("/var/run/docker.sock")
|
||||
body = json.dumps(data).encode() if data is not None else b""
|
||||
hdr = (f"{method} {path} HTTP/1.0\r\nHost: localhost\r\n"
|
||||
f"Content-Type: application/json\r\nContent-Length: {len(body)}\r\n\r\n")
|
||||
sock.sendall(hdr.encode() + body)
|
||||
resp = b""
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk: break
|
||||
resp += chunk
|
||||
sock.close()
|
||||
status = int(resp.split(b" ")[1]) if resp else 0
|
||||
rbody = resp.split(b"\r\n\r\n", 1)[1] if b"\r\n\r\n" in resp else b""
|
||||
return status, rbody
|
||||
|
||||
def get_docker_containers():
|
||||
try:
|
||||
_, body = docker_call("GET", "/containers/json")
|
||||
containers = json.loads(body)
|
||||
return [{"name": c["Names"][0].lstrip("/"), "image": c["Image"].split(":")[0],
|
||||
"status": c["Status"], "state": c["State"]} for c in containers]
|
||||
except: return []
|
||||
|
||||
def docker_action_local(name, action):
|
||||
"""action: start | stop | restart"""
|
||||
status, _ = docker_call("POST", f"/containers/{name}/{action}", {})
|
||||
return status in (200, 204, 304)
|
||||
|
||||
def docker_reboot_host():
|
||||
"""Reboot Ampère via privileged container."""
|
||||
docker_call("DELETE", "/containers/host-reboot?force=1")
|
||||
config = {"Image": "alpine", "Cmd": ["nsenter","-t","1","-m","-u","-i","-n","-p","--","reboot"],
|
||||
"HostConfig": {"Privileged": True, "PidMode": "host", "AutoRemove": True}}
|
||||
status, body = docker_call("POST", "/containers/create?name=host-reboot", config)
|
||||
if status in (201, 409):
|
||||
docker_call("POST", "/containers/host-reboot/start", {})
|
||||
return status in (201, 409)
|
||||
|
||||
# ── SSH helpers (Guardian / Vigile) ───────────────────────────────────────
|
||||
SSH_KEYS = {
|
||||
"guardian": {"key": "/ssh/guardian.key", "host": "82.70.226.48"},
|
||||
"vigile": {"key": "/ssh/vigile.key", "host": "79.72.30.231"},
|
||||
}
|
||||
|
||||
def ssh_run(vm, cmd, timeout=10):
|
||||
cfg = SSH_KEYS.get(vm)
|
||||
if not cfg: return False, "VM inconnue"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-i", cfg["key"], "-o", "StrictHostKeyChecking=no",
|
||||
"-o", "BatchMode=yes", "-o", f"ConnectTimeout={timeout}",
|
||||
f"ubuntu@{cfg['host']}", cmd],
|
||||
capture_output=True, timeout=timeout+2)
|
||||
return r.returncode == 0, r.stdout.decode().strip() or r.stderr.decode().strip()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def docker_action_remote(vm, name, action):
|
||||
ok, out = ssh_run(vm, f"sudo docker {action} {name}")
|
||||
return ok
|
||||
|
||||
def ssh_reboot(vm):
|
||||
ok, _ = ssh_run(vm, "sudo reboot", timeout=5)
|
||||
return ok
|
||||
|
||||
# ── Ollama ────────────────────────────────────────────────────────────────
|
||||
OLLAMA_URL = "http://172.20.0.1:11434"
|
||||
|
||||
def get_ollama_ps():
|
||||
try:
|
||||
with urllib.request.urlopen(OLLAMA_URL+"/api/ps", timeout=3) as r:
|
||||
d = json.loads(r.read())
|
||||
return [{"name": m["name"], "size_gb": round(m.get("size",0)/1e9,1),
|
||||
"expires": m.get("expires_at","")} for m in d.get("models",[])]
|
||||
except: return []
|
||||
|
||||
# ── Pipeline helpers ───────────────────────────────────────────────────────
|
||||
def _log_running(path, max_age=120):
|
||||
try: return (time.time() - os.path.getmtime(path)) < max_age
|
||||
except: return False
|
||||
|
||||
def get_pipeline():
|
||||
state={"processed":[],"failed":[]}
|
||||
try:
|
||||
with open("/data/pipeline_state.json") as f: state=json.load(f)
|
||||
except: pass
|
||||
running = _log_running("/data/pipeline.log")
|
||||
current_pdf=None; current_chunk=None; total_chunks=None
|
||||
chunks_done=[]; log_tail=[]
|
||||
try:
|
||||
with open("/data/pipeline.log") as f: lines=f.readlines()
|
||||
log_tail=[l.rstrip() for l in lines[-40:] if l.strip()]
|
||||
if running:
|
||||
for l in reversed(lines):
|
||||
l=l.rstrip()
|
||||
if not current_pdf and re.search(r"^\[.+\.pdf\]",l):
|
||||
current_pdf=l.strip()[1:-1]
|
||||
m=re.search(r"chunk (\d+)/(\d+)",l)
|
||||
if m and current_chunk is None:
|
||||
current_chunk=int(m.group(1)); total_chunks=int(m.group(2))
|
||||
m2=re.search(r"chunk \d+/\d+.*OK \((\d+)s",l)
|
||||
if m2: chunks_done.append(int(m2.group(1)))
|
||||
if current_pdf and current_chunk and len(chunks_done)>=1: break
|
||||
except: pass
|
||||
last_t=chunks_done[-1] if chunks_done else None
|
||||
avg_t=round(sum(chunks_done)/len(chunks_done)) if chunks_done else None
|
||||
eta=None
|
||||
if current_chunk and total_chunks and last_t:
|
||||
rem=(total_chunks-current_chunk)*last_t
|
||||
eta=f"{rem//3600}h{(rem%3600)//60}m" if rem>=3600 else f"{rem//60}m"
|
||||
return {"running":running,"done":len(state.get("processed",[])),"failed":len(state.get("failed",[])),
|
||||
"current_pdf":current_pdf if running else None,"current_chunk":current_chunk,
|
||||
"total_chunks":total_chunks,"avg_chunk_s":avg_t,"last_chunk_s":last_t,
|
||||
"eta_current_pdf":eta,"log_tail":log_tail}
|
||||
|
||||
def get_fallout_ingest():
|
||||
state={"ingested":[],"failed":[]}
|
||||
try:
|
||||
with open("/data/fallout_state.json") as f: state=json.load(f)
|
||||
except: pass
|
||||
running = _log_running("/data/fallout_ingest.log")
|
||||
current_pdf=None; current_chunk=None; total_chunks=None
|
||||
chunks_done=[]; log_tail=[]
|
||||
try:
|
||||
with open("/data/fallout_ingest.log") as f: lines=f.readlines()
|
||||
log_tail=[l.rstrip() for l in lines[-30:] if l.strip()]
|
||||
for l in reversed(lines):
|
||||
l=l.rstrip()
|
||||
if not current_pdf and re.search(r"^\[fallout/.+\.pdf\]",l):
|
||||
current_pdf=l.strip()[1:-1]
|
||||
m=re.search(r"chunk (\d+)/(\d+)",l)
|
||||
if m and current_chunk is None:
|
||||
current_chunk=int(m.group(1)); total_chunks=int(m.group(2))
|
||||
m2=re.search(r"chunk \d+/\d+.*OK \((\d+\.?\d*)s",l)
|
||||
if m2: chunks_done.append(float(m2.group(1)))
|
||||
except: pass
|
||||
avg_t=round(sum(chunks_done)/len(chunks_done),1) if chunks_done else None
|
||||
return {"running":running,"done":len(state.get("ingested",[])),"failed":len(state.get("failed",[])),
|
||||
"total":17,"current_pdf":current_pdf if running else None,
|
||||
"current_chunk":current_chunk,"total_chunks":total_chunks,"avg_embed_s":avg_t,"log_tail":log_tail}
|
||||
|
||||
# ── Prometheus ─────────────────────────────────────────────────────────────
|
||||
def prom_query(q):
|
||||
try:
|
||||
url = PROM+"/api/v1/query?query="+urllib.request.quote(q)
|
||||
with urllib.request.urlopen(url, timeout=4) as r:
|
||||
d=json.loads(r.read()); res=d.get("data",{}).get("result",[])
|
||||
if res: return float(res[0]["value"][1])
|
||||
except: pass
|
||||
return None
|
||||
|
||||
def prom_query_all(q):
|
||||
try:
|
||||
url = PROM+"/api/v1/query?query="+urllib.request.quote(q)
|
||||
with urllib.request.urlopen(url, timeout=4) as r:
|
||||
d=json.loads(r.read()); return d.get("data",{}).get("result",[])
|
||||
except: return []
|
||||
|
||||
def get_vm_metrics(instance):
|
||||
cpu = prom_query(f'100-(avg by(instance)(rate(node_cpu_seconds_total{{mode="idle",instance="{instance}"}}[2m]))*100)')
|
||||
mem_total = prom_query(f'node_memory_MemTotal_bytes{{instance="{instance}"}}')
|
||||
mem_avail = prom_query(f'node_memory_MemAvailable_bytes{{instance="{instance}"}}')
|
||||
disk_size = prom_query(f'node_filesystem_size_bytes{{instance="{instance}",mountpoint="/",fstype!="tmpfs"}}')
|
||||
disk_avail = prom_query(f'node_filesystem_avail_bytes{{instance="{instance}",mountpoint="/",fstype!="tmpfs"}}')
|
||||
uptime_s = prom_query(f'time()-node_boot_time_seconds{{instance="{instance}"}}')
|
||||
mem_pct = round((1 - mem_avail/mem_total)*100, 1) if mem_total and mem_avail else None
|
||||
disk_pct = round((1 - disk_avail/disk_size)*100, 1) if disk_size and disk_avail else None
|
||||
up_h = int(uptime_s//3600) if uptime_s else None
|
||||
up_d = int(uptime_s//86400) if uptime_s else None
|
||||
return {
|
||||
"cpu": round(cpu,1) if cpu else None,
|
||||
"mem_pct": mem_pct,
|
||||
"mem_total_gb": round(mem_total/1e9,1) if mem_total else None,
|
||||
"disk_pct": disk_pct,
|
||||
"uptime_human": f"{up_d}j {(up_h or 0)%24}h" if uptime_s else None,
|
||||
"available": cpu is not None
|
||||
}
|
||||
|
||||
def get_summary30():
|
||||
rx=prom_query('sum(increase(node_network_receive_bytes_total{device!="lo"}[30d]))')
|
||||
tx=prom_query('sum(increase(node_network_transmit_bytes_total{device!="lo"}[30d]))')
|
||||
cpu=prom_query('avg(100-(avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[30d]))*100))')
|
||||
total=(rx or 0)+(tx or 0)
|
||||
return {"rx_30d":rx,"tx_30d":tx,"total_bw":total if total>0 else None,
|
||||
"avg_cpu_30d":round(cpu,1) if cpu else None}
|
||||
|
||||
# ── Sites ─────────────────────────────────────────────────────────────────
|
||||
SITES = [
|
||||
{"name":"CoyoteOS", "url":"http://coyoteos.ovh", "icon":"globe"},
|
||||
{"name":"Wiki MAO", "url":"https://wiki.coyoteos.ovh", "icon":"book"},
|
||||
{"name":"Gitea", "url":"https://git.coyoteos.ovh", "icon":"git"},
|
||||
{"name":"Grafana", "url":"http://grafana.coyoteos.ovh", "icon":"chart"},
|
||||
{"name":"Navidrome", "url":"http://music.coyoteos.ovh", "icon":"music"},
|
||||
{"name":"Studio", "url":"http://studio.coyoteos.ovh", "icon":"studio"},
|
||||
{"name":"Dashboard", "url":"http://dash.coyoteos.ovh", "icon":"dash"},
|
||||
]
|
||||
_site_cache={"ts":0,"data":[]}
|
||||
|
||||
def check_sites():
|
||||
now=time.time()
|
||||
if now-_site_cache["ts"]<30: return _site_cache["data"]
|
||||
results=[]
|
||||
for s in SITES:
|
||||
if "dash.coyoteos.ovh" in s["url"]:
|
||||
results.append({**s,"status":200,"up":True}); continue
|
||||
try:
|
||||
req=urllib.request.Request(s["url"],method="HEAD")
|
||||
req.add_header("User-Agent","CoyoteOS-Dash/1.0")
|
||||
with urllib.request.urlopen(req, timeout=6, context=_ssl_ctx) as r:
|
||||
results.append({**s,"status":r.status,"up":r.status<400})
|
||||
except urllib.error.HTTPError as e:
|
||||
results.append({**s,"status":e.code,"up":e.code<400})
|
||||
except Exception as e:
|
||||
results.append({**s,"status":0,"up":False,"error":str(e)[:60]})
|
||||
_site_cache["ts"]=now; _site_cache["data"]=results
|
||||
return results
|
||||
|
||||
# ── HTTP Server ────────────────────────────────────────────────────────────
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self,*a): pass
|
||||
|
||||
def send_json(self, data, code=200):
|
||||
body=json.dumps(data,default=str).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type","application/json")
|
||||
self.send_header("Access-Control-Allow-Origin","*")
|
||||
self.end_headers(); self.wfile.write(body)
|
||||
|
||||
def read_body(self):
|
||||
length = int(self.headers.get("Content-Length",0))
|
||||
return json.loads(self.rfile.read(length)) if length else {}
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin","*")
|
||||
self.send_header("Access-Control-Allow-Methods","GET,POST")
|
||||
self.send_header("Access-Control-Allow-Headers","Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
p = self.path.split("?")[0]
|
||||
if p == "/metrics":
|
||||
self.send_json({"cpu":{"percent":get_cpu()},"memory":get_mem(),
|
||||
"disk":get_disk(),"uptime":get_uptime(),"network":get_network(),
|
||||
"timestamp":int(time.time())})
|
||||
elif p == "/pipeline": self.send_json(get_pipeline())
|
||||
elif p == "/fallout-ingest": self.send_json(get_fallout_ingest())
|
||||
elif p == "/docker-ps": self.send_json(get_docker_containers())
|
||||
elif p == "/groups": self.send_json(GROUPS)
|
||||
elif p == "/ollama-ps": self.send_json(get_ollama_ps())
|
||||
elif p == "/sites": self.send_json(check_sites())
|
||||
elif p == "/summary30": self.send_json(get_summary30())
|
||||
elif p == "/vm-metrics":
|
||||
qs = self.path[self.path.find("?")+1:] if "?" in self.path else ""
|
||||
params = dict(kv.split("=") for kv in qs.split("&") if "=" in kv)
|
||||
inst = params.get("instance","Guardian")
|
||||
self.send_json(get_vm_metrics(inst))
|
||||
elif p in ("/", "/index.html"):
|
||||
with open("/app/index.html","rb") as f: body=f.read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type","text/html; charset=utf-8")
|
||||
self.end_headers(); self.wfile.write(body)
|
||||
else:
|
||||
self.send_response(404); self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
p = self.path
|
||||
if p == "/admin/docker":
|
||||
body = self.read_body()
|
||||
vm = body.get("vm","ampere")
|
||||
name = body.get("container","")
|
||||
action = body.get("action","stop") # start | stop | restart
|
||||
if not name: self.send_json({"ok":False,"error":"no container"}, 400); return
|
||||
if vm == "ampere":
|
||||
ok = docker_action_local(name, action)
|
||||
else:
|
||||
ok = docker_action_remote(vm, name, action)
|
||||
self.send_json({"ok":ok,"vm":vm,"container":name,"action":action})
|
||||
|
||||
elif p == "/admin/reboot":
|
||||
body = self.read_body()
|
||||
vm = body.get("vm","ampere")
|
||||
if vm == "ampere":
|
||||
ok = docker_reboot_host()
|
||||
else:
|
||||
ok = ssh_reboot(vm)
|
||||
self.send_json({"ok":ok,"vm":vm})
|
||||
|
||||
else:
|
||||
self.send_response(404); self.end_headers()
|
||||
|
||||
if __name__=="__main__":
|
||||
print("CoyoteOS Dashboard :5000")
|
||||
HTTPServer(("0.0.0.0",5000),Handler).serve_forever()
|
||||
Reference in New Issue
Block a user