Files

393 lines
18 KiB
Python

"""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)
def docker_run_host_cmd(name, cmd_list):
"""Run a command on the Ampère host via privileged nsenter container."""
docker_call("DELETE", f"/containers/{name}?force=1")
full_cmd = ["nsenter","-t","1","-m","-u","-i","-n","-p","--"] + cmd_list
config = {"Image": "alpine", "Cmd": full_cmd,
"HostConfig": {"Privileged": True, "PidMode": "host", "AutoRemove": True}}
status, body = docker_call("POST", f"/containers/create?name={name}", config)
if status in (201, 409):
docker_call("POST", f"/containers/{name}/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
# Préfère le log v2 s'il existe, sinon log v1
import os as _os
log_v2 = "/data/fallout_ingest_v2.log"
log_v1 = "/data/fallout_ingest.log"
log_path = log_v2 if _os.path.exists(log_v2) else log_v1
running = _log_running(log_path)
current_pdf=None; current_chunk=None; total_chunks=None
chunks_done=[]; log_tail=[]; version="v2" if log_path==log_v2 else "v1"
try:
with open(log_path) 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,"version":version,"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})
elif p == "/admin/run-ingest":
body = self.read_body()
script = body.get("script","fallout")
if script == "fallout":
cmd = ["sh","-c","PYTHONUNBUFFERED=1 python3 -u /home/ubuntu/fallout/ingest.py >> /home/ubuntu/fallout/ingest.log 2>&1 &"]
else:
self.send_json({"ok":False,"error":"unknown script"}); return
ok = docker_run_host_cmd("host-ingest", cmd)
self.send_json({"ok":ok,"script":script})
else:
self.send_response(404); self.end_headers()
if __name__=="__main__":
print("CoyoteOS Dashboard :5000")
HTTPServer(("0.0.0.0",5000),Handler).serve_forever()