feat: add hy app monitor

This commit is contained in:
ZuoZuo 2026-04-22 00:07:30 +08:00
commit c60a546246
15 changed files with 1475 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
run/
__pycache__/
*.pyc
.DS_Store

93
README.md Normal file
View File

@ -0,0 +1,93 @@
# hy-app-monitor
一个给 `deploy` 机器使用的轻量监控页。
- 前端:静态 HTML + Vue 3 CDN
- 后端Python 3 标准库 HTTP 服务
- 默认端口:`6666`
- 默认监听:`0.0.0.0`
## 功能
- 从 `deploy` 机器内部探测内网 Java / Golang 服务健康状态
- 对外提供监控页和 JSON API
- 提供启动 / 停止 / 重启 / 状态脚本
- 提供通过 TAT 放开 6666 端口和远端拉取部署的脚本
## 目录
- `server.py`:后端入口
- `config/targets.json`:监控目标配置
- `static/`:前端页面
- `scripts/`:启停脚本
- `ops/`TAT 相关脚本
## 本地启动
```bash
cd /opt/hy-app-monitor
bash scripts/start.sh
```
默认访问:
```bash
http://127.0.0.1:6666/
```
## 脚本
```bash
bash scripts/start.sh
bash scripts/stop.sh
bash scripts/restart.sh
bash scripts/status.sh
```
## 环境变量
- `APP_BIND`:默认 `0.0.0.0`
- `APP_PORT`:默认 `6666`
- `TARGETS_FILE`:默认 `config/targets.json`
- `PROBE_TIMEOUT_SECONDS`:默认 `3`
- `PROBE_CACHE_TTL_SECONDS`:默认 `5`
- `PROBE_MAX_WORKERS`:默认 `12`
## 用 TAT 放开 6666
先确保本机有腾讯云密钥环境变量:
```bash
export TENCENT_SECRET_ID=...
export TENCENT_SECRET_KEY=...
```
执行:
```bash
python3 ops/open_port_via_tat.py
```
默认会针对 deploy 机器 `ins-ntmpcd3a``me-saudi-arabia` 区域:
- 通过 TAT 在机器内尝试放开系统防火墙
- 通过 TAT 调腾讯云 API 放开安全组 TCP `6666`
## 用 TAT 拉取并启动
```bash
python3 ops/deploy_via_tat.py
```
默认远端目录:
```bash
/opt/hy-app-monitor
```
默认会:
1. clone / pull 仓库
2. 给脚本加执行权限
3. 重启服务
4. 检查 6666 端口监听

154
config/targets.json Normal file
View File

@ -0,0 +1,154 @@
{
"hosts": [
{
"name": "gateway-1",
"ip": "10.2.1.8",
"group": "gateway",
"services": [
{
"name": "gateway",
"kind": "java",
"port": 8080,
"path": "/health"
}
]
},
{
"name": "gateway-2",
"ip": "10.2.2.17",
"group": "gateway",
"services": [
{
"name": "gateway",
"kind": "java",
"port": 8080,
"path": "/health"
}
]
},
{
"name": "app-1",
"ip": "10.2.11.3",
"group": "app",
"services": [
{
"name": "auth",
"kind": "java",
"port": 1000,
"path": "/actuator/health"
},
{
"name": "other",
"kind": "java",
"port": 2400,
"path": "/actuator/health"
},
{
"name": "live",
"kind": "java",
"port": 2500,
"path": "/actuator/health"
},
{
"name": "console",
"kind": "java",
"port": 2700,
"path": "/console/actuator/health"
},
{
"name": "external",
"kind": "java",
"port": 3000,
"path": "/actuator/health"
},
{
"name": "golang",
"kind": "golang",
"port": 2900,
"path": "/health"
}
]
},
{
"name": "app-2",
"ip": "10.2.12.6",
"group": "app",
"services": [
{
"name": "auth",
"kind": "java",
"port": 1000,
"path": "/actuator/health"
},
{
"name": "other",
"kind": "java",
"port": 2400,
"path": "/actuator/health"
},
{
"name": "live",
"kind": "java",
"port": 2500,
"path": "/actuator/health"
},
{
"name": "console",
"kind": "java",
"port": 2700,
"path": "/console/actuator/health"
},
{
"name": "external",
"kind": "java",
"port": 3000,
"path": "/actuator/health"
},
{
"name": "golang",
"kind": "golang",
"port": 2900,
"path": "/health"
}
]
},
{
"name": "pay-1",
"ip": "10.2.11.14",
"group": "pay",
"services": [
{
"name": "wallet",
"kind": "java",
"port": 2000,
"path": "/actuator/health"
},
{
"name": "order",
"kind": "java",
"port": 2600,
"path": "/actuator/health"
}
]
},
{
"name": "pay-2",
"ip": "10.2.12.5",
"group": "pay",
"services": [
{
"name": "wallet",
"kind": "java",
"port": 2000,
"path": "/actuator/health"
},
{
"name": "order",
"kind": "java",
"port": 2600,
"path": "/actuator/health"
}
]
}
]
}

129
ops/deploy_via_tat.py Executable file
View File

@ -0,0 +1,129 @@
#!/usr/bin/env python3
from __future__ import annotations
import base64
import json
import os
import time
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.tat.v20201028 import models as tat_models, tat_client
DEFAULT_INSTANCE_ID = os.environ.get("DEPLOY_INSTANCE_ID", "ins-ntmpcd3a")
DEFAULT_REGION = os.environ.get("DEPLOY_REGION", "me-saudi-arabia")
DEFAULT_REPO_URL = os.environ.get("MONITOR_REPO_URL", "git@gitea.haiyihy.com:hy/hy-app-monitor.git")
DEFAULT_REPO_REF = os.environ.get("MONITOR_REPO_REF", "main")
DEFAULT_REMOTE_DIR = os.environ.get("MONITOR_REMOTE_DIR", "/opt/hy-app-monitor")
DEFAULT_APP_PORT = os.environ.get("APP_PORT", "6666")
def env_required(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
raise SystemExit(f"missing env: {name}")
return value
def build_tat(secret_id: str, secret_key: str, region: str) -> tat_client.TatClient:
cred = credential.Credential(secret_id, secret_key)
return tat_client.TatClient(
cred,
region,
ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")),
)
def remote_script(repo_url: str, repo_ref: str, remote_dir: str, app_port: str) -> str:
return f"""set -euo pipefail
REPO_URL={json.dumps(repo_url)}
REPO_REF={json.dumps(repo_ref)}
REMOTE_DIR={json.dumps(remote_dir)}
APP_PORT={json.dumps(app_port)}
export GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=accept-new'
mkdir -p "$(dirname "$REMOTE_DIR")"
if [ -d "$REMOTE_DIR/.git" ]; then
git -C "$REMOTE_DIR" fetch --all --prune
git -C "$REMOTE_DIR" checkout "$REPO_REF"
git -C "$REMOTE_DIR" pull --ff-only origin "$REPO_REF"
else
rm -rf "$REMOTE_DIR"
git clone --branch "$REPO_REF" "$REPO_URL" "$REMOTE_DIR"
fi
cd "$REMOTE_DIR"
chmod +x scripts/*.sh
APP_PORT="$APP_PORT" bash scripts/restart.sh
sleep 1
ss -lntp | grep ":$APP_PORT" || true
"""
def run_tat_script(client: tat_client.TatClient, instance_id: str, script: str, timeout_seconds: int = 600) -> str:
req = tat_models.RunCommandRequest()
req.from_json_string(
json.dumps(
{
"CommandName": "hy-app-monitor-deploy",
"CommandType": "SHELL",
"Content": base64.b64encode(script.encode("utf-8")).decode("ascii"),
"InstanceIds": [instance_id],
"Timeout": timeout_seconds,
"WorkingDirectory": "/root",
}
)
)
resp = json.loads(client.RunCommand(req).to_json_string())
invocation_id = resp["InvocationId"]
deadline = time.time() + timeout_seconds + 120
while time.time() < deadline:
time.sleep(3)
query = tat_models.DescribeInvocationTasksRequest()
query.from_json_string(
json.dumps(
{
"HideOutput": False,
"Limit": 20,
"Filters": [{"Name": "invocation-id", "Values": [invocation_id]}],
}
)
)
data = json.loads(client.DescribeInvocationTasks(query).to_json_string())
tasks = data.get("InvocationTaskSet") or []
if not tasks:
continue
task = tasks[0]
status = task["TaskStatus"]
if status in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}:
output = base64.b64decode(task.get("TaskResult", {}).get("Output", "")).decode("utf-8", errors="replace")
if status != "SUCCESS":
raise SystemExit(output or f"TAT task failed: {status}")
return output
raise SystemExit("TAT task timed out")
def main() -> None:
secret_id = env_required("TENCENT_SECRET_ID")
secret_key = env_required("TENCENT_SECRET_KEY")
instance_id = os.environ.get("DEPLOY_INSTANCE_ID", DEFAULT_INSTANCE_ID)
region = os.environ.get("DEPLOY_REGION", DEFAULT_REGION)
repo_url = os.environ.get("MONITOR_REPO_URL", DEFAULT_REPO_URL)
repo_ref = os.environ.get("MONITOR_REPO_REF", DEFAULT_REPO_REF)
remote_dir = os.environ.get("MONITOR_REMOTE_DIR", DEFAULT_REMOTE_DIR)
app_port = os.environ.get("APP_PORT", DEFAULT_APP_PORT)
tat = build_tat(secret_id, secret_key, region)
output = run_tat_script(tat, instance_id, remote_script(repo_url, repo_ref, remote_dir, app_port))
print(output.strip())
if __name__ == "__main__":
main()

199
ops/open_port_via_tat.py Executable file
View File

@ -0,0 +1,199 @@
#!/usr/bin/env python3
from __future__ import annotations
import base64
import json
import os
import time
from pathlib import Path
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.cvm.v20170312 import cvm_client, models as cvm_models
from tencentcloud.tat.v20201028 import models as tat_models, tat_client
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INSTANCE_ID = os.environ.get("DEPLOY_INSTANCE_ID", "ins-ntmpcd3a")
DEFAULT_REGION = os.environ.get("DEPLOY_REGION", "me-saudi-arabia")
DEFAULT_PORT = int(os.environ.get("OPEN_PORT", "6666"))
DEFAULT_CIDR = os.environ.get("OPEN_CIDR", "0.0.0.0/0")
def env_required(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
raise SystemExit(f"missing env: {name}")
return value
def build_cvm(secret_id: str, secret_key: str, region: str) -> cvm_client.CvmClient:
cred = credential.Credential(secret_id, secret_key)
return cvm_client.CvmClient(
cred,
region,
ClientProfile(httpProfile=HttpProfile(endpoint="cvm.tencentcloudapi.com")),
)
def build_tat(secret_id: str, secret_key: str, region: str) -> tat_client.TatClient:
cred = credential.Credential(secret_id, secret_key)
return tat_client.TatClient(
cred,
region,
ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")),
)
def resolve_security_group_id(client: cvm_client.CvmClient, instance_id: str) -> str:
req = cvm_models.DescribeInstancesRequest()
req.from_json_string(json.dumps({"InstanceIds": [instance_id]}))
resp = json.loads(client.DescribeInstances(req).to_json_string())
instances = resp.get("InstanceSet") or []
if not instances:
raise SystemExit(f"instance not found: {instance_id}")
security_group_ids = instances[0].get("SecurityGroupIds") or []
if not security_group_ids:
raise SystemExit(f"no security group attached: {instance_id}")
return str(security_group_ids[0])
def remote_script(secret_id: str, secret_key: str, region: str, security_group_id: str, port: int, cidr: str) -> str:
payload = {
"secret_id": secret_id,
"secret_key": secret_key,
"region": region,
"security_group_id": security_group_id,
"port": port,
"cidr": cidr,
}
blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
return f"""set -euo pipefail
if command -v firewall-cmd >/dev/null 2>&1; then
firewall-cmd --add-port={port}/tcp --permanent >/dev/null 2>&1 || true
firewall-cmd --reload >/dev/null 2>&1 || true
fi
if command -v ufw >/dev/null 2>&1; then
ufw allow {port}/tcp >/dev/null 2>&1 || true
fi
if command -v iptables >/dev/null 2>&1; then
iptables -C INPUT -p tcp --dport {port} -j ACCEPT >/dev/null 2>&1 || iptables -I INPUT -p tcp --dport {port} -j ACCEPT || true
fi
python3 -m pip install --user -q tencentcloud-sdk-python >/dev/null 2>&1 || true
python3 - <<'PY'
import base64
import json
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.vpc.v20170312 import vpc_client, models
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
payload = json.loads(base64.b64decode({blob!r}).decode('utf-8'))
cred = credential.Credential(payload['secret_id'], payload['secret_key'])
client = vpc_client.VpcClient(
cred,
payload['region'],
ClientProfile(httpProfile=HttpProfile(endpoint='vpc.tencentcloudapi.com')),
)
req = models.AuthorizeSecurityGroupPoliciesRequest()
req.from_json_string(json.dumps({{
'SecurityGroupId': payload['security_group_id'],
'SecurityGroupPolicySet': {{
'Ingress': [{{
'Protocol': 'TCP',
'Port': str(payload['port']),
'CidrBlock': payload['cidr'],
'Action': 'ACCEPT',
'PolicyDescription': 'hy-app-monitor 6666',
}}]
}}
}}))
try:
client.AuthorizeSecurityGroupPolicies(req)
print('security_group_rule=created')
except TencentCloudSDKException as exc:
if 'Duplicate' in str(exc) or 'already exists' in str(exc):
print('security_group_rule=exists')
else:
raise
PY
echo "port_opened={port}"
"""
def run_tat_script(client: tat_client.TatClient, instance_id: str, script: str, timeout_seconds: int = 300) -> str:
req = tat_models.RunCommandRequest()
req.from_json_string(
json.dumps(
{
"CommandName": "hy-app-monitor-open-port",
"CommandType": "SHELL",
"Content": base64.b64encode(script.encode("utf-8")).decode("ascii"),
"InstanceIds": [instance_id],
"Timeout": timeout_seconds,
"WorkingDirectory": "/root",
}
)
)
resp = json.loads(client.RunCommand(req).to_json_string())
invocation_id = resp["InvocationId"]
deadline = time.time() + timeout_seconds + 120
while time.time() < deadline:
time.sleep(3)
query = tat_models.DescribeInvocationTasksRequest()
query.from_json_string(
json.dumps(
{
"HideOutput": False,
"Limit": 20,
"Filters": [{"Name": "invocation-id", "Values": [invocation_id]}],
}
)
)
data = json.loads(client.DescribeInvocationTasks(query).to_json_string())
tasks = data.get("InvocationTaskSet") or []
if not tasks:
continue
task = tasks[0]
status = task["TaskStatus"]
if status in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}:
output = base64.b64decode(task.get("TaskResult", {}).get("Output", "")).decode("utf-8", errors="replace")
if status != "SUCCESS":
raise SystemExit(output or f"TAT task failed: {status}")
return output
raise SystemExit("TAT task timed out")
def main() -> None:
secret_id = env_required("TENCENT_SECRET_ID")
secret_key = env_required("TENCENT_SECRET_KEY")
instance_id = os.environ.get("DEPLOY_INSTANCE_ID", DEFAULT_INSTANCE_ID)
region = os.environ.get("DEPLOY_REGION", DEFAULT_REGION)
port = int(os.environ.get("OPEN_PORT", str(DEFAULT_PORT)))
cidr = os.environ.get("OPEN_CIDR", DEFAULT_CIDR)
cvm = build_cvm(secret_id, secret_key, region)
tat = build_tat(secret_id, secret_key, region)
security_group_id = os.environ.get("SECURITY_GROUP_ID", "").strip() or resolve_security_group_id(cvm, instance_id)
output = run_tat_script(tat, instance_id, remote_script(secret_id, secret_key, region, security_group_id, port, cidr))
print(f"instance_id={instance_id}")
print(f"security_group_id={security_group_id}")
print(output.strip())
if __name__ == "__main__":
main()

1
requirements-ops.txt Normal file
View File

@ -0,0 +1 @@
tencentcloud-sdk-python>=3.0.0

28
scripts/common.sh Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
RUN_DIR="$ROOT_DIR/run"
PID_FILE="$RUN_DIR/hy-app-monitor.pid"
LOG_FILE="$RUN_DIR/hy-app-monitor.log"
APP_BIND="${APP_BIND:-0.0.0.0}"
APP_PORT="${APP_PORT:-6666}"
PYTHON_BIN="${PYTHON_BIN:-python3}"
TARGETS_FILE="${TARGETS_FILE:-$ROOT_DIR/config/targets.json}"
mkdir -p "$RUN_DIR"
read_pid() {
if [ -f "$PID_FILE" ]; then
cat "$PID_FILE"
fi
}
is_running() {
local pid
pid="$(read_pid || true)"
if [ -z "${pid:-}" ]; then
return 1
fi
kill -0 "$pid" >/dev/null 2>&1
}

8
scripts/restart.sh Executable file
View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
"$SCRIPT_DIR/stop.sh" || true
sleep 1
exec "$SCRIPT_DIR/start.sh"

32
scripts/start.sh Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/common.sh"
if is_running; then
echo "hy-app-monitor already running pid=$(read_pid)"
exit 0
fi
if [ ! -f "$TARGETS_FILE" ]; then
echo "targets file not found: $TARGETS_FILE" >&2
exit 1
fi
export APP_BIND APP_PORT TARGETS_FILE PYTHONUNBUFFERED=1
nohup "$PYTHON_BIN" "$ROOT_DIR/server.py" >>"$LOG_FILE" 2>&1 &
pid=$!
echo "$pid" >"$PID_FILE"
sleep 1
if kill -0 "$pid" >/dev/null 2>&1; then
echo "hy-app-monitor started pid=$pid port=$APP_PORT"
exit 0
fi
echo "hy-app-monitor failed to start" >&2
tail -n 40 "$LOG_FILE" >&2 || true
exit 1

14
scripts/status.sh Executable file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/common.sh"
if is_running; then
echo "hy-app-monitor running pid=$(read_pid) port=$APP_PORT"
exit 0
fi
echo "hy-app-monitor stopped"
exit 1

28
scripts/stop.sh Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/common.sh"
if ! is_running; then
echo "hy-app-monitor not running"
rm -f "$PID_FILE"
exit 0
fi
pid="$(read_pid)"
kill "$pid" >/dev/null 2>&1 || true
for _ in $(seq 1 10); do
if ! kill -0 "$pid" >/dev/null 2>&1; then
rm -f "$PID_FILE"
echo "hy-app-monitor stopped"
exit 0
fi
sleep 1
done
kill -9 "$pid" >/dev/null 2>&1 || true
rm -f "$PID_FILE"
echo "hy-app-monitor force stopped"

206
server.py Normal file
View File

@ -0,0 +1,206 @@
#!/usr/bin/env python3
from __future__ import annotations
import concurrent.futures
import json
import os
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parent
STATIC_DIR = ROOT / "static"
CONFIG_PATH = Path(os.environ.get("TARGETS_FILE", ROOT / "config" / "targets.json")).expanduser()
APP_BIND = os.environ.get("APP_BIND", "0.0.0.0").strip() or "0.0.0.0"
APP_PORT = int(os.environ.get("APP_PORT", "6666"))
PROBE_TIMEOUT_SECONDS = float(os.environ.get("PROBE_TIMEOUT_SECONDS", "3"))
PROBE_CACHE_TTL_SECONDS = float(os.environ.get("PROBE_CACHE_TTL_SECONDS", "5"))
PROBE_MAX_WORKERS = int(os.environ.get("PROBE_MAX_WORKERS", "12"))
_CACHE_LOCK = threading.Lock()
_CACHE_PAYLOAD: dict | None = None
_CACHE_AT = 0.0
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def load_targets() -> list[dict]:
payload = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
hosts = payload.get("hosts") or []
normalized: list[dict] = []
for host in hosts:
services = host.get("services") or []
for service in services:
normalized.append(
{
"host": str(host.get("name") or "").strip(),
"group": str(host.get("group") or "").strip(),
"ip": str(host.get("ip") or "").strip(),
"service": str(service.get("name") or "").strip(),
"kind": str(service.get("kind") or "java").strip(),
"port": int(service.get("port")),
"path": str(service.get("path") or "/").strip(),
}
)
return normalized
def _healthy_from_body(service_kind: str, status_code: int, body_text: str) -> bool:
lowered = body_text.lower()
if service_kind == "golang":
return status_code == 200 and ('"code":"ok"' in lowered or '"code": "ok"' in lowered or '"status":"ok"' in lowered)
if status_code in {401, 403}:
return True
if status_code != 200:
return False
if '"status":"up"' in lowered or '"status": "up"' in lowered:
return True
if body_text.strip() == "UP":
return True
return "up" in lowered and "status" in lowered
def probe_target(target: dict) -> dict:
url = f"http://{target['ip']}:{target['port']}{target['path']}"
started = time.perf_counter()
request = urllib.request.Request(url, headers={"User-Agent": "hy-app-monitor/1.0"})
base = {
**target,
"url": url,
"statusCode": 0,
"latencyMs": 0,
"ok": False,
"detail": "",
}
try:
with urllib.request.urlopen(request, timeout=PROBE_TIMEOUT_SECONDS) as response:
body_text = response.read().decode("utf-8", errors="replace")
latency_ms = int((time.perf_counter() - started) * 1000)
ok = _healthy_from_body(target["kind"], response.status, body_text)
return {
**base,
"statusCode": int(response.status),
"latencyMs": latency_ms,
"ok": ok,
"detail": body_text[:220].replace("\n", " ").strip(),
}
except urllib.error.HTTPError as exc:
body_text = exc.read().decode("utf-8", errors="replace")
latency_ms = int((time.perf_counter() - started) * 1000)
ok = _healthy_from_body(target["kind"], exc.code, body_text)
return {
**base,
"statusCode": int(exc.code),
"latencyMs": latency_ms,
"ok": ok,
"detail": body_text[:220].replace("\n", " ").strip() or str(exc),
}
except Exception as exc: # noqa: BLE001
latency_ms = int((time.perf_counter() - started) * 1000)
return {
**base,
"latencyMs": latency_ms,
"detail": str(exc),
}
def build_monitor_payload() -> dict:
targets = load_targets()
workers = max(1, min(PROBE_MAX_WORKERS, len(targets) or 1))
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
items = list(executor.map(probe_target, targets))
ok_count = sum(1 for item in items if item["ok"])
payload = {
"updatedAt": utc_now(),
"summary": {
"total": len(items),
"ok": ok_count,
"down": len(items) - ok_count,
},
"items": items,
}
return payload
def get_monitor_payload() -> dict:
global _CACHE_PAYLOAD, _CACHE_AT
now = time.time()
with _CACHE_LOCK:
if _CACHE_PAYLOAD is not None and now - _CACHE_AT < PROBE_CACHE_TTL_SECONDS:
return _CACHE_PAYLOAD
payload = build_monitor_payload()
with _CACHE_LOCK:
_CACHE_PAYLOAD = payload
_CACHE_AT = now
return payload
def response_json(payload: dict | list) -> bytes:
return (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
class MonitorHandler(BaseHTTPRequestHandler):
server_version = "HyAppMonitor/1.0"
def log_message(self, format: str, *args) -> None: # noqa: A003
return
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path in {"/", "/index.html"}:
return self.serve_static("index.html", "text/html; charset=utf-8")
if parsed.path == "/app.css":
return self.serve_static("app.css", "text/css; charset=utf-8")
if parsed.path == "/app.js":
return self.serve_static("app.js", "application/javascript; charset=utf-8")
if parsed.path == "/api/health":
return self.send_payload(HTTPStatus.OK, response_json({"status": "ok", "time": utc_now()}), "application/json; charset=utf-8")
if parsed.path == "/api/monitor/services":
payload = get_monitor_payload()
return self.send_payload(HTTPStatus.OK, response_json(payload), "application/json; charset=utf-8")
if parsed.path == "/favicon.ico":
return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon")
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found")
def serve_static(self, name: str, content_type: str) -> None:
path = STATIC_DIR / name
if not path.exists():
return self.send_error_payload(HTTPStatus.NOT_FOUND, "static file missing")
self.send_payload(HTTPStatus.OK, path.read_bytes(), content_type)
def send_error_payload(self, status: HTTPStatus, message: str) -> None:
self.send_payload(
status,
response_json({"status": int(status), "error": message}),
"application/json; charset=utf-8",
)
def send_payload(self, status: HTTPStatus, body: bytes, content_type: str) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def main() -> None:
server = ThreadingHTTPServer((APP_BIND, APP_PORT), MonitorHandler)
print(f"hy-app-monitor listening on http://{APP_BIND}:{APP_PORT}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()

367
static/app.css Normal file
View File

@ -0,0 +1,367 @@
:root {
--bg: oklch(19% 0.02 248);
--bg-soft: oklch(23% 0.02 248);
--panel: oklch(27% 0.024 248 / 0.9);
--panel-strong: oklch(31% 0.028 248 / 0.95);
--line: oklch(46% 0.03 248 / 0.34);
--text: oklch(94% 0.01 248);
--muted: oklch(76% 0.02 248);
--accent: oklch(70% 0.13 206);
--ok: oklch(76% 0.16 151);
--bad: oklch(68% 0.18 24);
--shadow: 0 24px 70px rgba(5, 10, 24, 0.35);
}
* {
box-sizing: border-box;
}
[v-cloak] {
display: none;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
color: var(--text);
font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif;
background:
radial-gradient(circle at top left, rgba(84, 165, 255, 0.14), transparent 24%),
radial-gradient(circle at bottom right, rgba(33, 211, 146, 0.14), transparent 22%),
linear-gradient(180deg, var(--bg-soft) 0%, var(--bg) 100%);
}
.shell {
max-width: 1360px;
margin: 0 auto;
padding: 28px 18px 48px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
gap: 18px;
margin-bottom: 18px;
}
.hero-copy,
.hero-side,
.toolbar,
.host-panel {
border: 1px solid var(--line);
border-radius: 24px;
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(14px);
}
.hero-copy {
padding: 26px 28px;
}
.eyebrow {
margin: 0 0 12px;
color: var(--accent);
font-size: 13px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.hero-copy h1 {
margin: 0;
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: clamp(2rem, 4vw, 3.7rem);
line-height: 0.96;
letter-spacing: -0.05em;
}
.hero-text {
max-width: 60ch;
margin: 14px 0 0;
color: var(--muted);
line-height: 1.7;
}
.hero-side {
display: grid;
gap: 12px;
padding: 18px;
}
.stat-block {
padding: 16px 18px;
border-radius: 18px;
background: var(--panel-strong);
border: 1px solid var(--line);
}
.stat-block span {
display: block;
color: var(--muted);
font-size: 13px;
}
.stat-block strong {
display: block;
margin-top: 8px;
font-size: 2rem;
letter-spacing: -0.04em;
}
.stat-block.ok strong {
color: var(--ok);
}
.stat-block.bad strong {
color: var(--bad);
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 14px;
margin-bottom: 12px;
}
.toolbar-left,
.toolbar-right {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.filter-chip,
.refresh {
border: 1px solid var(--line);
background: var(--panel-strong);
color: var(--text);
border-radius: 999px;
padding: 10px 14px;
font: inherit;
cursor: pointer;
transition: transform 180ms ease, background 180ms ease, border-color 180ms ease;
}
.filter-chip:hover,
.refresh:hover {
transform: translateY(-1px);
}
.filter-chip.active {
border-color: color-mix(in oklab, var(--accent) 65%, white 15%);
background: color-mix(in oklab, var(--accent) 18%, var(--panel-strong));
}
.search {
min-width: 260px;
border: 1px solid var(--line);
background: var(--panel-strong);
color: var(--text);
border-radius: 999px;
padding: 10px 14px;
font: inherit;
}
.meta-strip {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 2px 4px 16px;
color: var(--muted);
font-size: 13px;
}
.bad-text {
color: var(--bad);
}
.hosts {
display: grid;
gap: 16px;
}
.host-panel {
padding: 18px;
}
.host-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
margin-bottom: 16px;
}
.host-header h2 {
margin: 0;
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: 1.45rem;
letter-spacing: -0.03em;
}
.host-header p {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
}
.host-badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
border: 1px solid var(--line);
}
.badge.neutral {
color: var(--muted);
}
.badge.ok {
color: var(--ok);
border-color: color-mix(in oklab, var(--ok) 50%, var(--line));
}
.badge.bad {
color: var(--bad);
border-color: color-mix(in oklab, var(--bad) 50%, var(--line));
}
.service-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.service-card {
padding: 16px;
border-radius: 20px;
border: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)),
var(--panel-strong);
}
.service-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.service-name {
font-size: 1.08rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.service-kind {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.status-pill.ok {
color: var(--ok);
background: color-mix(in oklab, var(--ok) 14%, transparent);
}
.status-pill.bad {
color: var(--bad);
background: color-mix(in oklab, var(--bad) 14%, transparent);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.service-data {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.service-data small,
.service-url,
.service-detail {
color: var(--muted);
}
.service-data strong {
display: block;
margin-top: 4px;
font-size: 1rem;
}
.service-url {
margin-top: 16px;
font-size: 12px;
word-break: break-all;
}
.service-detail {
margin-top: 8px;
min-height: 40px;
font-size: 12px;
line-height: 1.55;
}
@media (max-width: 1080px) {
.hero {
grid-template-columns: 1fr;
}
.service-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.toolbar,
.host-header,
.meta-strip {
flex-direction: column;
align-items: stretch;
}
.search {
min-width: 0;
width: 100%;
}
.service-grid,
.service-data {
grid-template-columns: 1fr;
}
}

105
static/app.js Normal file
View File

@ -0,0 +1,105 @@
const { createApp } = Vue;
createApp({
data() {
return {
loading: false,
error: "",
filter: "all",
keyword: "",
payload: {
updatedAt: "",
summary: { total: 0, ok: 0, down: 0 },
items: [],
},
timer: null,
};
},
computed: {
summary() {
return this.payload.summary || { total: 0, ok: 0, down: 0 };
},
formattedUpdatedAt() {
if (!this.payload.updatedAt) {
return "-";
}
const date = new Date(this.payload.updatedAt);
return Number.isNaN(date.getTime()) ? this.payload.updatedAt : date.toLocaleString();
},
filteredItems() {
const keyword = this.keyword.toLowerCase();
return (this.payload.items || []).filter((item) => {
if (this.filter === "down" && item.ok) {
return false;
}
if (this.filter === "java" && item.kind !== "java") {
return false;
}
if (this.filter === "golang" && item.kind !== "golang") {
return false;
}
if (!keyword) {
return true;
}
return [item.host, item.ip, item.service, item.kind, item.group]
.join(" ")
.toLowerCase()
.includes(keyword);
});
},
groupedHosts() {
const map = new Map();
for (const item of this.filteredItems) {
if (!map.has(item.host)) {
map.set(item.host, {
host: item.host,
ip: item.ip,
group: item.group,
groupLabel: this.groupLabel(item.group),
downCount: 0,
items: [],
});
}
const group = map.get(item.host);
group.items.push(item);
if (!item.ok) {
group.downCount += 1;
}
}
return [...map.values()];
},
},
methods: {
groupLabel(group) {
return {
app: "应用节点",
pay: "支付节点",
gateway: "网关节点",
}[group] || group;
},
async refresh() {
this.loading = true;
this.error = "";
try {
const response = await fetch("/api/monitor/services", { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
this.payload = await response.json();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.loading = false;
}
},
},
mounted() {
this.refresh();
this.timer = window.setInterval(() => this.refresh(), 10000);
},
beforeUnmount() {
if (this.timer) {
window.clearInterval(this.timer);
}
},
}).mount("#app");

107
static/index.html Normal file
View File

@ -0,0 +1,107 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HY App Monitor</title>
<link rel="stylesheet" href="/app.css" />
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script defer src="/app.js"></script>
</head>
<body>
<div id="app" v-cloak class="shell">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">HY / Deploy Monitor</p>
<h1>内网服务实时巡检面板</h1>
<p class="hero-text">
页面本身不直接请求内网服务。所有探测都由 deploy 机器代发,适合直接通过公网 6666 访问。
</p>
</div>
<div class="hero-side">
<div class="stat-block">
<span>总服务数</span>
<strong>{{ summary.total }}</strong>
</div>
<div class="stat-block ok">
<span>正常</span>
<strong>{{ summary.ok }}</strong>
</div>
<div class="stat-block bad">
<span>异常</span>
<strong>{{ summary.down }}</strong>
</div>
</div>
</header>
<section class="toolbar">
<div class="toolbar-left">
<button :class="['filter-chip', filter === 'all' ? 'active' : '']" @click="filter = 'all'">全部</button>
<button :class="['filter-chip', filter === 'down' ? 'active' : '']" @click="filter = 'down'">只看异常</button>
<button :class="['filter-chip', filter === 'java' ? 'active' : '']" @click="filter = 'java'">Java</button>
<button :class="['filter-chip', filter === 'golang' ? 'active' : '']" @click="filter = 'golang'">Golang</button>
</div>
<div class="toolbar-right">
<input v-model.trim="keyword" class="search" placeholder="搜索主机 / 服务 / IP" />
<button class="refresh" @click="refresh" :disabled="loading">{{ loading ? '刷新中...' : '立即刷新' }}</button>
</div>
</section>
<section class="meta-strip">
<span>最后刷新:{{ formattedUpdatedAt }}</span>
<span v-if="error" class="bad-text">加载失败:{{ error }}</span>
<span v-else>自动刷新10 秒</span>
</section>
<section class="hosts">
<article v-for="group in groupedHosts" :key="group.host" class="host-panel">
<header class="host-header">
<div>
<h2>{{ group.host }}</h2>
<p>{{ group.ip }} · {{ group.groupLabel }}</p>
</div>
<div class="host-badges">
<span class="badge neutral">{{ group.items.length }} 项</span>
<span :class="['badge', group.downCount > 0 ? 'bad' : 'ok']">
{{ group.downCount > 0 ? `${group.downCount} 异常` : '全部正常' }}
</span>
</div>
</header>
<div class="service-grid">
<div v-for="item in group.items" :key="item.host + '-' + item.service" class="service-card">
<div class="service-top">
<div>
<div class="service-name">{{ item.service }}</div>
<div class="service-kind">{{ item.kind }}</div>
</div>
<div :class="['status-pill', item.ok ? 'ok' : 'bad']">
<span class="dot"></span>
{{ item.ok ? 'UP' : 'DOWN' }}
</div>
</div>
<div class="service-data">
<div>
<small>HTTP</small>
<strong>{{ item.statusCode || '-' }}</strong>
</div>
<div>
<small>耗时</small>
<strong>{{ item.latencyMs }}ms</strong>
</div>
<div>
<small>端口</small>
<strong>{{ item.port }}</strong>
</div>
</div>
<div class="service-url">{{ item.url }}</div>
<div class="service-detail">{{ item.detail || '无返回内容' }}</div>
</div>
</div>
</article>
</section>
</div>
</body>
</html>