"""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()