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