From ce0aa22672cbb645feef46eee65add4ede5e4bd9 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 23 Aug 2026 14:07:37 +0800 Subject: [PATCH] Fix node id collision, host normalization and blank error text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects made the panel look broken to the operator: - POST /api/proxies with a Chinese name slugged the id down to its ASCII digits ("香港01" -> "01"), collided with an existing node and returned HTTP 500 "proxy id already exists". Non-ASCII names now get sequential node-N ids; ASCII names keep a readable slug. Both paths de-duplicate. - A host pasted as a URL ("http://192.168.3.71") went straight to DNS, so every check failed with "[Errno -2] Name does not resolve". normalize_host accepts host, host:port, scheme://user:pass@host:port and [IPv6]:port, and existing configs are healed at startup. - str(asyncio.TimeoutError()) is empty, so a black-holed proxy showed a blank error in the UI and looked like an internal bug. describe_exc always produces a reason. Also: - Expected validation failures return 400 with an error message instead of 500 + traceback; the frontend surfaces every API error as a toast. - PUT/DELETE on an unknown proxy id return 404 instead of silently creating or reporting ok=false with 200. - POST /api/check/ forces a check on disabled nodes instead of returning ok=true without doing anything. - Port range is validated (1..65535). - Store.close() lets tests release SQLite handles. - 33 unittest cases cover id generation, host normalization, error text, startup migration and manual checks. --- .dockerignore | 2 + .gitignore | 1 + Dockerfile.patch | 14 +++ README.md | 42 ++++++- app.py | 210 ++++++++++++++++++++++++++++---- static/index.html | 4 +- tests/test_log_retention.py | 10 +- tests/test_proxy_validation.py | 211 +++++++++++++++++++++++++++++++++ 8 files changed, 468 insertions(+), 26 deletions(-) create mode 100644 Dockerfile.patch create mode 100644 tests/test_proxy_validation.py diff --git a/.dockerignore b/.dockerignore index 5916548..3ff9aef 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,5 +4,7 @@ __pycache__/ .git/ data/ config.json +config.json.bak.* *.db *.log +tests/ diff --git a/.gitignore b/.gitignore index 9eefaad..a61d333 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ config.json +config.json.bak.* .env *.env .env.* diff --git a/Dockerfile.patch b/Dockerfile.patch new file mode 100644 index 0000000..342fb8b --- /dev/null +++ b/Dockerfile.patch @@ -0,0 +1,14 @@ +# Incremental rebuild on top of the previously published image. +# Used when the upstream python:3.11-alpine base cannot be pulled +# (mirror rate limits). Only ships the updated app source. +# +# docker build -f Dockerfile.patch -t 192.168.2.66:80/hermes/socks5-monitor:latest . +# +# This is an escape hatch, not the canonical build. Prefer the plain +# `Dockerfile` whenever the base image is reachable, otherwise layers keep +# stacking on top of each other with every patch. +FROM 192.168.2.66:80/hermes/socks5-monitor:latest + +WORKDIR /app +COPY app.py README.md ./ +COPY static ./static diff --git a/README.md b/README.md index 02430d8..a6b5e29 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,6 @@ PUT /api/settings DELETE /api/logs POST /api/check/ POST /api/notify/test -GET /api/proxies POST /api/proxies PUT /api/proxies/ DELETE /api/proxies/ @@ -112,12 +111,53 @@ DELETE /api/channels/ POST /api/channels//test ``` +节点列表通过 `GET /api/status` 返回(含运行状态和最近记录),没有单独的 +`GET /api/proxies`。参数校验失败返回 `400` 并带 `error` 说明,仅未预期的内部 +错误返回 `500`。 + +## 节点字段说明 + +### Host 会被自动归一化 + +`host` 支持直接粘贴以下形式,服务端会拆成主机名 + 端口: + +```text +192.168.1.10 +192.168.1.10:7890 +http://192.168.1.10:7890 +socks5://user:pass@192.168.1.10:1080 +[2001:db8::1]:1080 +``` + +未显式指定 `port`(或仍为默认 `1080`)时采用 URL 中的端口。启动时也会自动修正 +历史配置里残留的带协议前缀的 `host`,避免出现 +`[Errno -2] Name does not resolve`。 + +### 节点 id 生成规则 + +未显式提供 `id` 时由 `name` 推导:纯 ASCII 名字保留可读 slug(`HK Node 01` → +`HK-Node-01`),含中文/emoji 的名字分配递增 `node-N`。两种路径都会自动去重, +不会因重名报错。`PUT` 编辑不会改变已有 id。 + +## 测试 + +```bash +python3 -m unittest discover -s tests +``` + ## Docker 镜像 ```text 192.168.2.66:80/hermes/socks5-monitor:latest ``` +上游基础镜像(`python:3.11-alpine`)拉取受限时,可用 `Dockerfile.patch` 在上一版 +镜像之上只叠加应用源码做增量构建: + +```bash +docker build -f Dockerfile.patch -t 192.168.2.66:80/hermes/socks5-monitor:latest . +``` + ## 数据目录 ```text diff --git a/app.py b/app.py index fd44f23..99305e1 100644 --- a/app.py +++ b/app.py @@ -41,10 +41,26 @@ def load_json(path: Path) -> dict[str, Any]: def save_json(path: Path, data: dict[str, Any]) -> None: + """Persist JSON config. + + Prefer atomic replace, but fall back to in-place write when the target is a + Docker/macOS bind-mounted file. Those mounts commonly reject ``os.replace`` + with ``OSError: [Errno 16] Resource busy``, which previously aborted + ``upsert_proxy`` before ``restart_proxy_task`` and left new nodes without a + monitor loop. + """ path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - tmp.replace(path) + text = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + tmp = path.with_name(path.name + ".tmp") + try: + tmp.write_text(text, encoding="utf-8") + tmp.replace(path) + except OSError: + # Bind-mounted single files cannot always be renamed into place. + path.write_text(text, encoding="utf-8") + with contextlib.suppress(Exception): + if tmp.exists(): + tmp.unlink() def deep_copy(obj: Any) -> Any: @@ -145,6 +161,11 @@ class Store: self.lock = asyncio.Lock() self.init() + def close(self) -> None: + """Release the SQLite handle (used by tests to avoid ResourceWarning).""" + with contextlib.suppress(Exception): + self.conn.close() + def init(self) -> None: self.conn.executescript( """ @@ -461,6 +482,57 @@ async def get_exit_ip_via_socks(proxy: dict[str, Any], timeout: float) -> str | return None +def normalize_host(raw: str) -> tuple[str, int | None]: + """Split a user-entered host field into (hostname, optional port). + + Accepts bare hosts, ``host:port``, and full URLs such as + ``socks5://user@1.2.3.4:1080`` / ``http://192.168.3.71:7890``. Without this, + a pasted URL is handed straight to DNS and every check fails with + ``[Errno -2] Name does not resolve``, which looks like a panel bug. + """ + s = (raw or "").strip().strip("/") + if not s: + return "", None + if "://" in s: + s = s.split("://", 1)[1] + s = s.split("/", 1)[0] + if "@" in s: + s = s.rsplit("@", 1)[1] + port: int | None = None + if s.startswith("["): # bracketed IPv6, optionally with :port + host, _, rest = s[1:].partition("]") + if rest.startswith(":") and rest[1:].isdigit(): + port = int(rest[1:]) + return host, port + if s.count(":") == 1: + host, _, pstr = s.partition(":") + if pstr.isdigit(): + return host, int(pstr) + return host, None + return s, None + + +def describe_exc(e: BaseException) -> str: + """Human-readable failure reason. + + ``str(e)`` is empty for several exceptions that matter here -- most notably + ``asyncio.TimeoutError``/``TimeoutError``, which is what a firewalled or + black-holed proxy endpoint produces. An empty error string in the panel + looked like an internal bug, so always fall back to the class name and + annotate the common cases. + """ + msg = str(e).strip() + if isinstance(e, (asyncio.TimeoutError, TimeoutError)): + return f"超时无响应:TCP 已连接但代理未回应握手({msg or 'timeout'})" + if isinstance(e, asyncio.IncompleteReadError): + return f"握手被中断:{msg or 'connection closed during handshake'}" + if isinstance(e, ConnectionRefusedError): + return "连接被拒绝:端口未监听" + if isinstance(e, socket.gaierror): + return f"域名解析失败:{msg or e}" + return msg or e.__class__.__name__ + + async def check_proxy(proxy: dict[str, Any]) -> tuple[bool, int | None, str | None, str | None]: timeout = float(proxy.get("timeout_seconds", 8)) start = time.perf_counter() @@ -471,13 +543,14 @@ async def check_proxy(proxy: dict[str, Any]) -> tuple[bool, int | None, str | No err = None if exit_ip else (proxy.get("last_exit_ip_error") or "exit ip lookup failed") return True, latency, err, exit_ip except Exception as e: - return False, int((time.perf_counter() - start) * 1000), str(e), None + return False, int((time.perf_counter() - start) * 1000), describe_exc(e), None class App: def __init__(self, config_path: Path): self.config_path = config_path self.config = merge_defaults(load_json(config_path)) + self.migrate_proxy_hosts() if not config_path.exists(): save_json(config_path, self.config) self.store = Store(Path(self.config.get("db_path") or DEFAULT_DB)) self.notifier = Notifier(self.config.get("notifications") or {}, self.store) @@ -493,6 +566,25 @@ class App: def channels(self) -> list[dict[str, Any]]: return self.config.setdefault("notifications", {}).setdefault("channels", []) + def migrate_proxy_hosts(self) -> None: + """Clean up hosts stored before ``normalize_host`` existed. + + Nodes saved with a pasted URL (e.g. ``http://192.168.3.71``) fail every + check with ``[Errno -2] Name does not resolve``. Rewrite them in place at + startup so existing configs heal themselves; ``start_tasks`` persists the + result. + """ + self._migrated = False + for p in self.config.get("proxies") or []: + raw = str(p.get("host") or "") + host, url_port = normalize_host(raw) + if host and host != raw: + p["host"] = host + if url_port and int(p.get("port") or 0) in (0, 1080): + p["port"] = url_port + self._migrated = True + print(f"[migrate] proxy {p.get('id')}: host {raw!r} -> {host!r}:{p.get('port')}", flush=True) + def state_for(self, proxy_id: str) -> RuntimeState: return self.states.setdefault(proxy_id, RuntimeState()) @@ -507,13 +599,53 @@ class App: def get_channel(self, channel_id: str) -> dict[str, Any] | None: return next((c for c in self.channels if c.get("id") == channel_id), None) + def slug_id(self, name: str | None) -> str: + """Build a URL-safe unique node id from a (possibly non-ASCII) name. + + Non-ASCII names (Chinese, emoji flags, ...) collapse to an empty or + misleading slug -- "香港01" used to become "01", colliding with an + existing node and making the POST fail with 500 "proxy id already + exists". Keep the readable slug only for plain ASCII names; anything + containing non-ASCII characters gets a sequential ``node-N`` id. + Both paths are de-duplicated instead of raising. + """ + raw = (name or "").strip() + base = "" + if raw and re.fullmatch(r"[\x20-\x7e]+", raw): + base = re.sub(r"[^a-zA-Z0-9_-]+", "-", raw).strip("-") + if not base: + for n in range(1, 10000): + cand = f"node-{n}" + if not self.get_proxy(cand): + return cand + return f"node-{uuid.uuid4().hex[:8]}" + if not self.get_proxy(base): + return base + for n in range(2, 1000): + cand = f"{base}-{n}" + if not self.get_proxy(cand): + return cand + return f"{base}-{uuid.uuid4().hex[:8]}" + def validate_proxy(self, data: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]: p = dict(existing or {}) p.update(data) - p.setdefault("id", re.sub(r"[^a-zA-Z0-9_-]+", "-", (p.get("name") or "proxy").strip()).strip("-") or f"proxy-{uuid.uuid4().hex[:8]}") + if not p.get("id"): + p["id"] = self.slug_id(p.get("name")) p.setdefault("name", p["id"]) if not p.get("host"): raise ValueError("host required") - p["port"] = int(p.get("port") or 1080) + # Users paste "http://1.2.3.4:7890" or "1.2.3.4:7890" into the Host + # field; the raw string then fails DNS with "Name does not resolve". + host, url_port = normalize_host(str(p["host"])) + if not host: + raise ValueError("host required") + p["host"] = host + # A port embedded in the pasted host wins over an untouched default. + if url_port and int(p.get("port") or 0) in (0, 1080): + p["port"] = url_port + p["port"] = int(p.get("port") or url_port or 1080) + if not 1 <= p["port"] <= 65535: + raise ValueError("port must be 1..65535") p["target_host"] = p.get("target_host") or self.config.get("target_host") or "www.baidu.com" p["target_port"] = int(p.get("target_port") or self.config.get("target_port") or 443) p["timeout_seconds"] = int(p.get("timeout_seconds") or self.config.get("timeout_seconds") or 8) @@ -587,7 +719,16 @@ class App: else: if self.get_proxy(p["id"]): raise ValueError("proxy id already exists") self.proxies.append(p) - await self.save_config(); await self.restart_proxy_task(p["id"]) + # Always (re)start the monitor loop after the in-memory update so a + # transient config-write failure cannot leave a node without polling. + save_error: Exception | None = None + try: + await self.save_config() + except Exception as exc: # noqa: BLE001 - re-raise after starting monitor + save_error = exc + await self.restart_proxy_task(p["id"]) + if save_error is not None: + raise save_error return public_proxy(p) async def delete_proxy(self, proxy_id: str) -> bool: @@ -618,8 +759,13 @@ class App: await self.save_config() return True - async def run_once(self, proxy: dict[str, Any]) -> None: - if not proxy.get("enabled", True): return + async def run_once(self, proxy: dict[str, Any], force: bool = False) -> None: + """Run one check. ``force`` bypasses the enabled flag for manual checks. + + Without ``force`` a manual "check now" on a disabled node returned + ``{"ok": true}`` while silently doing nothing. + """ + if not force and not proxy.get("enabled", True): return proxy_id = proxy["id"] st = self.state_for(proxy_id) ok, latency, err, exit_ip = await check_proxy(proxy) @@ -649,20 +795,34 @@ class App: st.notified_down = True async def monitor_loop(self, proxy_id: str) -> None: - while True: - proxy = self.get_proxy(proxy_id) - if not proxy: return - if proxy.get("enabled", True): - await self.run_once(proxy) - await asyncio.sleep(int(proxy.get("interval_seconds", self.config.get("interval_seconds", 60)))) + try: + while True: + proxy = self.get_proxy(proxy_id) + if not proxy: + return + if proxy.get("enabled", True): + await self.run_once(proxy) + interval = int(proxy.get("interval_seconds", self.config.get("interval_seconds", 60))) + await asyncio.sleep(max(5, interval)) + except asyncio.CancelledError: + raise async def restart_proxy_task(self, proxy_id: str) -> None: old = self.monitor_tasks.pop(proxy_id, None) - if old: old.cancel() - if self.get_proxy(proxy_id): + if old: + old.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await old + proxy = self.get_proxy(proxy_id) + if proxy and proxy.get("enabled", True): self.monitor_tasks[proxy_id] = asyncio.create_task(self.monitor_loop(proxy_id)) + elif proxy and not proxy.get("enabled", True): + # Keep disabled nodes registered without an active loop. + self.monitor_tasks.pop(proxy_id, None) async def start_tasks(self) -> None: + if self._migrated: + await self.save_config() for p in self.proxies: await self.restart_proxy_task(p["id"]) @@ -725,15 +885,21 @@ async def handle_http(reader: asyncio.StreamReader, writer: asyncio.StreamWriter status, ctype, resp = json_response({"ok": True, "proxy": await app.upsert_proxy(data)}, 201) elif method == "PUT" and p.startswith("/api/proxies/"): pid = urllib.parse.unquote(p.rsplit("/", 1)[-1]) - status, ctype, resp = json_response({"ok": True, "proxy": await app.upsert_proxy(data, pid)}) + if not app.get_proxy(pid): + status, ctype, resp = json_response({"ok": False, "error": "proxy not found"}, 404) + else: + status, ctype, resp = json_response({"ok": True, "proxy": await app.upsert_proxy(data, pid)}) elif method == "DELETE" and p.startswith("/api/proxies/"): pid = urllib.parse.unquote(p.rsplit("/", 1)[-1]) - status, ctype, resp = json_response({"ok": await app.delete_proxy(pid)}) + if await app.delete_proxy(pid): + status, ctype, resp = json_response({"ok": True}) + else: + status, ctype, resp = json_response({"ok": False, "error": "proxy not found"}, 404) elif method == "POST" and p.startswith("/api/check/"): pid = urllib.parse.unquote(p.rsplit("/", 1)[-1]); proxy = app.get_proxy(pid) if not proxy: status, ctype, resp = json_response({"ok": False, "error": "proxy not found"}, 404) else: - await app.run_once(proxy) + await app.run_once(proxy, force=True) status, ctype, resp = json_response({"ok": True, "state": dataclasses.asdict(app.state_for(pid))}) elif method == "POST" and p == "/api/channels": status, ctype, resp = json_response({"ok": True, "channel": await app.upsert_channel(data)}, 201) @@ -752,9 +918,11 @@ async def handle_http(reader: asyncio.StreamReader, writer: asyncio.StreamWriter status, ctype, resp = json_response({"ok": True, "results": res}) else: status, ctype, resp = json_response({"ok": False, "error": "not found"}, 404) + except ValueError as e: + status, ctype, resp = json_response({"ok": False, "error": str(e)}, 400) except Exception as e: traceback.print_exc() - status, ctype, resp = json_response({"ok": False, "error": str(e)}, 500) + status, ctype, resp = json_response({"ok": False, "error": describe_exc(e)}, 500) try: reason = {200:"OK",201:"Created",400:"Bad Request",404:"Not Found",500:"Internal Server Error"}.get(status,"OK") writer.write(f"HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {len(resp)}\r\nConnection: close\r\n\r\n".encode()+resp) diff --git a/static/index.html b/static/index.html index 833b0b5..dfdacd1 100644 --- a/static/index.html +++ b/static/index.html @@ -11,7 +11,7 @@ :root{--bg:#f8f8f0;--card:rgb(247,243,223);--text:#725d42;--head:#794f27;--muted:#9f927d;--soft:#c4b89e;--line:#9f927d;--mint:#19c8b9;--mint2:#e6f9f6;--green:#6fba2c;--red:#e05a5a;--yellow:#f5c31c;--blue:#889df0;--pink:#f8a6b2;--purple:#b77dee;--orange:#e59266;--teal:#82d5bb;--shadow:#bdaea0}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at 12% 18%,rgba(130,213,187,.35),transparent 22%),radial-gradient(circle at 88% 8%,rgba(247,205,103,.36),transparent 21%),linear-gradient(180deg,#a7dec0 0,#f8f8f0 42%);color:var(--text);font-family:Nunito,'Noto Sans SC','Zen Maru Gothic',-apple-system,'PingFang SC',sans-serif;font-weight:500;letter-spacing:.01em}.wrap{max-width:1420px;margin:0 auto;padding:26px}.island{background:rgba(248,248,240,.76);border:3px solid rgba(159,146,125,.45);border-radius:42px 36px 48px 34px/36px 44px 34px 46px;padding:22px;box-shadow:0 12px 0 rgba(114,93,66,.12)}.nav{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:22px}.brand{display:flex;align-items:center;gap:14px}.logo{width:58px;height:58px;border-radius:22px;background:linear-gradient(145deg,var(--mint),#86d67a);border:3px solid var(--line);box-shadow:0 5px 0 var(--shadow);display:grid;place-items:center;font-size:30px}.brand h1{margin:0;color:var(--head);font-size:34px;font-weight:900;letter-spacing:.02em}.muted{color:var(--muted)}.tabs{display:flex;gap:10px;flex-wrap:wrap}.tab,.btn{border:2.5px solid var(--line);background:var(--card);color:var(--text);border-radius:50px;height:44px;padding:0 18px;font-weight:800;cursor:pointer;box-shadow:0 5px 0 var(--shadow);transition:all .22s cubic-bezier(.4,0,.2,1)}.tab:hover,.btn:hover{transform:translateY(-1px);box-shadow:0 6px 0 var(--shadow)}.tab:active,.btn:active{transform:translateY(2px);box-shadow:0 1px 0 var(--shadow)}.tab.active,.btn.primary{background:var(--mint);color:white;border-color:#11a89b;box-shadow:0 5px 0 #0f8f85}.btn.danger{background:#fff1ef;color:var(--red);border-color:var(--red);box-shadow:0 5px 0 #c94444}.btn.small{height:34px;padding:0 13px;font-size:12px;border-radius:18px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:18px}.hero{display:grid;grid-template-columns:1.2fr .8fr;gap:18px;margin-bottom:18px}.card{background:var(--card);border:3px solid rgba(159,146,125,.88);border-radius:28px 24px 32px 23px/25px 32px 24px 30px;padding:18px;box-shadow:0 4px 10px rgba(107,92,67,.42);transition:all .22s}.card:hover{transform:translateY(-2px)}.card.blue{background:#eef2ff}.card.green{background:#eef9e8}.card.yellow{background:#fff5cf}.card.pink{background:#fff0f3}.row{display:flex;align-items:center;justify-content:space-between;gap:12px}.metric{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:16px}.metric .box{background:linear-gradient(180deg,#fff,var(--bg));border:2.5px solid #d4cfc3;border-radius:20px;padding:14px;box-shadow:0 3px 0 #d4c9b4}.num{font-size:34px;font-weight:900;color:#8b7355}.section{display:none}.section.active{display:block}h2,h3{color:var(--head)}.pill{display:inline-flex;align-items:center;gap:7px;border:2px solid var(--line);border-radius:999px;padding:5px 10px;background:#fff;font-size:12px;font-weight:900}.dot{width:9px;height:9px;border-radius:50%;background:var(--yellow)}.up{color:var(--green)}.up .dot{background:var(--green)}.down{color:var(--red)}.down .dot{background:var(--red)}.unknown{color:#dba90e}.unknown .dot{background:var(--yellow)}.mono{font-family:'Zen Maru Gothic',ui-monospace,monospace}.kv{display:grid;grid-template-columns:110px 1fr;gap:7px;margin:14px 0;font-size:13px}.toolbar{display:flex;gap:9px;flex-wrap:wrap}table{width:100%;border-collapse:separate;border-spacing:0 7px;font-size:13px}td,th{text-align:left;padding:8px 10px}th{color:var(--muted);font-weight:900}tbody tr{background:rgba(255,255,255,.55)}tbody td:first-child{border-radius:14px 0 0 14px}tbody td:last-child{border-radius:0 14px 14px 0}.err{max-width:420px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.drawer{position:fixed;right:18px;top:18px;bottom:18px;width:min(620px,calc(100vw - 36px));background:var(--card);border:3px solid var(--line);border-radius:40px 35px 45px 38px/38px 45px 35px 40px;padding:24px;box-shadow:0 18px 0 rgba(114,93,66,.22),0 30px 80px rgba(107,92,67,.38);transform:translateX(120%);transition:.25s;z-index:10;overflow:auto}.drawer.open{transform:none}.overlay{position:fixed;inset:0;background:rgba(80,60,40,.38);display:none;z-index:9}.overlay.open{display:block}.form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.form .full{grid-column:1/-1}label{font-size:12px;color:var(--muted);font-weight:900;display:block;margin:8px 0 5px}input,select,textarea{width:100%;height:42px;background:#fff;color:var(--text);border:2.5px solid #c4b89e;border-radius:50px;padding:0 16px;box-shadow:0 3px 0 #d4c9b4;outline:none;font-weight:700}textarea{height:94px;border-radius:20px;padding:12px 16px}input:focus,select:focus,textarea:focus{border-color:#ffcc00;box-shadow:0 3px 0 #e0b800,0 0 0 3px rgba(255,204,0,.15)}.type-fields{background:rgba(255,255,255,.48);border:2px dashed #c4b89e;border-radius:22px;padding:12px}.channel{display:grid;grid-template-columns:1fr auto;align-items:center;gap:12px}.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--mint);color:#fff;border:3px solid #11a89b;border-radius:50px;padding:12px 18px;box-shadow:0 5px 0 #0f8f85;font-weight:900;display:none;z-index:20}.toast.show{display:block}.sea{height:60px;margin-top:18px;border-radius:0 0 34px 34px;background:repeating-linear-gradient(135deg,#82d5bb 0 18px,#19c8b9 18px 36px);opacity:.45}@media(max-width:900px){.hero{grid-template-columns:1fr}.metric{grid-template-columns:repeat(2,1fr)}.form{grid-template-columns:1fr}.nav{align-items:flex-start;flex-direction:column}.wrap{padding:12px}}

运行概览

加载中...
节点
0
正常
0
故障
0
通知
0
单节点记录
-

快速操作

管理节点和通知渠道。

SOCKS5 节点

通知渠道

检测设置

配置全局检测目标、间隔、超时和状态阈值。

默认检测目标
-
默认间隔
-
默认超时
-
阈值
-
单节点记录上限
-

事件

时间节点状态消息

通知记录

时间渠道结果标题