feat: add common tools page with CDN refresh
This commit is contained in:
parent
3d66ed45ac
commit
e1519a77f4
@ -104,6 +104,8 @@ TENCENT_SECRET_KEY=
|
||||
GATEWAY_CLB_LOAD_BALANCER_ID=
|
||||
GATEWAY_CLB_LISTENER_IDS=
|
||||
GATEWAY_CLB_ALLOWED_DOMAINS=
|
||||
CDN_ALLOWED_DOMAINS=h5.haiyihy.com
|
||||
CDN_PURGE_TASK_LIMIT=20
|
||||
GATEWAY_CLB_POLL_SECONDS=2
|
||||
GATEWAY_CLB_TASK_TIMEOUT_SECONDS=120
|
||||
GATEWAY_CLB_DRAIN_SECONDS=0
|
||||
|
||||
13
README.md
13
README.md
@ -6,14 +6,16 @@
|
||||
- 登录入口:`http://43.164.75.199:2026/login`
|
||||
- 入口选择页:`http://43.164.75.199:2026/select`
|
||||
- Yumi 入口:`http://43.164.75.199:2026/yumi/`
|
||||
- MySQL 入口:`http://43.164.75.199:2026/mysql/`
|
||||
- 通用入口:`http://43.164.75.199:2026/common/`
|
||||
- HaiYi 入口:`http://43.164.75.199:2026/haiyi/`
|
||||
- MySQL 兼容入口:`http://43.164.75.199:2026/mysql/`
|
||||
- 内部实例:`gateway:2026`、`yumi:2027`、`haiyi:2028`
|
||||
- 服务健康:直接探测各业务服务健康接口
|
||||
- 主机资源:通过 `SSH` 采集 `CPU / 内存 / 磁盘 / 进程数`
|
||||
- Nacos:直连 Nacos OpenAPI,列出命名空间、服务和注册实例
|
||||
- MongoDB:直连 Mongo,采集 `serverStatus / listDatabases / replSetGetStatus`,并提供库表浏览、分页查询、CRUD、聚合、Distinct、索引管理
|
||||
- MySQL:直连云 MySQL,提供运行状态、库表浏览、100 条分页查询、CRUD、列/索引变更和 SQL 工作台
|
||||
- CDN:直连腾讯云 CDN,支持 `URL / 目录` 刷新和最近任务查询;当前限制域名 `https://h5.haiyihy.com/`
|
||||
- 宿主机运维:日志、滚动重启、compose 同步、服务更新统一走 `SSH`
|
||||
- 带外引导:只在打通 `deploy -> target:22` 时使用 `TAT`
|
||||
|
||||
@ -65,6 +67,8 @@ TENCENT_SECRET_ID=***
|
||||
TENCENT_SECRET_KEY=***
|
||||
GATEWAY_CLB_LOAD_BALANCER_ID=lb-xxxxxxxx
|
||||
GATEWAY_CLB_LISTENER_IDS=lbl-http,lbl-https
|
||||
CDN_ALLOWED_DOMAINS=h5.haiyihy.com
|
||||
CDN_PURGE_TASK_LIMIT=20
|
||||
GATEWAY_CLB_POLL_SECONDS=2
|
||||
GATEWAY_CLB_TASK_TIMEOUT_SECONDS=120
|
||||
GATEWAY_CLB_DRAIN_SECONDS=0
|
||||
@ -140,6 +144,13 @@ MYSQL_MAX_PAGE_SIZE=200
|
||||
MYSQL_QUERY_MAX_ROWS=500
|
||||
```
|
||||
|
||||
CDN 刷新工具可选参数:
|
||||
|
||||
```bash
|
||||
CDN_ALLOWED_DOMAINS=h5.haiyihy.com
|
||||
CDN_PURGE_TASK_LIMIT=20
|
||||
```
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
|
||||
@ -120,6 +120,10 @@ GATEWAY_CLB_LOAD_BALANCER_ID = env_str("GATEWAY_CLB_LOAD_BALANCER_ID", "")
|
||||
GATEWAY_CLB_LISTENER_IDS = env_str_list("GATEWAY_CLB_LISTENER_IDS", [])
|
||||
# gateway 发布时只保护这些域名规则;留空表示 listener 下匹配到的规则全部处理。
|
||||
GATEWAY_CLB_ALLOWED_DOMAINS = env_str_list("GATEWAY_CLB_ALLOWED_DOMAINS", [])
|
||||
# CDN 刷新工具可选限制到固定业务域名;留空时允许提交任意腾讯云 CDN 域名。
|
||||
CDN_ALLOWED_DOMAINS = env_str_list("CDN_ALLOWED_DOMAINS", [])
|
||||
# CDN 工具页默认展示最近多少条刷新记录。
|
||||
CDN_PURGE_TASK_LIMIT = env_int("CDN_PURGE_TASK_LIMIT", 20)
|
||||
# CLB 异步任务轮询间隔。
|
||||
GATEWAY_CLB_POLL_SECONDS = env_float("GATEWAY_CLB_POLL_SECONDS", 2.0)
|
||||
# CLB 摘流 / 恢复异步任务最长等待秒数。
|
||||
|
||||
@ -48,6 +48,13 @@ APP_PROXY_TARGETS: tuple[dict[str, Any], ...] = (
|
||||
"host": YUMI_INTERNAL_HOST,
|
||||
"port": YUMI_INTERNAL_PORT,
|
||||
},
|
||||
{
|
||||
"app": "common",
|
||||
"basePath": "/common",
|
||||
"upstreamBasePath": "/yumi",
|
||||
"host": YUMI_INTERNAL_HOST,
|
||||
"port": YUMI_INTERNAL_PORT,
|
||||
},
|
||||
{
|
||||
"app": "mysql",
|
||||
"basePath": "/mysql",
|
||||
@ -164,7 +171,7 @@ class GatewayHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
|
||||
# 子应用前缀下的认证状态统一由网关代答。
|
||||
if path in {"/yumi/api/auth/session", "/haiyi/api/auth/session", "/mysql/api/auth/session"}:
|
||||
if path in {"/yumi/api/auth/session", "/haiyi/api/auth/session", "/mysql/api/auth/session", "/common/api/auth/session"}:
|
||||
return self.handle_auth_session(send_body=send_body)
|
||||
|
||||
# favicon 直接返回空。
|
||||
@ -192,7 +199,13 @@ class GatewayHandler(BaseHTTPRequestHandler):
|
||||
path = parsed.path
|
||||
|
||||
# 退出接口允许空请求体,先单独处理。
|
||||
if path in {"/api/auth/logout", "/yumi/api/auth/logout", "/haiyi/api/auth/logout", "/mysql/api/auth/logout"}:
|
||||
if path in {
|
||||
"/api/auth/logout",
|
||||
"/yumi/api/auth/logout",
|
||||
"/haiyi/api/auth/logout",
|
||||
"/mysql/api/auth/logout",
|
||||
"/common/api/auth/logout",
|
||||
}:
|
||||
return self.handle_logout()
|
||||
|
||||
# 登录接口只在根路径开放。
|
||||
|
||||
@ -54,6 +54,7 @@ from .service_logs import get_service_log_export_payload, get_service_log_previe
|
||||
from .service_release import build_update_targets_payload
|
||||
from .service_update_jobs import get_update_job_log_text, get_update_job_payload, start_update_job
|
||||
from .service_ops import build_restart_targets_payload, rolling_restart_payload
|
||||
from .tencent_cdn import get_cdn_overview_payload, submit_cdn_purge_payload
|
||||
|
||||
|
||||
def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
|
||||
@ -239,6 +240,20 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
# 返回 CDN 刷新工具页概览、配额和最近记录。
|
||||
if local_path == "/api/cdn/overview":
|
||||
try:
|
||||
payload = get_cdn_overview_payload()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
|
||||
|
||||
return self.send_payload(
|
||||
HTTPStatus.OK,
|
||||
response_json(payload),
|
||||
"application/json; charset=utf-8",
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
# 返回某个库下的表列表。
|
||||
if local_path == "/api/mysql/tables":
|
||||
database_name = self.query_value(query, "database")
|
||||
@ -515,6 +530,9 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
if local_path == "/api/mysql/sql/execute":
|
||||
return self.handle_mysql_action(execute_mysql_sql_payload, payload)
|
||||
|
||||
if local_path == "/api/cdn/purge":
|
||||
return self.handle_mysql_action(submit_cdn_purge_payload, payload, status=HTTPStatus.ACCEPTED)
|
||||
|
||||
if local_path == "/api/ops/rolling-restart":
|
||||
return self.handle_rolling_restart(payload)
|
||||
|
||||
|
||||
326
monitors/yumi/tencent_cdn.py
Normal file
326
monitors/yumi/tencent_cdn.py
Normal file
@ -0,0 +1,326 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from core.config import (
|
||||
CDN_ALLOWED_DOMAINS,
|
||||
CDN_PURGE_TASK_LIMIT,
|
||||
DEPLOY_REGION,
|
||||
TENCENT_SECRET_ID,
|
||||
TENCENT_SECRET_KEY,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
|
||||
URL_PURGE_BATCH_LIMIT = 1000
|
||||
PATH_PURGE_BATCH_LIMIT = 500
|
||||
|
||||
|
||||
def cdn_configuration_error() -> str:
|
||||
# CDN 刷新和现有腾讯云模块共用同一组凭据;少任意一项都直接视为未配置。
|
||||
missing: list[str] = []
|
||||
if not TENCENT_SECRET_ID:
|
||||
missing.append("TENCENT_SECRET_ID")
|
||||
if not TENCENT_SECRET_KEY:
|
||||
missing.append("TENCENT_SECRET_KEY")
|
||||
if not DEPLOY_REGION:
|
||||
missing.append("DEPLOY_REGION")
|
||||
|
||||
if missing:
|
||||
return f"cdn refresh requires .env: {', '.join(missing)}"
|
||||
return ""
|
||||
|
||||
|
||||
def normalized_cdn_allowed_domains() -> set[str]:
|
||||
# 域名白名单统一转成小写集合,前后端展示和后端校验保持一套口径。
|
||||
return {str(item or "").strip().lower() for item in CDN_ALLOWED_DOMAINS if str(item or "").strip()}
|
||||
|
||||
|
||||
def build_cdn_client() -> tuple[Any | None, str]:
|
||||
# 先兜底检查配置,避免没有凭据时还继续初始化 SDK。
|
||||
error = cdn_configuration_error()
|
||||
if error:
|
||||
return None, error
|
||||
|
||||
try:
|
||||
from tencentcloud.cdn.v20180606 import cdn_client
|
||||
from tencentcloud.common import credential
|
||||
from tencentcloud.common.profile.client_profile import ClientProfile
|
||||
from tencentcloud.common.profile.http_profile import HttpProfile
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, f"tencentcloud sdk unavailable: {exc}"
|
||||
|
||||
cred = credential.Credential(TENCENT_SECRET_ID, TENCENT_SECRET_KEY)
|
||||
client = cdn_client.CdnClient(
|
||||
cred,
|
||||
DEPLOY_REGION,
|
||||
ClientProfile(httpProfile=HttpProfile(endpoint="cdn.tencentcloudapi.com")),
|
||||
)
|
||||
return client, ""
|
||||
|
||||
|
||||
def invoke_cdn(action: Any, label: str, retries: int = 3, delay_seconds: int = 2) -> Any:
|
||||
# CDN SDK 异常和 CLB/TAT 一样集中做轻量重试,减少瞬时网络抖动带来的假失败。
|
||||
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
|
||||
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
return action()
|
||||
except TencentCloudSDKException as exc:
|
||||
last_error = exc
|
||||
if attempt == retries:
|
||||
break
|
||||
time.sleep(delay_seconds * attempt)
|
||||
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise RuntimeError(f"{label} failed without error")
|
||||
|
||||
|
||||
def call_cdn(method_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
# 所有 CDN 调用统一走 JSON 请求体,后续补字段时不需要反复修改 SDK 模型赋值逻辑。
|
||||
client, error = build_cdn_client()
|
||||
if client is None:
|
||||
raise RuntimeError(error)
|
||||
|
||||
from tencentcloud.cdn.v20180606 import models as cdn_models
|
||||
|
||||
request_class = getattr(cdn_models, f"{method_name}Request")
|
||||
request = request_class()
|
||||
request.from_json_string(json.dumps(payload))
|
||||
response = invoke_cdn(lambda: getattr(client, method_name)(request), method_name)
|
||||
payload = json.loads(response.to_json_string())
|
||||
if isinstance(payload, dict) and isinstance(payload.get("Response"), dict):
|
||||
return dict(payload.get("Response") or {})
|
||||
return dict(payload or {})
|
||||
|
||||
|
||||
def normalize_cdn_purge_type(value: Any) -> str:
|
||||
# 工具页只开放 URL 和目录两种刷新类型。
|
||||
purge_type = str(value or "url").strip().lower()
|
||||
if purge_type not in {"url", "path"}:
|
||||
raise ValueError("purgeType must be url or path")
|
||||
return purge_type
|
||||
|
||||
|
||||
def normalize_cdn_area(value: Any, *, allow_global: bool = False) -> str:
|
||||
# 刷新提交只接受境内/境外;记录筛选额外兼容 global。
|
||||
area = str(value or "").strip().lower()
|
||||
if not area:
|
||||
return ""
|
||||
|
||||
allowed = {"mainland", "overseas"}
|
||||
if allow_global:
|
||||
allowed.add("global")
|
||||
if area not in allowed:
|
||||
raise ValueError(f"area must be one of: {', '.join(sorted(allowed))}")
|
||||
return area
|
||||
|
||||
|
||||
def normalize_cdn_flush_type(purge_type: str, value: Any) -> str:
|
||||
# URL 刷新没有 flushType;目录刷新默认走更保守的 flush。
|
||||
flush_type = str(value or "flush").strip().lower()
|
||||
if purge_type == "url":
|
||||
return ""
|
||||
if flush_type not in {"flush", "delete"}:
|
||||
raise ValueError("flushType must be flush or delete")
|
||||
return flush_type
|
||||
|
||||
|
||||
def normalize_cdn_items(raw_items: Any) -> list[str]:
|
||||
# 前端可直接传数组;兜底兼容换行文本,方便后续脚本接口复用。
|
||||
if isinstance(raw_items, list):
|
||||
candidates = raw_items
|
||||
elif isinstance(raw_items, str):
|
||||
candidates = raw_items.splitlines()
|
||||
else:
|
||||
raise ValueError("items must be a list or string")
|
||||
|
||||
items: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in candidates:
|
||||
text = str(item or "").strip()
|
||||
if not text or text in seen:
|
||||
continue
|
||||
seen.add(text)
|
||||
items.append(text)
|
||||
|
||||
if not items:
|
||||
raise ValueError("至少填写一条 URL 或目录")
|
||||
return items
|
||||
|
||||
|
||||
def normalize_cdn_item(item: str, *, purge_type: str, allowed_domains: set[str]) -> str:
|
||||
# 每条刷新目标都强制要求完整 URL,避免误把裸路径提交到错误域名。
|
||||
parsed = urlparse(item)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError(f"无效 CDN 地址: {item}")
|
||||
|
||||
hostname = str(parsed.hostname or "").strip().lower()
|
||||
if allowed_domains and hostname not in allowed_domains:
|
||||
raise ValueError(f"域名未加入 CDN_ALLOWED_DOMAINS: {hostname}")
|
||||
|
||||
if purge_type == "path":
|
||||
normalized_path = parsed.path or "/"
|
||||
if not normalized_path.endswith("/"):
|
||||
raise ValueError(f"目录刷新必须以 / 结尾: {item}")
|
||||
parsed = parsed._replace(path=normalized_path, query="", fragment="")
|
||||
else:
|
||||
parsed = parsed._replace(fragment="")
|
||||
|
||||
return urlunparse(parsed)
|
||||
|
||||
|
||||
def normalize_cdn_purge_items(raw_items: Any, *, purge_type: str) -> list[str]:
|
||||
# 去重、域名校验和目录校验统一收口,提交接口和测试都复用这套规则。
|
||||
items = normalize_cdn_items(raw_items)
|
||||
allowed_domains = normalized_cdn_allowed_domains()
|
||||
max_batch = URL_PURGE_BATCH_LIMIT if purge_type == "url" else PATH_PURGE_BATCH_LIMIT
|
||||
if len(items) > max_batch:
|
||||
raise ValueError(f"{'URL' if purge_type == 'url' else '目录'} 单次最多提交 {max_batch} 条")
|
||||
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
value = normalize_cdn_item(item, purge_type=purge_type, allowed_domains=allowed_domains)
|
||||
if value in seen:
|
||||
continue
|
||||
seen.add(value)
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_quota_rows(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
# 配额结构只保留页面展示会用到的字段,前端不需要感知腾讯云原始大小写命名。
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
rows.append(
|
||||
{
|
||||
"area": str(item.get("Area") or "").strip().lower(),
|
||||
"batch": int(item.get("Batch") or 0),
|
||||
"total": int(item.get("Total") or 0),
|
||||
"available": int(item.get("Available") or 0),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def normalize_purge_task_rows(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
# 刷新记录统一转成前端直接可表格渲染的结构,并按提交时间倒序排列。
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
rows.append(
|
||||
{
|
||||
"taskId": str(item.get("TaskId") or "").strip(),
|
||||
"url": str(item.get("Url") or "").strip(),
|
||||
"status": str(item.get("Status") or "").strip().lower(),
|
||||
"purgeType": str(item.get("PurgeType") or "").strip().lower(),
|
||||
"flushType": str(item.get("FlushType") or "").strip().lower(),
|
||||
"createTime": str(item.get("CreateTime") or "").strip(),
|
||||
}
|
||||
)
|
||||
return sorted(rows, key=lambda item: item.get("createTime") or "", reverse=True)
|
||||
|
||||
|
||||
def recent_task_query_window() -> tuple[str, str]:
|
||||
# CDN 记录查询要求传 TaskId 或时间范围;工具页默认拉最近 7 天窗口。
|
||||
end_at = datetime.now().replace(microsecond=0)
|
||||
start_at = end_at - timedelta(days=7)
|
||||
return start_at.strftime("%Y-%m-%d %H:%M:%S"), end_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def get_cdn_overview_payload() -> dict[str, Any]:
|
||||
# 通用工具页一次拿到配置状态、配额和最近刷新记录,减少前端额外往返。
|
||||
payload: dict[str, Any] = {
|
||||
"ok": False,
|
||||
"configured": False,
|
||||
"updatedAt": utc_now(),
|
||||
"error": "",
|
||||
"warnings": [],
|
||||
"settings": {
|
||||
"allowedDomains": sorted(normalized_cdn_allowed_domains()),
|
||||
"taskLimit": max(int(CDN_PURGE_TASK_LIMIT or 20), 1),
|
||||
},
|
||||
"quota": {
|
||||
"url": [],
|
||||
"path": [],
|
||||
},
|
||||
"tasks": [],
|
||||
"taskTotal": 0,
|
||||
}
|
||||
|
||||
error = cdn_configuration_error()
|
||||
if error:
|
||||
payload["error"] = error
|
||||
return payload
|
||||
|
||||
payload["configured"] = True
|
||||
|
||||
try:
|
||||
quota_payload = call_cdn("DescribePurgeQuota", {})
|
||||
payload["quota"]["url"] = normalize_quota_rows(list(quota_payload.get("UrlPurge") or []))
|
||||
payload["quota"]["path"] = normalize_quota_rows(list(quota_payload.get("PathPurge") or []))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
payload["warnings"].append(f"quota: {exc}")
|
||||
|
||||
try:
|
||||
start_time, end_time = recent_task_query_window()
|
||||
tasks_payload = call_cdn(
|
||||
"DescribePurgeTasks",
|
||||
{
|
||||
"StartTime": start_time,
|
||||
"EndTime": end_time,
|
||||
"Offset": 0,
|
||||
"Limit": max(int(CDN_PURGE_TASK_LIMIT or 20), 1),
|
||||
},
|
||||
)
|
||||
payload["tasks"] = normalize_purge_task_rows(list(tasks_payload.get("PurgeLogs") or []))
|
||||
payload["taskTotal"] = int(tasks_payload.get("TotalCount") or len(payload["tasks"]))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
payload["warnings"].append(f"tasks: {exc}")
|
||||
|
||||
payload["ok"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def submit_cdn_purge_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
# 刷新任务提交前先做本地校验,把明显错误拦在页面内。
|
||||
error = cdn_configuration_error()
|
||||
if error:
|
||||
raise RuntimeError(error)
|
||||
|
||||
purge_type = normalize_cdn_purge_type(payload.get("purgeType"))
|
||||
items = normalize_cdn_purge_items(payload.get("items"), purge_type=purge_type)
|
||||
area = normalize_cdn_area(payload.get("area"))
|
||||
flush_type = normalize_cdn_flush_type(purge_type, payload.get("flushType"))
|
||||
url_encode = bool(payload.get("urlEncode"))
|
||||
|
||||
request_payload: dict[str, Any] = {"UrlEncode": url_encode}
|
||||
if area:
|
||||
request_payload["Area"] = area
|
||||
|
||||
if purge_type == "url":
|
||||
request_payload["Urls"] = items
|
||||
response = call_cdn("PurgeUrlsCache", request_payload)
|
||||
else:
|
||||
request_payload["Paths"] = items
|
||||
request_payload["FlushType"] = flush_type or "flush"
|
||||
response = call_cdn("PurgePathCache", request_payload)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"taskId": str(response.get("TaskId") or "").strip(),
|
||||
"requestId": str(response.get("RequestId") or "").strip(),
|
||||
"purgeType": purge_type,
|
||||
"area": area,
|
||||
"flushType": flush_type,
|
||||
"urlEncode": url_encode,
|
||||
"itemCount": len(items),
|
||||
"items": items,
|
||||
"createdAt": utc_now(),
|
||||
}
|
||||
@ -3896,6 +3896,41 @@ body.auth-page {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.common-cdn-grid {
|
||||
align-items: start;
|
||||
grid-template-columns: minmax(0, 360px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.common-cdn-summary {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.common-cdn-textarea-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.common-cdn-textarea {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.common-cdn-url {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.common-cdn-task-panel .table-wrap {
|
||||
max-height: 520px;
|
||||
}
|
||||
|
||||
.common-cdn-domain-strip,
|
||||
.common-cdn-result-strip {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mongo-panel-grid.narrow {
|
||||
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
|
||||
}
|
||||
@ -4584,6 +4619,7 @@ body.auth-page {
|
||||
}
|
||||
|
||||
.mongo-panel-grid,
|
||||
.common-cdn-grid,
|
||||
.mongo-query-grid,
|
||||
.mysql-filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@ -4727,6 +4763,7 @@ body.auth-page {
|
||||
.config-meta-grid,
|
||||
.update-form-grid,
|
||||
.mongo-panel-grid,
|
||||
.common-cdn-grid,
|
||||
.mongo-query-grid,
|
||||
.mysql-filter-grid,
|
||||
.mysql-checkbox-grid,
|
||||
|
||||
347
static/app.js
347
static/app.js
@ -3,7 +3,8 @@ const { createApp } = Vue;
|
||||
const APP_TEMPLATE = document.getElementById("app")?.innerHTML || "";
|
||||
const APP_CONTEXTS = [
|
||||
{ id: "yumi", basePath: "/yumi", mode: "suite", title: "HY App Monitor" },
|
||||
{ id: "mysql", basePath: "/mysql", mode: "mysql", title: "HY App Monitor MySQL" },
|
||||
{ id: "common", basePath: "/common", mode: "common", title: "HY App Monitor 通用" },
|
||||
{ id: "mysql", basePath: "/mysql", mode: "common", title: "HY App Monitor 通用" },
|
||||
];
|
||||
const STORAGE_KEY = "hy-app-monitor-refresh-interval";
|
||||
const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation";
|
||||
@ -34,8 +35,12 @@ const DATABASE_ENGINES = [
|
||||
{ id: "mysql", label: "MySQL" },
|
||||
{ id: "mongo", label: "MongoDB" },
|
||||
];
|
||||
const MYSQL_MODULE_VIEW_TABS = [
|
||||
{ id: "database", label: "MySQL" },
|
||||
const COMMON_MODULE_VIEW_TABS = [
|
||||
{ id: "common", label: "通用" },
|
||||
];
|
||||
const COMMON_TOOL_TABS = [
|
||||
{ id: "mysql", label: "MySQL" },
|
||||
{ id: "cdn", label: "CDN 刷新" },
|
||||
];
|
||||
const PRIMARY_FILTERS = [
|
||||
{ id: "all", label: "全部" },
|
||||
@ -354,19 +359,59 @@ function createMySqlAdminState() {
|
||||
};
|
||||
}
|
||||
|
||||
function createCdnAdminState() {
|
||||
return {
|
||||
loaded: false,
|
||||
loading: false,
|
||||
submitting: false,
|
||||
error: "",
|
||||
message: "",
|
||||
messageTone: "neutral",
|
||||
keyword: "",
|
||||
overview: {
|
||||
ok: false,
|
||||
configured: false,
|
||||
updatedAt: "",
|
||||
error: "",
|
||||
warnings: [],
|
||||
settings: {
|
||||
allowedDomains: [],
|
||||
taskLimit: 20,
|
||||
},
|
||||
quota: {
|
||||
url: [],
|
||||
path: [],
|
||||
},
|
||||
tasks: [],
|
||||
taskTotal: 0,
|
||||
},
|
||||
form: {
|
||||
purgeType: "url",
|
||||
area: "",
|
||||
flushType: "flush",
|
||||
urlEncode: false,
|
||||
itemsText: "",
|
||||
},
|
||||
lastResult: null,
|
||||
};
|
||||
}
|
||||
|
||||
createApp({
|
||||
template: APP_TEMPLATE,
|
||||
data() {
|
||||
const isCommonMode = CURRENT_APP_CONTEXT.mode === "common";
|
||||
return {
|
||||
activeView: CURRENT_APP_CONTEXT.mode === "mysql" ? "database" : "overview",
|
||||
databaseEngine: CURRENT_APP_CONTEXT.mode === "mysql" ? "mysql" : "mongo",
|
||||
activeView: isCommonMode ? "common" : "overview",
|
||||
databaseEngine: isCommonMode ? "mysql" : "mongo",
|
||||
commonTool: "mysql",
|
||||
densityMode: "compact",
|
||||
detailPanelOpen: false,
|
||||
overviewHealthyHostsOpen: false,
|
||||
viewTabs: CURRENT_APP_CONTEXT.mode === "mysql" ? MYSQL_MODULE_VIEW_TABS : VIEW_TABS,
|
||||
databaseEngines: CURRENT_APP_CONTEXT.mode === "mysql"
|
||||
viewTabs: isCommonMode ? COMMON_MODULE_VIEW_TABS : VIEW_TABS,
|
||||
databaseEngines: isCommonMode
|
||||
? DATABASE_ENGINES.filter((engine) => engine.id === "mysql")
|
||||
: DATABASE_ENGINES.filter((engine) => engine.id === "mongo"),
|
||||
commonToolTabs: COMMON_TOOL_TABS,
|
||||
primaryFilters: PRIMARY_FILTERS,
|
||||
releaseSections: RELEASE_SECTIONS,
|
||||
configTabs: CONFIG_TABS,
|
||||
@ -506,6 +551,7 @@ createApp({
|
||||
},
|
||||
mongoAdmin: createMongoAdminState(),
|
||||
mySqlAdmin: createMySqlAdminState(),
|
||||
cdnAdmin: createCdnAdminState(),
|
||||
updateOps: {
|
||||
loading: false,
|
||||
loaded: false,
|
||||
@ -586,6 +632,11 @@ createApp({
|
||||
return this.payload.settings?.thresholds || {};
|
||||
},
|
||||
formattedUpdatedAt() {
|
||||
if (CURRENT_APP_CONTEXT.mode === "common") {
|
||||
return this.formatDateTime(
|
||||
this.cdnAdmin.overview.updatedAt || this.mySqlAdmin.overview.updatedAt || "",
|
||||
);
|
||||
}
|
||||
return this.formatDateTime(this.payload.updatedAt);
|
||||
},
|
||||
hasRenderableData() {
|
||||
@ -599,6 +650,8 @@ createApp({
|
||||
|| (this.goConfig.hosts || []).length > 0
|
||||
|| (this.updateOps.items || []).length > 0
|
||||
|| (this.restartOps.items || []).length > 0
|
||||
|| this.mySqlAdmin.loaded
|
||||
|| this.cdnAdmin.loaded
|
||||
);
|
||||
},
|
||||
showLoadingSkeleton() {
|
||||
@ -1068,6 +1121,13 @@ createApp({
|
||||
activeMySqlAdminTabLabel() {
|
||||
return this.mySqlAdminTabs.find((item) => item.id === this.mySqlAdmin.activeTab)?.label || "";
|
||||
},
|
||||
showDatabaseWorkbench() {
|
||||
return this.activeView === "database" || (this.activeView === "common" && this.commonTool === "mysql");
|
||||
},
|
||||
showMySqlWorkbench() {
|
||||
return (this.activeView === "database" && this.databaseEngine === "mysql")
|
||||
|| (this.activeView === "common" && this.commonTool === "mysql");
|
||||
},
|
||||
activeDatabaseTabs() {
|
||||
return this.databaseEngine === "mongo" ? this.mongoAdminTabs : this.mySqlAdminTabs;
|
||||
},
|
||||
@ -1234,6 +1294,75 @@ createApp({
|
||||
},
|
||||
];
|
||||
},
|
||||
cdnAdminKeyword() {
|
||||
return this.cdnAdmin.keyword.trim().toLowerCase();
|
||||
},
|
||||
cdnOverviewStatusTone() {
|
||||
if (this.cdnAdmin.error || this.cdnAdmin.overview.error) {
|
||||
return "bad";
|
||||
}
|
||||
if ((this.cdnAdmin.overview.warnings || []).length > 0) {
|
||||
return "warn";
|
||||
}
|
||||
return this.cdnAdmin.loaded ? "ok" : "neutral";
|
||||
},
|
||||
cdnOverviewStatusText() {
|
||||
if (this.cdnAdmin.error || this.cdnAdmin.overview.error) {
|
||||
return "未配置";
|
||||
}
|
||||
if ((this.cdnAdmin.overview.warnings || []).length > 0) {
|
||||
return "部分异常";
|
||||
}
|
||||
return this.cdnAdmin.loaded ? "可用" : "待加载";
|
||||
},
|
||||
cdnAllowedDomains() {
|
||||
return this.cdnAdmin.overview.settings?.allowedDomains || [];
|
||||
},
|
||||
cdnFormItemCount() {
|
||||
return this.parseMultilineItems(this.cdnAdmin.form.itemsText).length;
|
||||
},
|
||||
cdnFormPlaceholder() {
|
||||
if (this.cdnAdmin.form.purgeType === "path") {
|
||||
return "https://h5.haiyihy.com/static/\nhttps://h5.haiyihy.com/assets/";
|
||||
}
|
||||
return "https://h5.haiyihy.com/index.html\nhttps://h5.haiyihy.com/assets/app.js";
|
||||
},
|
||||
cdnQuotaCards() {
|
||||
const rows = [
|
||||
...(this.cdnAdmin.overview.quota?.url || []).map((item) => ({ ...item, kind: "url" })),
|
||||
...(this.cdnAdmin.overview.quota?.path || []).map((item) => ({ ...item, kind: "path" })),
|
||||
];
|
||||
|
||||
return rows.map((item) => ({
|
||||
key: `${item.kind}-${item.area || "default"}`,
|
||||
label: `${item.kind === "url" ? "URL" : "目录"} / ${this.cdnAreaLabel(item.area || "auto")}`,
|
||||
value: `${this.formatNumber(item.available)} / ${this.formatNumber(item.total)}`,
|
||||
meta: `单次 ${this.formatNumber(item.batch)}`,
|
||||
tone: Number(item.available || 0) > 0 ? "neutral" : "warn",
|
||||
}));
|
||||
},
|
||||
filteredCdnTasks() {
|
||||
const keyword = this.cdnAdminKeyword;
|
||||
return (this.cdnAdmin.overview.tasks || []).filter((item) => (
|
||||
!keyword
|
||||
|| [
|
||||
item.taskId,
|
||||
item.url,
|
||||
item.status,
|
||||
item.purgeType,
|
||||
item.flushType,
|
||||
item.createTime,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword)
|
||||
));
|
||||
},
|
||||
commonToolContextLabel() {
|
||||
return this.commonTool === "mysql"
|
||||
? "MySQL / 数据 / 写入 / 结构 / SQL"
|
||||
: "CDN / URL 刷新 / 目录刷新 / 最近记录";
|
||||
},
|
||||
mySqlSqlShortcuts() {
|
||||
const tableName = this.selectedMySqlTableItem?.name || "";
|
||||
const quotedTableName = tableName ? this.quoteMySqlIdentifier(tableName) : "";
|
||||
@ -1951,6 +2080,9 @@ createApp({
|
||||
if (nextValue === "services") {
|
||||
this.detailPanelOpen = true;
|
||||
}
|
||||
if (nextValue === "common") {
|
||||
this.ensureCommonWorkbenchLoaded();
|
||||
}
|
||||
if (nextValue === "database") {
|
||||
this.ensureDatabaseWorkbenchLoaded();
|
||||
}
|
||||
@ -1972,8 +2104,25 @@ createApp({
|
||||
this.ensureDatabaseWorkbenchLoaded();
|
||||
}
|
||||
},
|
||||
commonTool() {
|
||||
if (this.activeView === "common") {
|
||||
this.ensureCommonWorkbenchLoaded();
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async ensureCommonWorkbenchLoaded(tool = this.commonTool) {
|
||||
if (tool === "mysql") {
|
||||
if (!this.mySqlAdmin.loaded && !this.mySqlAdmin.loading) {
|
||||
await this.loadMySqlWorkbench();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.cdnAdmin.loaded && !this.cdnAdmin.loading) {
|
||||
await this.refreshCdnOverview({ silent: true });
|
||||
}
|
||||
},
|
||||
async ensureReleaseWorkbenchLoaded({ force = false } = {}) {
|
||||
const shouldRefreshUpdate = force
|
||||
|| ((!this.updateOps.loaded || this.updateOps.items.length === 0 || Boolean(this.updateOps.error))
|
||||
@ -2016,6 +2165,15 @@ createApp({
|
||||
await this.ensureDatabaseWorkbenchLoaded(engine);
|
||||
}
|
||||
},
|
||||
async switchCommonTool(tool) {
|
||||
if (!tool || this.commonTool === tool) {
|
||||
return;
|
||||
}
|
||||
this.commonTool = tool;
|
||||
if (this.activeView === "common") {
|
||||
await this.ensureCommonWorkbenchLoaded(tool);
|
||||
}
|
||||
},
|
||||
async refreshDatabaseTree() {
|
||||
if (this.databaseEngine === "mongo") {
|
||||
await this.refreshMongoAdminOverview({ preserveSelection: true });
|
||||
@ -3249,8 +3407,9 @@ createApp({
|
||||
return;
|
||||
}
|
||||
|
||||
if (CURRENT_APP_CONTEXT.mode === "mysql") {
|
||||
if (CURRENT_APP_CONTEXT.mode === "common") {
|
||||
await this.refresh();
|
||||
this.applyRefreshTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -3348,9 +3507,25 @@ createApp({
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async refreshCommonModule() {
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
this.mySqlAdmin.error = "";
|
||||
this.cdnAdmin.error = "";
|
||||
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
this.refreshMySqlDatabases({ preserveSelection: true, silent: true }),
|
||||
this.refreshCdnOverview({ silent: true }),
|
||||
]);
|
||||
this.error = this.mySqlAdmin.error || this.cdnAdmin.error || "";
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async refresh() {
|
||||
if (CURRENT_APP_CONTEXT.mode === "mysql") {
|
||||
await this.refreshMySqlModule();
|
||||
if (CURRENT_APP_CONTEXT.mode === "common") {
|
||||
await this.refreshCommonModule();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -3633,6 +3808,158 @@ createApp({
|
||||
this.mySqlAdmin.message = message || "";
|
||||
this.mySqlAdmin.messageTone = tone || "neutral";
|
||||
},
|
||||
setCdnAdminMessage(message, tone = "neutral") {
|
||||
this.cdnAdmin.message = message || "";
|
||||
this.cdnAdmin.messageTone = tone || "neutral";
|
||||
},
|
||||
parseMultilineItems(text) {
|
||||
return String(text || "")
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
},
|
||||
cdnAreaLabel(area) {
|
||||
if (area === "mainland") {
|
||||
return "境内";
|
||||
}
|
||||
if (area === "overseas") {
|
||||
return "境外";
|
||||
}
|
||||
if (area === "global") {
|
||||
return "全球";
|
||||
}
|
||||
return "自动";
|
||||
},
|
||||
cdnTaskStatusLabel(status) {
|
||||
if (status === "done") {
|
||||
return "成功";
|
||||
}
|
||||
if (status === "process") {
|
||||
return "进行中";
|
||||
}
|
||||
if (status === "fail") {
|
||||
return "失败";
|
||||
}
|
||||
return status || "-";
|
||||
},
|
||||
cdnTaskStatusTone(status) {
|
||||
if (status === "done") {
|
||||
return "ok";
|
||||
}
|
||||
if (status === "process") {
|
||||
return "warn";
|
||||
}
|
||||
if (status === "fail") {
|
||||
return "bad";
|
||||
}
|
||||
return "neutral";
|
||||
},
|
||||
cdnPurgeTypeLabel(purgeType) {
|
||||
return purgeType === "path" ? "目录" : "URL";
|
||||
},
|
||||
cdnFlushTypeLabel(flushType) {
|
||||
if (flushType === "delete") {
|
||||
return "delete";
|
||||
}
|
||||
if (flushType === "flush") {
|
||||
return "flush";
|
||||
}
|
||||
return "-";
|
||||
},
|
||||
async refreshCdnOverview({ silent = false } = {}) {
|
||||
this.cdnAdmin.loading = true;
|
||||
if (!silent) {
|
||||
this.cdnAdmin.error = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, payload } = await this.requestJson("/api/cdn/overview");
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
this.cdnAdmin.overview = {
|
||||
...createCdnAdminState().overview,
|
||||
...payload,
|
||||
settings: {
|
||||
...createCdnAdminState().overview.settings,
|
||||
...(payload.settings || {}),
|
||||
},
|
||||
quota: {
|
||||
url: Array.isArray(payload.quota?.url) ? payload.quota.url : [],
|
||||
path: Array.isArray(payload.quota?.path) ? payload.quota.path : [],
|
||||
},
|
||||
tasks: Array.isArray(payload.tasks) ? payload.tasks : [],
|
||||
};
|
||||
this.cdnAdmin.loaded = true;
|
||||
this.cdnAdmin.error = payload.error || "";
|
||||
|
||||
if (!silent) {
|
||||
if (payload.error) {
|
||||
this.setCdnAdminMessage(this.explainError(payload.error), "warn");
|
||||
} else if ((payload.warnings || []).length > 0) {
|
||||
this.setCdnAdminMessage("CDN 记录已刷新,部分接口异常", "warn");
|
||||
} else {
|
||||
this.setCdnAdminMessage("已刷新 CDN 记录", "neutral");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.cdnAdmin.error = error instanceof Error ? error.message : String(error);
|
||||
if (!silent) {
|
||||
this.setCdnAdminMessage(this.explainError(this.cdnAdmin.error), "warn");
|
||||
}
|
||||
} finally {
|
||||
this.cdnAdmin.loading = false;
|
||||
}
|
||||
},
|
||||
clearCdnForm() {
|
||||
this.cdnAdmin.form.itemsText = "";
|
||||
this.cdnAdmin.form.area = "";
|
||||
this.cdnAdmin.form.flushType = "flush";
|
||||
this.cdnAdmin.form.urlEncode = false;
|
||||
},
|
||||
async submitCdnPurge() {
|
||||
if (this.cdnAdmin.submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = this.parseMultilineItems(this.cdnAdmin.form.itemsText);
|
||||
if (items.length === 0) {
|
||||
this.setCdnAdminMessage("至少填写一条 URL 或目录", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
this.cdnAdmin.submitting = true;
|
||||
this.cdnAdmin.error = "";
|
||||
|
||||
try {
|
||||
const { response, payload } = await this.requestJson("/api/cdn/purge", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
purgeType: this.cdnAdmin.form.purgeType,
|
||||
items,
|
||||
area: this.cdnAdmin.form.area,
|
||||
flushType: this.cdnAdmin.form.flushType,
|
||||
urlEncode: this.cdnAdmin.form.urlEncode,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
this.cdnAdmin.lastResult = payload;
|
||||
this.setCdnAdminMessage(`已提交 ${payload.itemCount || items.length} 条刷新`, "ok");
|
||||
await this.refreshCdnOverview({ silent: true });
|
||||
} catch (error) {
|
||||
this.cdnAdmin.error = error instanceof Error ? error.message : String(error);
|
||||
this.setCdnAdminMessage(this.explainError(this.cdnAdmin.error), "warn");
|
||||
} finally {
|
||||
this.cdnAdmin.submitting = false;
|
||||
}
|
||||
},
|
||||
clearMySqlSchemaState() {
|
||||
this.mySqlAdmin.schema = {
|
||||
updatedAt: "",
|
||||
|
||||
@ -207,6 +207,51 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeView === 'common'">
|
||||
<section class="control-bar task-control-bar database-control-bar common-control-bar">
|
||||
<div class="taskbar-context mongo-taskbar-context">
|
||||
<strong>通用工作台</strong>
|
||||
<span>{{ commonToolContextLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group common-tool-switch">
|
||||
<button
|
||||
v-for="tool in commonToolTabs"
|
||||
:key="tool.id"
|
||||
:class="['filter-chip', commonTool === tool.id ? 'active' : '']"
|
||||
@click="switchCommonTool(tool.id)"
|
||||
>
|
||||
{{ tool.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="control-group control-group-spread">
|
||||
<template v-if="commonTool === 'mysql'">
|
||||
<input v-model.trim="mySqlAdmin.keyword" class="search" placeholder="搜索数据库 / 表" />
|
||||
<button class="refresh ghost" @click="refreshMySqlDatabases({ preserveSelection: true })" :disabled="mySqlAdmin.loading">
|
||||
{{ mySqlAdmin.loading ? '刷新中...' : '刷新树' }}
|
||||
</button>
|
||||
<button
|
||||
class="refresh"
|
||||
@click="refreshMySqlCurrent"
|
||||
:disabled="!mySqlAdmin.selectedDatabase || databaseRefreshBusy"
|
||||
>
|
||||
{{ databaseRefreshBusy ? '加载中...' : databaseRefreshLabel }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input v-model.trim="cdnAdmin.keyword" class="search" placeholder="搜索 taskId / URL / 状态" />
|
||||
<button class="refresh ghost" @click="refreshCdnOverview()" :disabled="cdnAdmin.loading">
|
||||
{{ cdnAdmin.loading ? '刷新中...' : '刷新记录' }}
|
||||
</button>
|
||||
<button class="refresh" @click="submitCdnPurge" :disabled="cdnAdmin.submitting || cdnFormItemCount === 0">
|
||||
{{ cdnAdmin.submitting ? '提交中...' : '执行刷新' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeView === 'config'">
|
||||
<section class="control-bar task-control-bar">
|
||||
<div class="taskbar-context">
|
||||
@ -1298,8 +1343,8 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="activeView === 'database'">
|
||||
<section v-if="databaseEngine === 'mysql'" class="panel infra-panel database-workbench-panel mysql-workbench-panel">
|
||||
<template v-if="showDatabaseWorkbench">
|
||||
<section v-if="showMySqlWorkbench" class="panel infra-panel database-workbench-panel mysql-workbench-panel">
|
||||
<div class="compact-workbench-head mysql-workbench-head">
|
||||
<div class="metadata-strip metadata-strip-tight">
|
||||
<span class="mono">{{ truncateMiddle(mySqlAdmin.overview.info.host || '未配置 MySQL', 20, 12) }}</span>
|
||||
@ -2709,6 +2754,151 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="activeView === 'common' && commonTool === 'cdn'">
|
||||
<section class="panel infra-panel common-cdn-panel">
|
||||
<div class="compact-workbench-head common-workbench-head">
|
||||
<div class="metadata-strip metadata-strip-tight">
|
||||
<span class="mono">{{ cdnAllowedDomains[0] || '未配置域名' }}</span>
|
||||
<span>限制域名 {{ formatNumber(cdnAllowedDomains.length) }}</span>
|
||||
<span>记录 {{ formatNumber(cdnAdmin.overview.taskTotal) }}</span>
|
||||
<span>更新时间 {{ formatDateTime(cdnAdmin.overview.updatedAt) }}</span>
|
||||
</div>
|
||||
<span :class="['badge', cdnOverviewStatusTone]">{{ cdnOverviewStatusText }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="cdnAdmin.error || cdnAdmin.overview.error" class="metric-error">{{ explainError(cdnAdmin.error || cdnAdmin.overview.error) }}</p>
|
||||
<p v-else-if="(cdnAdmin.overview.warnings || []).length > 0" class="metric-error warn">{{ explainError(cdnAdmin.overview.warnings[0]) }}</p>
|
||||
|
||||
<div class="summary-cards common-cdn-summary">
|
||||
<article v-for="card in cdnQuotaCards" :key="'cdn-quota-' + card.key" :class="['summary-card', `tone-${card.tone}`]">
|
||||
<small>{{ card.label }}</small>
|
||||
<strong>{{ card.value }}</strong>
|
||||
<span>{{ card.meta }}</span>
|
||||
</article>
|
||||
<article v-if="cdnQuotaCards.length === 0" class="summary-card tone-neutral">
|
||||
<small>CDN 配额</small>
|
||||
<strong>-</strong>
|
||||
<span>当前没有可展示配额</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="mongo-panel-grid common-cdn-grid">
|
||||
<article class="mongo-action-panel common-cdn-form-panel tone-write">
|
||||
<header class="panel-header">
|
||||
<div class="panel-title">
|
||||
<h2>刷新提交</h2>
|
||||
<p>仅支持 https://h5.haiyihy.com/</p>
|
||||
</div>
|
||||
<span class="badge neutral">{{ formatNumber(cdnFormItemCount) }} 条</span>
|
||||
</header>
|
||||
|
||||
<div class="metadata-strip metadata-strip-tight common-cdn-domain-strip">
|
||||
<span class="mono">https://h5.haiyihy.com/</span>
|
||||
<span>域名白名单校验已启用</span>
|
||||
</div>
|
||||
|
||||
<label class="update-field">
|
||||
<span>刷新类型</span>
|
||||
<select v-model="cdnAdmin.form.purgeType" class="interval-select">
|
||||
<option value="url">URL</option>
|
||||
<option value="path">目录</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="update-field">
|
||||
<span>区域</span>
|
||||
<select v-model="cdnAdmin.form.area" class="interval-select">
|
||||
<option value="">自动</option>
|
||||
<option value="mainland">境内</option>
|
||||
<option value="overseas">境外</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label v-if="cdnAdmin.form.purgeType === 'path'" class="update-field">
|
||||
<span>目录模式</span>
|
||||
<select v-model="cdnAdmin.form.flushType" class="interval-select">
|
||||
<option value="flush">flush</option>
|
||||
<option value="delete">delete</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="table-check">
|
||||
<input v-model="cdnAdmin.form.urlEncode" type="checkbox" />
|
||||
<span>中文路径自动编码</span>
|
||||
</label>
|
||||
|
||||
<label class="update-field common-cdn-textarea-field">
|
||||
<span>URL / 目录</span>
|
||||
<textarea
|
||||
v-model="cdnAdmin.form.itemsText"
|
||||
class="config-textarea mongo-editor-textarea mono common-cdn-textarea"
|
||||
:placeholder="cdnFormPlaceholder"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div v-if="cdnAdmin.lastResult" class="metadata-strip metadata-strip-tight common-cdn-result-strip">
|
||||
<span class="mono">task {{ cdnAdmin.lastResult.taskId || '-' }}</span>
|
||||
<span>{{ cdnPurgeTypeLabel(cdnAdmin.lastResult.purgeType) }}</span>
|
||||
<span>{{ cdnAdmin.lastResult.flushType ? cdnFlushTypeLabel(cdnAdmin.lastResult.flushType) : '-' }}</span>
|
||||
<span>{{ formatDateTime(cdnAdmin.lastResult.createdAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="action-group">
|
||||
<button class="refresh ghost" @click="clearCdnForm" :disabled="cdnAdmin.submitting">清空</button>
|
||||
<button class="refresh ghost" @click="refreshCdnOverview()" :disabled="cdnAdmin.loading">刷新记录</button>
|
||||
<button class="refresh" @click="submitCdnPurge" :disabled="cdnAdmin.submitting || cdnFormItemCount === 0">
|
||||
{{ cdnAdmin.submitting ? '提交中...' : '提交刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="mongo-action-panel common-cdn-task-panel tone-edit">
|
||||
<header class="panel-header">
|
||||
<div class="panel-title">
|
||||
<h2>最近记录</h2>
|
||||
<p>{{ formatNumber(filteredCdnTasks.length) }} 条</p>
|
||||
</div>
|
||||
<span v-if="cdnAdmin.message" :class="['badge', cdnAdmin.messageTone === 'ok' ? 'ok' : cdnAdmin.messageTone === 'warn' ? 'warn' : 'neutral']">
|
||||
{{ cdnAdmin.message }}
|
||||
</span>
|
||||
<span v-else class="badge neutral">最近 7 天</span>
|
||||
</header>
|
||||
|
||||
<div class="table-wrap table-scroll-x">
|
||||
<table class="table compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>URL / 目录</th>
|
||||
<th>状态</th>
|
||||
<th>类型</th>
|
||||
<th>模式</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in filteredCdnTasks" :key="'cdn-task-' + task.taskId + '-' + task.url">
|
||||
<td class="mono">{{ truncateMiddle(task.taskId, 14, 6) }}</td>
|
||||
<td class="mono common-cdn-url" :title="task.url">{{ truncateMiddle(task.url, 48, 16) }}</td>
|
||||
<td>
|
||||
<span :class="['badge', cdnTaskStatusTone(task.status)]">{{ cdnTaskStatusLabel(task.status) }}</span>
|
||||
</td>
|
||||
<td>{{ cdnPurgeTypeLabel(task.purgeType) }}</td>
|
||||
<td>{{ task.purgeType === 'path' ? cdnFlushTypeLabel(task.flushType) : '-' }}</td>
|
||||
<td>{{ formatDateTime(task.createTime) }}</td>
|
||||
</tr>
|
||||
<tr v-if="filteredCdnTasks.length === 0">
|
||||
<td colspan="6" class="table-empty-cell">当前没有匹配的刷新记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="!showLoadingSkeleton && activeView === 'config'">
|
||||
<section v-if="configTab === 'nacos'" class="panel infra-panel config-workbench-panel">
|
||||
<div class="compact-workbench-head config-head-strip">
|
||||
|
||||
@ -33,9 +33,9 @@
|
||||
<strong>Yumi</strong>
|
||||
<span>监控 / 配置 / 发布</span>
|
||||
</a>
|
||||
<a class="entry-card" href="/mysql/">
|
||||
<strong>MySQL</strong>
|
||||
<span>数据库工作台</span>
|
||||
<a class="entry-card" href="/common/">
|
||||
<strong>通用</strong>
|
||||
<span>MySQL / CDN 刷新</span>
|
||||
</a>
|
||||
<a class="entry-card" href="/haiyi/">
|
||||
<strong>HaiYi</strong>
|
||||
|
||||
117
tests/test_cdn_refresh.py
Normal file
117
tests/test_cdn_refresh.py
Normal file
@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from monitors.yumi.tencent_cdn import get_cdn_overview_payload, submit_cdn_purge_payload
|
||||
|
||||
|
||||
class CdnRefreshTests(unittest.TestCase):
|
||||
@patch("monitors.yumi.tencent_cdn.CDN_PURGE_TASK_LIMIT", 5)
|
||||
@patch("monitors.yumi.tencent_cdn.call_cdn")
|
||||
@patch("monitors.yumi.tencent_cdn.cdn_configuration_error", return_value="")
|
||||
def test_overview_combines_quota_and_recent_tasks(self, config_error_mock, call_cdn_mock) -> None:
|
||||
del config_error_mock
|
||||
|
||||
def side_effect(method_name: str, payload: dict) -> dict:
|
||||
if method_name == "DescribePurgeQuota":
|
||||
self.assertEqual(payload, {})
|
||||
return {
|
||||
"UrlPurge": [
|
||||
{"Area": "mainland", "Batch": 1000, "Total": 10000, "Available": 9999},
|
||||
],
|
||||
"PathPurge": [
|
||||
{"Area": "overseas", "Batch": 500, "Total": 2000, "Available": 1800},
|
||||
],
|
||||
}
|
||||
if method_name == "DescribePurgeTasks":
|
||||
self.assertEqual(payload["Limit"], 5)
|
||||
return {
|
||||
"TotalCount": 1,
|
||||
"PurgeLogs": [
|
||||
{
|
||||
"TaskId": "task-1",
|
||||
"Url": "https://h5.haiyihy.com/index.html",
|
||||
"Status": "done",
|
||||
"PurgeType": "url",
|
||||
"FlushType": "",
|
||||
"CreateTime": "2026-04-23 12:00:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
raise AssertionError(f"unexpected method: {method_name}")
|
||||
|
||||
call_cdn_mock.side_effect = side_effect
|
||||
|
||||
payload = get_cdn_overview_payload()
|
||||
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertTrue(payload["configured"])
|
||||
self.assertEqual(payload["quota"]["url"][0]["area"], "mainland")
|
||||
self.assertEqual(payload["quota"]["path"][0]["available"], 1800)
|
||||
self.assertEqual(payload["tasks"][0]["taskId"], "task-1")
|
||||
self.assertEqual(payload["taskTotal"], 1)
|
||||
|
||||
@patch("monitors.yumi.tencent_cdn.CDN_ALLOWED_DOMAINS", ("h5.haiyihy.com",))
|
||||
@patch("monitors.yumi.tencent_cdn.call_cdn", return_value={"TaskId": "task-2", "RequestId": "req-2"})
|
||||
@patch("monitors.yumi.tencent_cdn.cdn_configuration_error", return_value="")
|
||||
def test_submit_url_refresh_uses_allowlisted_domain(
|
||||
self,
|
||||
config_error_mock,
|
||||
call_cdn_mock,
|
||||
) -> None:
|
||||
del config_error_mock
|
||||
|
||||
payload = submit_cdn_purge_payload(
|
||||
{
|
||||
"purgeType": "url",
|
||||
"items": [
|
||||
"https://h5.haiyihy.com/index.html",
|
||||
"https://h5.haiyihy.com/index.html",
|
||||
],
|
||||
"area": "mainland",
|
||||
"urlEncode": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertEqual(payload["taskId"], "task-2")
|
||||
self.assertEqual(payload["itemCount"], 1)
|
||||
call_cdn_mock.assert_called_once_with(
|
||||
"PurgeUrlsCache",
|
||||
{
|
||||
"UrlEncode": True,
|
||||
"Area": "mainland",
|
||||
"Urls": ["https://h5.haiyihy.com/index.html"],
|
||||
},
|
||||
)
|
||||
|
||||
@patch("monitors.yumi.tencent_cdn.CDN_ALLOWED_DOMAINS", ("h5.haiyihy.com",))
|
||||
@patch("monitors.yumi.tencent_cdn.cdn_configuration_error", return_value="")
|
||||
def test_submit_rejects_unknown_domain(self, config_error_mock) -> None:
|
||||
del config_error_mock
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "CDN_ALLOWED_DOMAINS"):
|
||||
submit_cdn_purge_payload(
|
||||
{
|
||||
"purgeType": "url",
|
||||
"items": ["https://static.example.com/app.js"],
|
||||
}
|
||||
)
|
||||
|
||||
@patch("monitors.yumi.tencent_cdn.CDN_ALLOWED_DOMAINS", ("h5.haiyihy.com",))
|
||||
@patch("monitors.yumi.tencent_cdn.cdn_configuration_error", return_value="")
|
||||
def test_path_refresh_requires_trailing_slash(self, config_error_mock) -> None:
|
||||
del config_error_mock
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "以 / 结尾"):
|
||||
submit_cdn_purge_payload(
|
||||
{
|
||||
"purgeType": "path",
|
||||
"items": ["https://h5.haiyihy.com/assets"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -19,6 +19,19 @@ class GatewayMysqlAliasTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(rewritten, "/yumi/api/mysql/overview?refresh=1")
|
||||
|
||||
def test_match_proxy_target_returns_common_alias(self) -> None:
|
||||
target = match_proxy_target("/common/api/cdn/overview")
|
||||
|
||||
self.assertIsNotNone(target)
|
||||
self.assertEqual(target["app"], "common")
|
||||
self.assertEqual(target["basePath"], "/common")
|
||||
self.assertEqual(target["upstreamBasePath"], "/yumi")
|
||||
|
||||
def test_common_alias_rewrite_preserves_query(self) -> None:
|
||||
rewritten = rewrite_target_path("/common/api/cdn/overview?limit=20", "/common", "/yumi")
|
||||
|
||||
self.assertEqual(rewritten, "/yumi/api/cdn/overview?limit=20")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user