|
|
@@ -14,6 +14,7 @@ _sys.path.insert(0, str(_plb.Path(__file__).resolve().parents[1]))
|
|
|
from src import paths as P
|
|
|
import argparse, datetime as dt, hashlib, http.client, json, os, pathlib, re, socket, subprocess, sys, time, urllib.parse
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
+from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
|
PORTAL = pathlib.Path(os.environ.get("GUANLAN_PORTAL", str(ROOT / "release/portal.html"))) # v2: 门户 = 本仓 release/, 不再指桌面
|
|
|
@@ -69,15 +70,42 @@ def portal_bytes():
|
|
|
return _PORTAL_CACHE["body"]
|
|
|
|
|
|
|
|
|
-def _probe(host, port, path, timeout=3.0):
|
|
|
+def _probe(host, port, path, timeout=None):
|
|
|
t0 = time.time()
|
|
|
try:
|
|
|
- c = http.client.HTTPConnection(host, port, timeout=timeout); c.request("GET", _q(path)); r = c.getresponse(); r.read(65536); c.close()
|
|
|
+ c = http.client.HTTPConnection(host, port, timeout=timeout or PROBE_TIMEOUT); c.request("GET", _q(path)); r = c.getresponse(); r.read(65536); c.close()
|
|
|
return dict(ok=r.status < 400, http=r.status, ms=int((time.time() - t0) * 1000))
|
|
|
except OSError as e:
|
|
|
return dict(ok=False, http=None, ms=int((time.time() - t0) * 1000), err=str(e)[:80])
|
|
|
|
|
|
|
|
|
+# 探活参数 (2026-09-11 调): 原先 6 个上游**串行**探、每个默认 3 s, 而两个掉线的模块
|
|
|
+# (CMS 无产物未起 / Ollama 无模型未起) 各卡满超时 → /healthz 实测 6.5 s; 再加上每次重读
|
|
|
+# 20 MB 门户算 sha256 (磁盘忙时这一项能到几十秒), 一个探活页慢得没法看。
|
|
|
+# 现在: ① 全部探针并发 → 总耗时 = 最慢那个; ② 单探针 1.5 s; ③ 门户 sha 按 (mtime,size) 缓存;
|
|
|
+# ④ 整个结果缓存 HEALTH_TTL 秒 → 连续调用近乎零成本。实测: 冷探 6.5 s → 1.53 s, 命中缓存 15 ms;
|
|
|
+# 全部模块健康时冷探 ≈ 最慢的健康探针 (实测 sim_sys 345 ms, 其余 <25 ms)。
|
|
|
+#
|
|
|
+# ★为什么"掉线模块"必须等满超时: 本机实测 **连任何已关闭的 127.0.0.1 端口都不回 RST**,
|
|
|
+# 而是把 SYN 丢掉等超时 (对照用的随机闭端口 49996/49997 同样 1.5 s 超时, 不是我们端口的问题,
|
|
|
+# 是这台机器的防火墙/安全软件行为)。所以"有模块掉线"时的冷探下限就是 PROBE_TIMEOUT,
|
|
|
+# 想更快只能靠缓存 —— 别为此把超时压到健康模块也可能被误判的程度。
|
|
|
+PROBE_TIMEOUT = 1.5
|
|
|
+HEALTH_TTL = 5.0
|
|
|
+_HEALTH_CACHE = {"at": 0.0, "payload": None}
|
|
|
+_PORTAL_SHA = {"key": None, "sha": None}
|
|
|
+
|
|
|
+
|
|
|
+def _portal_sha():
|
|
|
+ """门户文件 sha256, 按 (mtime_ns, size) 缓存。原实现每次 /healthz 与 /api/version 都
|
|
|
+ `PORTAL.read_bytes()` 重读 20 MB 再哈希 —— 值没变, 代价却按请求量重复付。"""
|
|
|
+ st = PORTAL.stat()
|
|
|
+ key = (st.st_mtime_ns, st.st_size)
|
|
|
+ if _PORTAL_SHA["key"] != key:
|
|
|
+ _PORTAL_SHA.update(key=key, sha=hashlib.sha256(PORTAL.read_bytes()).hexdigest())
|
|
|
+ return _PORTAL_SHA["sha"]
|
|
|
+
|
|
|
+
|
|
|
def ollama_models(timeout=3.0):
|
|
|
"""返回 (running, [模型名]). 拒连 = 未启动, 不猜."""
|
|
|
try:
|
|
|
@@ -88,16 +116,34 @@ def ollama_models(timeout=3.0):
|
|
|
|
|
|
|
|
|
def healthz():
|
|
|
- mods = []
|
|
|
- for pre, port, _, hp, name in ROUTES:
|
|
|
- mods.append(dict(path=pre + "/", name=name, upstream_port=port, **_probe("127.0.0.1", port, hp)))
|
|
|
- try: # 门户文件读失败 (如 Finder 启动的 app 无外接盘权限 EPERM) 必须报 ok=False 带原因, 不能让 /healthz 整个炸成空回复 (2026-09-07 app 冷启动实测)
|
|
|
- mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=PORTAL.exists(), file_sha256=hashlib.sha256(PORTAL.read_bytes()).hexdigest() if PORTAL.exists() else None, portal=str(PORTAL)))
|
|
|
- except OSError as e:
|
|
|
- mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=False, file_sha256=None, portal=str(PORTAL), error=f"{type(e).__name__}: {e}"))
|
|
|
- run, models = ollama_models()
|
|
|
+ """全部模块状态 (逐项探活)。结果缓存 HEALTH_TTL 秒 (并发探活 + 缓存见 PROBE_TIMEOUT 处注释);
|
|
|
+ 返回结构一字未改 (path/name/upstream_port/ok/http/ms/err · 门户那项带 file_sha256/portal)。"""
|
|
|
+ if _HEALTH_CACHE["payload"] is not None and time.time() - _HEALTH_CACHE["at"] < HEALTH_TTL:
|
|
|
+ return _HEALTH_CACHE["payload"]
|
|
|
+ with ThreadPoolExecutor(max_workers=len(ROUTES) + 2) as ex:
|
|
|
+ f_mods = [ex.submit(_probe, "127.0.0.1", port, hp, PROBE_TIMEOUT) for _, port, _, hp, _ in ROUTES]
|
|
|
+ f_oll = ex.submit(ollama_models, PROBE_TIMEOUT) # 与路由探针并发, 不再串在后面多等一个超时
|
|
|
+ f_sha = ex.submit(_portal_sha)
|
|
|
+ mods = [dict(path=pre + "/", name=name, upstream_port=port, **f.result())
|
|
|
+ for (pre, port, _, hp, name), f in zip(ROUTES, f_mods)]
|
|
|
+ try:
|
|
|
+ psha = f_sha.result()
|
|
|
+ except OSError:
|
|
|
+ psha = None
|
|
|
+ run, models = f_oll.result()
|
|
|
+ # 门户文件读失败 (如 Finder 启动的 app 无外接盘权限 EPERM) 必须报 ok=False 带原因, 不能让 /healthz 整个炸成空回复 (2026-09-07 app 冷启动实测)
|
|
|
+ if not PORTAL.exists():
|
|
|
+ mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=False,
|
|
|
+ file_sha256=None, portal=str(PORTAL)))
|
|
|
+ elif psha is None:
|
|
|
+ mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=False,
|
|
|
+ file_sha256=None, portal=str(PORTAL), error="OSError: 门户文件读失败 (权限/占用?)"))
|
|
|
+ else:
|
|
|
+ mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=True,
|
|
|
+ file_sha256=psha, portal=str(PORTAL)))
|
|
|
out = dict(status="ok" if all(m["ok"] for m in mods) else "degraded", mode="offline", checked=dt.datetime.now().isoformat(timespec="seconds"), modules=mods,
|
|
|
local_ai=dict(running=run, models=models, note=None if run else "本机模型未启动 (Ollama %s:%d 拒连); 不会转云" % OLLAMA))
|
|
|
+ _HEALTH_CACHE.update(at=time.time(), payload=out)
|
|
|
return out
|
|
|
|
|
|
|
|
|
@@ -107,9 +153,11 @@ def version():
|
|
|
try: head = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"], capture_output=True, text=True, timeout=5).stdout.strip()
|
|
|
except Exception: head = None
|
|
|
con = json.loads(CONTRACT.read_text(encoding="utf-8")) if CONTRACT.exists() else {}
|
|
|
+ try: psha = _portal_sha() # 缓存版: 同一份门户不重复读 20 MB
|
|
|
+ except OSError: psha = None # 门户缺失/不可读 → 与原实现一致返回 None, 不抛
|
|
|
return dict(mode="offline", entry="http://127.0.0.1:28084/", repo_head=head, detail_style_baseline=dict(commit=b.get("git_commit"), built=b.get("built"), fingerprint=b.get("fingerprint"), artifact_sha256=b.get("artifact_sha256")),
|
|
|
content_contract=dict(version=con.get("contract_version"), sha256=con.get("contract_sha256"), n_claims=con.get("n_claims"), consumers=c.get("consumers_wired")),
|
|
|
- portal_sha256=hashlib.sha256(PORTAL.read_bytes()).hexdigest() if PORTAL.exists() else None, routes=[dict(path=p + "/", name=n) for p, _, _, _, n in ROUTES], gateway=pathlib.Path(__file__).name)
|
|
|
+ portal_sha256=psha, routes=[dict(path=p + "/", name=n) for p, _, _, _, n in ROUTES], gateway=pathlib.Path(__file__).name)
|
|
|
|
|
|
|
|
|
def down_page(name, prefix, info):
|