feat: add hy app monitor
This commit is contained in:
commit
c60a546246
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
run/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
93
README.md
Normal file
93
README.md
Normal 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
154
config/targets.json
Normal 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
129
ops/deploy_via_tat.py
Executable 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
199
ops/open_port_via_tat.py
Executable 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
1
requirements-ops.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
tencentcloud-sdk-python>=3.0.0
|
||||||
28
scripts/common.sh
Executable file
28
scripts/common.sh
Executable 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
8
scripts/restart.sh
Executable 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
32
scripts/start.sh
Executable 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
14
scripts/status.sh
Executable 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
28
scripts/stop.sh
Executable 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
206
server.py
Normal 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
367
static/app.css
Normal 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
105
static/app.js
Normal 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
107
static/index.html
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user