From 01453efac6c1e55946ad59ae4ba9515f24a08bd7 Mon Sep 17 00:00:00 2001 From: Corback Date: Mon, 15 Jun 2026 16:02:14 +0000 Subject: [PATCH] Add app.py --- app.py | 366 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 app.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..b041f72 --- /dev/null +++ b/app.py @@ -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()