from flask import Flask, jsonify, request, send_from_directory import psutil, os, time, signal, threading, subprocess, platform, glob, json, struct try: psutil.PROCFS_PATH = os.environ.get("PROCFS_PATH", "/proc") except Exception: pass PROCFS = os.environ.get("PROCFS_PATH", "/host/proc") if os.path.isdir(PROCFS): psutil.PROCFS_PATH = PROCFS app = Flask(__name__, static_folder="public", static_url_path="") PORT = int(os.environ.get("PORT", "7031")) # ---------- background sampler ---------- cache = {"procs": [], "cpu": 0.0, "load": [0, 0, 0], "ts": 0} net_prev = {} net_rate = {} def fmt_status(s): m = { psutil.STATUS_RUNNING: ("活动", 1), psutil.STATUS_SLEEPING: ("睡眠", 0), psutil.STATUS_DISK_SLEEP: ("睡眠", 0), psutil.STATUS_STOPPED: ("停止", 0), psutil.STATUS_ZOMBIE: ("僵尸", 0), } return m.get(s, ("睡眠", 0)) _uid_cache = {"map": {}, "ts": 0} def uid_name(uid_str): # 容器内 /etc/passwd 没有宿主用户,uid 会显示成数字,用宿主 passwd 还原 if not uid_str or not uid_str.isdigit(): return uid_str now = time.time() if now - _uid_cache["ts"] > 300: m = {} try: with open("/host/etc/passwd") as f: for line in f: p = line.split(":") if len(p) > 3: try: m[p[2]] = p[0] except Exception: pass except Exception: pass _uid_cache["map"] = m _uid_cache["ts"] = now return _uid_cache["map"].get(uid_str, uid_str) def sample_procs(): # init cpu percent calculation for p in psutil.process_iter(): try: p.cpu_percent(None) except Exception: pass time.sleep(1.0) procs = [] try: cpu_total = psutil.cpu_percent(interval=None) except Exception: cpu_total = 0 try: load = list(os.getloadavg()) except Exception: load = [0, 0, 0] for p in psutil.process_iter(["pid", "name", "username", "status", "num_threads", "cmdline"]): try: with p.oneshot(): cpu = p.cpu_percent(None) mem = p.memory_percent() try: rss = p.memory_info().rss except Exception: rss = 0 uss = 0 swap = 0 try: mfi = p.memory_full_info() uss = getattr(mfi, "uss", 0) or 0 swap = getattr(mfi, "swap", 0) or 0 except Exception: pass try: io = p.io_counters() rd, wr = io.read_bytes, io.write_bytes except Exception: rd, wr = 0, 0 try: conns = len(p.net_connections()) except Exception: conns = 0 info = p.info name = info.get("name") or "" cmd = " ".join(info.get("cmdline") or [])[:120] desc = cmd if cmd else name st, act = fmt_status(info.get("status")) if (not act) and (cpu or 0) > 1.0: st, act = "活动", 1 procs.append({ "name": name[:40], "pid": info["pid"], "cpu": round(cpu or 0, 2), "mem": round(mem or 0, 2), "rss": rss, "uss": uss, "swap": swap, "threads": info.get("num_threads") or 0, "user": uid_name(str(info.get("username") or ""))[:20], "status": st, "active": act, "desc": desc[:80], "io_r": rd, "io_w": wr, "conns": conns, }) except (psutil.NoSuchProcess, psutil.AccessDenied): continue except Exception: continue procs.sort(key=lambda x: x["cpu"], reverse=True) cache["procs"] = procs cache["cpu"] = round(cpu_total, 1) cache["load"] = [round(x, 2) for x in load] cache["ts"] = time.time() def sampler_loop(): while True: try: sample_procs() except Exception: pass time.sleep(2) threading.Thread(target=sampler_loop, daemon=True).start() # warm up immediately in background; serve stale until ready @app.route("/") def index(): return send_from_directory("public", "index.html") @app.route("/api/cpu") def api_cpu(): active = sum(1 for p in cache["procs"] if p["active"]) return jsonify({"cpu": cache["cpu"], "load": cache["load"], "active": active, "total": len(cache["procs"]), "info": get_cpu_info(), "percpu": get_percpu(), "items": cache["procs"][:100]}) _cpu_static = {} def _parse_host_cpuinfo(): info = {} try: with open("/host/proc/cpuinfo") as f: txt = f.read() except Exception: try: with open("/proc/cpuinfo") as f: txt = f.read() except Exception: return info # 取第一个 processor 段的 model name / cache size / cpu MHz for line in txt.splitlines(): if ":" not in line: continue k, v = line.split(":", 1) k, v = k.strip(), v.strip() if k == "model name" and "model" not in info: info["model"] = v elif k == "cache size" and "cache" not in info: info["cache"] = v return info def _parse_lscpu(): out = {} try: txt = subprocess.run(["lscpu"], capture_output=True, text=True, timeout=5).stdout except Exception: return out for line in txt.splitlines(): if ":" not in line and ":" not in line: continue line = line.replace(":", ":") k, v = line.split(":", 1) k, v = k.strip(), v.strip() out[k] = v return out def get_cpu_info(): global _cpu_static if not _cpu_static: ci = _parse_host_cpuinfo() lc = _parse_lscpu() def pick(*keys): for k in keys: if k in lc and lc[k]: return lc[k] return "" # L1d/L1i/L2/L3 键名中英文兼容 l1d = pick("L1d 缓存", "L1d cache") l1i = pick("L1i 缓存", "L1i cache") l2 = pick("L2 缓存", "L2 cache") l3 = pick("L3 缓存", "L3 cache") _cpu_static = { "model": ci.get("model") or pick("型号名称", "Model name"), "arch": platform.machine(), "sockets": pick("座", "Socket(s)"), "cores_per_socket": pick("每个座的核数", "Core(s) per socket"), "threads_per_core": pick("每个核的线程数", "Thread(s) per core"), "l1d": l1d, "l1i": l1i, "l2": l2, "l3": l3, "cache": ci.get("cache", ""), } try: f = psutil.cpu_freq() cur = round(f.current, 0) if f else 0 fmin = round(f.min, 0) if f and f.min else 0 fmax = round(f.max, 0) if f and f.max else 0 except Exception: cur = fmin = fmax = 0 d = dict(_cpu_static) d.update({ "logical": psutil.cpu_count() or 0, "physical": psutil.cpu_count(logical=False) or 0, "freq_cur": cur, "freq_min": fmin, "freq_max": fmax, }) return d def get_percpu(): try: vals = psutil.cpu_percent(interval=None, percpu=True) return [round(v, 1) for v in (vals or [])] except Exception: return [] @app.route("/api/mem") def api_mem(): vm = psutil.virtual_memory() sm = psutil.swap_memory() items = sorted(cache["procs"], key=lambda x: x["mem"], reverse=True)[:100] mi = _read_meminfo_host() dm = get_mem_hw() return jsonify({ "total": vm.total, "used": vm.used, "available": vm.available, "percent": vm.percent, "cached": getattr(vm, "cached", 0), "swap_total": sm.total, "swap_used": sm.used, "swap_percent": sm.percent, "detail": mi, "hw": dm, "items": items}) _mem_hw_static = {} def _read_meminfo_host(): mi = {} for p in ("/host/proc/meminfo", "/proc/meminfo"): try: with open(p) as f: for line in f: if ":" not in line: continue k, v = line.split(":", 1) parts = v.strip().split() if not parts: continue try: num = int(parts[0]) except ValueError: continue # 统一转 bytes if len(parts) > 1 and parts[1] == "kB": num *= 1024 mi[k.strip()] = num if mi: break except Exception: continue return mi def _walk_dmi(path): """yield (type, raw_bytes, strings)""" try: with open(path, "rb") as f: data = f.read() except Exception: return off, n = 0, 0 L = len(data) while off + 4 <= L and n < 200: typ, ln = data[off], data[off + 1] if ln < 4 or off + ln > L: break if typ == 127: # end of table break s = off + ln strs = [] ok = True while s < L: try: e = data.index(b"\x00", s) except ValueError: ok = False break if e == s: s += 1 break strs.append(data[s:e].decode("utf-8", "replace").strip()) s = e + 1 if not ok: break yield typ, data[off:off + ln], strs off = s n += 1 _MEM_TYPE = {0x02: "Unknown", 0x03: "DRAM", 0x12: "DDR", 0x13: "DDR2", 0x14: "DDR2 FB-DIMM", 0x18: "DDR3", 0x19: "FBD2", 0x1A: "DDR4", 0x1B: "LPDDR", 0x1C: "LPDDR2", 0x1D: "LPDDR3", 0x1E: "LPDDR4", 0x1F: "Logical NV", 0x20: "HBM", 0x21: "HBM2", 0x22: "DDR5", 0x23: "LPDDR5", 0x24: "HBM3"} def _parse_dmi_mem(): dimms, max_cap_kb = [], 0 for dmi_path in ("/host/sys/firmware/dmi/tables/DMI", "/sys/firmware/dmi/tables/DMI"): found = False for typ, raw, strs in _walk_dmi(dmi_path): found = True def S(i): return strs[i - 1] if 1 <= i <= len(strs) else "" if typ == 16 and len(raw) >= 11: import struct (cap,) = struct.unpack_from("= 28: import struct (size,) = struct.unpack_from("= 1024 else 1 size_b = (size & 0x7FFF) * 1024 else: size_mb = size & 0x7FFF size_b = size_mb * 1024 * 1024 if size_mb == 0x7FFF: continue (speed,) = struct.unpack_from("= 30: (cspeed,) = struct.unpack_from(" 18 else 0 dimms.append({ "locator": S(raw[16]) if len(raw) > 16 else "", "bank": S(raw[17]) if len(raw) > 17 else "", "size_b": size_b, "size_mb": size_mb, "type": _MEM_TYPE.get(mtype, "DDR4" if mtype == 0 else hex(mtype)), "speed": speed or 0, "cspeed": cspeed or 0, "maker": S(raw[22]) if len(raw) > 22 else "", "serial": S(raw[23]) if len(raw) > 23 else "", "part": S(raw[25]) if len(raw) > 25 else "", }) if found: break return dimms, max_cap_kb def _get_vram(): total = 0 for base in ("/host/sys/class/drm", "/sys/class/drm"): try: for p in glob.glob(base + "/card*/device/mem_info_vram_total"): try: with open(p) as f: total += int(f.read().strip()) except Exception: pass except Exception: pass if total: break return total def get_mem_hw(): global _mem_hw_static if not _mem_hw_static: dimms, max_cap_kb = [], 0 # 优先读构建时从宿主 dmidecode 导出的静态槽位表 for jp in ("hw_mem.json", os.path.join(os.path.dirname(os.path.abspath(__file__)), "hw_mem.json")): try: with open(jp) as f: hj = json.load(f) dimms = hj.get("dimms", []) or [] max_cap_kb = int((hj.get("max_cap_b", 0) or 0) // 1024) if dimms: break except Exception: continue if not dimms: dimms, max_cap_kb = _parse_dmi_mem() installed = sum(d["size_b"] for d in dimms) _mem_hw_static = {"dimms": dimms, "installed": installed, "max_cap_kb": max_cap_kb} d = {"dimms": _mem_hw_static["dimms"], "installed": _mem_hw_static["installed"], "max_cap_kb": _mem_hw_static["max_cap_kb"]} try: mem_total = psutil.virtual_memory().total except Exception: mem_total = 0 d["vram"] = _get_vram() d["system_total"] = mem_total d["reserved"] = (d["installed"] - mem_total) if d["installed"] and mem_total else 0 return d HOST_MNTS = [("/", "/host"), ("/vol1", "/host/vol1"), ("/vol2", "/host/vol2"), ("/vol3", "/host/vol3")] def _df_host(): """容器内 df 直读宿主路径(经 bind 进来的宿主目录),返回 {mnt: {...}}""" try: out = subprocess.run( ["df", "-B1", "--output=source,target,fstype,size,used,avail", "/host", "/host/vol1", "/host/vol2", "/host/vol3"], capture_output=True, text=True, timeout=5).stdout.splitlines() res = {} for line in out[1:]: p = line.split() if len(p) < 6: continue dev, tgt, fst, size, used, avail = p[0], p[1], p[2], int(p[3]), int(p[4]), int(p[5]) mnt = tgt.replace("/host", "", 1) or "/" res[mnt] = {"device": dev, "mount": mnt, "fstype": fst, "total": size, "used": used, "free": avail, "percent": round(used / size * 100, 1) if size else 0} return res except Exception: return {} @app.route("/api/mem/drop", methods=["POST"]) def api_mem_drop(): import subprocess try: subprocess.run(["sync"], check=False, timeout=10) p = os.environ.get("PROCFS_PATH", "/proc") with open(os.path.join(p, "sys", "vm", "drop_caches"), "w") as f: f.write("3") vm = psutil.virtual_memory() return jsonify({"ok": True, "available": vm.available, "percent": vm.percent}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 @app.route("/api/disk") def api_disk(): parts = [] dfm = _df_host() for mp, hp in HOST_MNTS: if mp in dfm: parts.append(dfm[mp]) continue try: st = os.statvfs(hp) total = st.f_blocks * st.f_frsize free = st.f_bavail * st.f_frsize used = total - free parts.append({"device": "?", "mount": mp, "fstype": "?", "total": total, "used": used, "free": free, "percent": round(used / total * 100, 1) if total else 0}) except Exception: continue try: dio = psutil.disk_io_counters() dio_d = {"read": dio.read_bytes, "write": dio.write_bytes} if dio else {} except Exception: dio_d = {} # top io processes items = sorted(cache["procs"], key=lambda x: x["io_r"] + x["io_w"], reverse=True)[:60] return jsonify({"parts": parts, "io": dio_d, "hw": get_disk_hw(), "items": items}) _disk_hw_static = {} def _read(p, default=""): try: with open(p) as f: return f.read().strip() except Exception: return default def _sys_block_base(): for base in ("/host/sys/block", "/sys/block"): try: if os.path.isdir(base): return base except Exception: continue return "/host/sys/block" def _live_sysfs(name): """实时 sysfs: 容量/转速/型号(覆盖静态快照中的漂移项)""" base = _sys_block_base() d = os.path.join(base, name) out = {} try: sectors = _read(os.path.join(d, "size")) if sectors.isdigit(): out["cap_b"] = int(sectors) * 512 except Exception: pass rot = _read(os.path.join(d, "queue/rotational")) if rot in ("0", "1"): out["rotational"] = (rot == "1") model = _read(os.path.join(d, "device/model")) if model: out["sys_model"] = model vendor = _read(os.path.join(d, "device/vendor")) if vendor: out["sys_vendor"] = vendor return out def _live_mdstat(): """实时 mdstat: 每组阵列状态/同步进度,键为 md 名""" st = {} for p in ("/host/proc/mdstat", "/proc/mdstat"): try: with open(p) as f: txt = f.read() break except Exception: txt = "" else: return st import re cur = None for line in txt.splitlines(): m = re.match(r"^(md\d+)\s*:\s*(\w+)\s+(\w+)(.*)$", line) if m: cur = m.group(1) st[cur] = {"status": m.group(2), "level": m.group(3), "devs": m.group(4).strip(), "sync": ""} continue if cur and ("recovery" in line or "resync" in line or "blocks" in line): s = line.strip() if "recovery" in line or "resync" in line: st[cur]["sync"] = s elif "blocks" in line and not st[cur].get("blocks"): st[cur]["blocks"] = s return st def _live_perdisk_io(): try: d = psutil.disk_io_counters(perdisk=True) or {} except Exception: return {} out = {} for k, v in d.items(): try: out[k] = {"read": v.read_bytes, "write": v.write_bytes} except Exception: continue return out def get_disk_hw(): global _disk_hw_static if not _disk_hw_static: hj = {} for jp in ("hw_disk.json", os.path.join(os.path.dirname(os.path.abspath(__file__)), "hw_disk.json")): try: with open(jp) as f: hj = json.load(f) if hj.get("disks"): break except Exception: continue _disk_hw_static = hj or {"disks": [], "arrays": []} disks = [dict(x) for x in _disk_hw_static.get("disks", [])] arrays = [dict(x) for x in _disk_hw_static.get("arrays", [])] # 合并实时数据 pio = _live_perdisk_io() for dk in disks: nm = dk.get("name", "") dk.update(_live_sysfs(nm)) if nm in pio: dk["io_read"] = pio[nm]["read"] dk["io_write"] = pio[nm]["write"] # 转速文字 if dk.get("rotational") is True and not dk.get("rpm"): dk["rpm"] = 5400 md_live = _live_mdstat() for a in arrays: md = a.get("md", "") lv = md_live.get(md, {}) if lv: a["live_status"] = lv.get("status", "") a["live_devs"] = lv.get("devs", "") a["live_sync"] = lv.get("sync", "") if md in pio: a["io_read"] = pio[md]["read"] a["io_write"] = pio[md]["write"] return {"disks": disks, "arrays": arrays, "extra": _disk_hw_static.get("extra", {}), "snapshot": _disk_hw_static.get("snapshot", ""), "note": _disk_hw_static.get("note", "")} @app.route("/api/net") def api_net(): global net_prev, net_rate now = time.time() try: counters = psutil.net_io_counters(pernic=True) except Exception: counters = {} rates = [] for nic, c in counters.items(): if nic.startswith(("lo", "docker", "br-", "veth")): continue prev = net_prev.get(nic) if prev: dt = max(now - prev["t"], 0.5) up = (c.bytes_sent - prev["sent"]) / dt down = (c.bytes_recv - prev["recv"]) / dt else: up = down = 0 net_prev[nic] = {"sent": c.bytes_sent, "recv": c.bytes_recv, "t": now} rates.append({"nic": nic, "up": max(up, 0), "down": max(down, 0), "sent": c.bytes_sent, "recv": c.bytes_recv}) rates.sort(key=lambda x: x["up"] + x["down"], reverse=True) try: conns = len(psutil.net_connections()) except Exception: conns = 0 total_recv = 0 total_sent = 0 for nic, c in counters.items(): if nic.startswith(("lo", "docker", "br-", "veth")): continue total_recv += getattr(c, "bytes_recv", 0) or 0 total_sent += getattr(c, "bytes_sent", 0) or 0 items = sorted(cache["procs"], key=lambda x: x["conns"], reverse=True)[:60] tcp_list = [] listen_list = [] try: name_by_pid = {p["pid"]: p["name"] for p in cache.get("procs", [])} for c in psutil.net_connections(kind="tcp"): laddr = f"{c.laddr.ip}:{c.laddr.port}" if c.laddr else "" raddr = f"{c.raddr.ip}:{c.raddr.port}" if c.raddr else "" family = "ipv6" if ":" in (c.laddr.ip if c.laddr else "") and "." not in (c.laddr.ip if c.laddr else "") else "ipv4" # better family detection import socket family = "ipv6" if (c.family == socket.AF_INET6) else "ipv4" if c.status == "LISTEN": listen_list.append({"name": name_by_pid.get(c.pid, ""), "laddr": c.laddr.ip if c.laddr else "", "port": c.laddr.port if c.laddr else 0, "family": family, "pid": c.pid, "proto": "tcp"}) else: tcp_list.append({"name": name_by_pid.get(c.pid, ""), "laddr": laddr, "raddr": raddr, "family": family, "pid": c.pid, "status": c.status}) except Exception: pass tcp_list = tcp_list[:80] listen_list = listen_list[:80] return jsonify({"nics": rates, "conns": conns, "items": items, "hw": get_net_hw(), "total_recv": total_recv, "total_sent": total_sent, "tcp": tcp_list, "listen": listen_list}) _net_hw_static = {} def _net_sysfs(name): """实时 sysfs: 状态/MTU/速率/MAC,键名与 psutil 速率互补""" out = {} for base in ("/host/sys/class/net", "/sys/class/net"): d = os.path.join(base, name) try: if not os.path.isdir(d): continue for k in ("address", "operstate", "mtu", "speed", "duplex", "carrier"): v = _read(os.path.join(d, k)) if v: out[k] = v # 驱动名 try: drv = os.path.basename(os.readlink(os.path.join(d, "device/driver"))) if drv: out["live_driver"] = drv except Exception: pass if out: break except Exception: continue return out def _net_addrs(): try: ad = psutil.net_if_addrs() or {} except Exception: return {} out = {} for nic, lst in ad.items(): v4, v6, mac = [], [], "" for a in lst: try: fam = a.family.name if hasattr(a.family, "name") else str(a.family) except Exception: fam = "" addr = a.address or "" if "PACKET" in fam: if addr: mac = mac or addr elif "AF_INET6" in fam: if addr and not addr.startswith("fe80"): v6.append(addr.split("%")[0]) elif addr: v6.append(addr.split("%")[0] + "(链路)") elif "AF_INET" in fam: if addr and len(addr.split(".")) == 4: v4.append(addr) out[nic] = {"v4": v4, "v6": v6, "mac": mac} return out def get_net_hw(): global _net_hw_static if not _net_hw_static: hj = {} for jp in ("hw_net.json", os.path.join(os.path.dirname(os.path.abspath(__file__)), "hw_net.json")): try: with open(jp) as f: hj = json.load(f) if hj.get("nics"): break except Exception: continue _net_hw_static = hj or {"nics": []} statics = {x.get("name"): dict(x) for x in _net_hw_static.get("nics", [])} try: st = psutil.net_if_stats() or {} except Exception: st = {} addrs = _net_addrs() # 展示的网卡: 静态档案 + 实际有流量的非虚拟口 names = list(statics.keys()) try: counters = psutil.net_io_counters(pernic=True) or {} except Exception: counters = {} for nic in counters: if nic.startswith(("lo", "docker", "br-", "veth")): continue if nic not in statics: names.append(nic) out = [] for nic in names: base = dict(statics.get(nic, {"name": nic})) s = st.get(nic) if s is not None: try: base["isup"] = bool(s.isup) base["live_speed"] = s.speed or 0 base["live_mtu"] = s.mtu or 0 except Exception: pass base.update(_net_sysfs(nic)) a = addrs.get(nic, {}) if a: base["ipv4"] = a.get("v4", []) base["ipv6"] = a.get("v6", []) if a.get("mac") and not base.get("mac"): base["mac"] = a["mac"] c = counters.get(nic) if c is not None: try: base["io_sent"] = c.bytes_sent base["io_recv"] = c.bytes_recv except Exception: pass out.append(base) # 默认网关/DNS 实时读 gw, dns = _net_hw_static.get("gateway", ""), _net_hw_static.get("dns", "") base_if = "" try: with open("/host/proc/net/route" if os.path.exists("/host/proc/net/route") else "/proc/net/route") as f: for line in f.read().splitlines()[1:]: f_ = line.split() if len(f_) > 2 and f_[1] == "00000000" and f_[7] == "00000000": import socket, struct gw = socket.inet_ntoa(struct.pack("