feat: add common tools page with CDN refresh

This commit is contained in:
hy 2026-04-23 21:46:31 +08:00
parent 3d66ed45ac
commit e1519a77f4
12 changed files with 1076 additions and 18 deletions

View File

@ -104,6 +104,8 @@ TENCENT_SECRET_KEY=
GATEWAY_CLB_LOAD_BALANCER_ID= GATEWAY_CLB_LOAD_BALANCER_ID=
GATEWAY_CLB_LISTENER_IDS= GATEWAY_CLB_LISTENER_IDS=
GATEWAY_CLB_ALLOWED_DOMAINS= GATEWAY_CLB_ALLOWED_DOMAINS=
CDN_ALLOWED_DOMAINS=h5.haiyihy.com
CDN_PURGE_TASK_LIMIT=20
GATEWAY_CLB_POLL_SECONDS=2 GATEWAY_CLB_POLL_SECONDS=2
GATEWAY_CLB_TASK_TIMEOUT_SECONDS=120 GATEWAY_CLB_TASK_TIMEOUT_SECONDS=120
GATEWAY_CLB_DRAIN_SECONDS=0 GATEWAY_CLB_DRAIN_SECONDS=0

View File

@ -6,14 +6,16 @@
- 登录入口:`http://43.164.75.199:2026/login` - 登录入口:`http://43.164.75.199:2026/login`
- 入口选择页:`http://43.164.75.199:2026/select` - 入口选择页:`http://43.164.75.199:2026/select`
- Yumi 入口:`http://43.164.75.199:2026/yumi/` - 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/` - HaiYi 入口:`http://43.164.75.199:2026/haiyi/`
- MySQL 兼容入口:`http://43.164.75.199:2026/mysql/`
- 内部实例:`gateway:2026``yumi:2027``haiyi:2028` - 内部实例:`gateway:2026``yumi:2027``haiyi:2028`
- 服务健康:直接探测各业务服务健康接口 - 服务健康:直接探测各业务服务健康接口
- 主机资源:通过 `SSH` 采集 `CPU / 内存 / 磁盘 / 进程数` - 主机资源:通过 `SSH` 采集 `CPU / 内存 / 磁盘 / 进程数`
- Nacos直连 Nacos OpenAPI列出命名空间、服务和注册实例 - Nacos直连 Nacos OpenAPI列出命名空间、服务和注册实例
- MongoDB直连 Mongo采集 `serverStatus / listDatabases / replSetGetStatus`并提供库表浏览、分页查询、CRUD、聚合、Distinct、索引管理 - MongoDB直连 Mongo采集 `serverStatus / listDatabases / replSetGetStatus`并提供库表浏览、分页查询、CRUD、聚合、Distinct、索引管理
- MySQL直连云 MySQL提供运行状态、库表浏览、100 条分页查询、CRUD、列/索引变更和 SQL 工作台 - MySQL直连云 MySQL提供运行状态、库表浏览、100 条分页查询、CRUD、列/索引变更和 SQL 工作台
- CDN直连腾讯云 CDN支持 `URL / 目录` 刷新和最近任务查询;当前限制域名 `https://h5.haiyihy.com/`
- 宿主机运维日志、滚动重启、compose 同步、服务更新统一走 `SSH` - 宿主机运维日志、滚动重启、compose 同步、服务更新统一走 `SSH`
- 带外引导:只在打通 `deploy -> target:22` 时使用 `TAT` - 带外引导:只在打通 `deploy -> target:22` 时使用 `TAT`
@ -65,6 +67,8 @@ TENCENT_SECRET_ID=***
TENCENT_SECRET_KEY=*** TENCENT_SECRET_KEY=***
GATEWAY_CLB_LOAD_BALANCER_ID=lb-xxxxxxxx GATEWAY_CLB_LOAD_BALANCER_ID=lb-xxxxxxxx
GATEWAY_CLB_LISTENER_IDS=lbl-http,lbl-https 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_POLL_SECONDS=2
GATEWAY_CLB_TASK_TIMEOUT_SECONDS=120 GATEWAY_CLB_TASK_TIMEOUT_SECONDS=120
GATEWAY_CLB_DRAIN_SECONDS=0 GATEWAY_CLB_DRAIN_SECONDS=0
@ -140,6 +144,13 @@ MYSQL_MAX_PAGE_SIZE=200
MYSQL_QUERY_MAX_ROWS=500 MYSQL_QUERY_MAX_ROWS=500
``` ```
CDN 刷新工具可选参数:
```bash
CDN_ALLOWED_DOMAINS=h5.haiyihy.com
CDN_PURGE_TASK_LIMIT=20
```
## 本地运行 ## 本地运行
```bash ```bash

View File

@ -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_CLB_LISTENER_IDS = env_str_list("GATEWAY_CLB_LISTENER_IDS", [])
# gateway 发布时只保护这些域名规则;留空表示 listener 下匹配到的规则全部处理。 # gateway 发布时只保护这些域名规则;留空表示 listener 下匹配到的规则全部处理。
GATEWAY_CLB_ALLOWED_DOMAINS = env_str_list("GATEWAY_CLB_ALLOWED_DOMAINS", []) 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 异步任务轮询间隔。 # CLB 异步任务轮询间隔。
GATEWAY_CLB_POLL_SECONDS = env_float("GATEWAY_CLB_POLL_SECONDS", 2.0) GATEWAY_CLB_POLL_SECONDS = env_float("GATEWAY_CLB_POLL_SECONDS", 2.0)
# CLB 摘流 / 恢复异步任务最长等待秒数。 # CLB 摘流 / 恢复异步任务最长等待秒数。

View File

@ -48,6 +48,13 @@ APP_PROXY_TARGETS: tuple[dict[str, Any], ...] = (
"host": YUMI_INTERNAL_HOST, "host": YUMI_INTERNAL_HOST,
"port": YUMI_INTERNAL_PORT, "port": YUMI_INTERNAL_PORT,
}, },
{
"app": "common",
"basePath": "/common",
"upstreamBasePath": "/yumi",
"host": YUMI_INTERNAL_HOST,
"port": YUMI_INTERNAL_PORT,
},
{ {
"app": "mysql", "app": "mysql",
"basePath": "/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) return self.handle_auth_session(send_body=send_body)
# favicon 直接返回空。 # favicon 直接返回空。
@ -192,7 +199,13 @@ class GatewayHandler(BaseHTTPRequestHandler):
path = parsed.path 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() return self.handle_logout()
# 登录接口只在根路径开放。 # 登录接口只在根路径开放。

View File

@ -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_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_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 .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: def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
@ -239,6 +240,20 @@ class MonitorHandler(BaseHTTPRequestHandler):
send_body=send_body, 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": if local_path == "/api/mysql/tables":
database_name = self.query_value(query, "database") database_name = self.query_value(query, "database")
@ -515,6 +530,9 @@ class MonitorHandler(BaseHTTPRequestHandler):
if local_path == "/api/mysql/sql/execute": if local_path == "/api/mysql/sql/execute":
return self.handle_mysql_action(execute_mysql_sql_payload, payload) 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": if local_path == "/api/ops/rolling-restart":
return self.handle_rolling_restart(payload) return self.handle_rolling_restart(payload)

View 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(),
}

View File

@ -3896,6 +3896,41 @@ body.auth-page {
gap: 12px; 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 { .mongo-panel-grid.narrow {
grid-template-columns: minmax(0, 220px) minmax(0, 1fr); grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
} }
@ -4584,6 +4619,7 @@ body.auth-page {
} }
.mongo-panel-grid, .mongo-panel-grid,
.common-cdn-grid,
.mongo-query-grid, .mongo-query-grid,
.mysql-filter-grid { .mysql-filter-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -4727,6 +4763,7 @@ body.auth-page {
.config-meta-grid, .config-meta-grid,
.update-form-grid, .update-form-grid,
.mongo-panel-grid, .mongo-panel-grid,
.common-cdn-grid,
.mongo-query-grid, .mongo-query-grid,
.mysql-filter-grid, .mysql-filter-grid,
.mysql-checkbox-grid, .mysql-checkbox-grid,

View File

@ -3,7 +3,8 @@ const { createApp } = Vue;
const APP_TEMPLATE = document.getElementById("app")?.innerHTML || ""; const APP_TEMPLATE = document.getElementById("app")?.innerHTML || "";
const APP_CONTEXTS = [ const APP_CONTEXTS = [
{ id: "yumi", basePath: "/yumi", mode: "suite", title: "HY App Monitor" }, { 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 STORAGE_KEY = "hy-app-monitor-refresh-interval";
const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation"; const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation";
@ -34,8 +35,12 @@ const DATABASE_ENGINES = [
{ id: "mysql", label: "MySQL" }, { id: "mysql", label: "MySQL" },
{ id: "mongo", label: "MongoDB" }, { id: "mongo", label: "MongoDB" },
]; ];
const MYSQL_MODULE_VIEW_TABS = [ const COMMON_MODULE_VIEW_TABS = [
{ id: "database", label: "MySQL" }, { id: "common", label: "通用" },
];
const COMMON_TOOL_TABS = [
{ id: "mysql", label: "MySQL" },
{ id: "cdn", label: "CDN 刷新" },
]; ];
const PRIMARY_FILTERS = [ const PRIMARY_FILTERS = [
{ id: "all", label: "全部" }, { 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({ createApp({
template: APP_TEMPLATE, template: APP_TEMPLATE,
data() { data() {
const isCommonMode = CURRENT_APP_CONTEXT.mode === "common";
return { return {
activeView: CURRENT_APP_CONTEXT.mode === "mysql" ? "database" : "overview", activeView: isCommonMode ? "common" : "overview",
databaseEngine: CURRENT_APP_CONTEXT.mode === "mysql" ? "mysql" : "mongo", databaseEngine: isCommonMode ? "mysql" : "mongo",
commonTool: "mysql",
densityMode: "compact", densityMode: "compact",
detailPanelOpen: false, detailPanelOpen: false,
overviewHealthyHostsOpen: false, overviewHealthyHostsOpen: false,
viewTabs: CURRENT_APP_CONTEXT.mode === "mysql" ? MYSQL_MODULE_VIEW_TABS : VIEW_TABS, viewTabs: isCommonMode ? COMMON_MODULE_VIEW_TABS : VIEW_TABS,
databaseEngines: CURRENT_APP_CONTEXT.mode === "mysql" databaseEngines: isCommonMode
? DATABASE_ENGINES.filter((engine) => engine.id === "mysql") ? DATABASE_ENGINES.filter((engine) => engine.id === "mysql")
: DATABASE_ENGINES.filter((engine) => engine.id === "mongo"), : DATABASE_ENGINES.filter((engine) => engine.id === "mongo"),
commonToolTabs: COMMON_TOOL_TABS,
primaryFilters: PRIMARY_FILTERS, primaryFilters: PRIMARY_FILTERS,
releaseSections: RELEASE_SECTIONS, releaseSections: RELEASE_SECTIONS,
configTabs: CONFIG_TABS, configTabs: CONFIG_TABS,
@ -506,6 +551,7 @@ createApp({
}, },
mongoAdmin: createMongoAdminState(), mongoAdmin: createMongoAdminState(),
mySqlAdmin: createMySqlAdminState(), mySqlAdmin: createMySqlAdminState(),
cdnAdmin: createCdnAdminState(),
updateOps: { updateOps: {
loading: false, loading: false,
loaded: false, loaded: false,
@ -586,6 +632,11 @@ createApp({
return this.payload.settings?.thresholds || {}; return this.payload.settings?.thresholds || {};
}, },
formattedUpdatedAt() { formattedUpdatedAt() {
if (CURRENT_APP_CONTEXT.mode === "common") {
return this.formatDateTime(
this.cdnAdmin.overview.updatedAt || this.mySqlAdmin.overview.updatedAt || "",
);
}
return this.formatDateTime(this.payload.updatedAt); return this.formatDateTime(this.payload.updatedAt);
}, },
hasRenderableData() { hasRenderableData() {
@ -599,6 +650,8 @@ createApp({
|| (this.goConfig.hosts || []).length > 0 || (this.goConfig.hosts || []).length > 0
|| (this.updateOps.items || []).length > 0 || (this.updateOps.items || []).length > 0
|| (this.restartOps.items || []).length > 0 || (this.restartOps.items || []).length > 0
|| this.mySqlAdmin.loaded
|| this.cdnAdmin.loaded
); );
}, },
showLoadingSkeleton() { showLoadingSkeleton() {
@ -1068,6 +1121,13 @@ createApp({
activeMySqlAdminTabLabel() { activeMySqlAdminTabLabel() {
return this.mySqlAdminTabs.find((item) => item.id === this.mySqlAdmin.activeTab)?.label || ""; 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() { activeDatabaseTabs() {
return this.databaseEngine === "mongo" ? this.mongoAdminTabs : this.mySqlAdminTabs; 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() { mySqlSqlShortcuts() {
const tableName = this.selectedMySqlTableItem?.name || ""; const tableName = this.selectedMySqlTableItem?.name || "";
const quotedTableName = tableName ? this.quoteMySqlIdentifier(tableName) : ""; const quotedTableName = tableName ? this.quoteMySqlIdentifier(tableName) : "";
@ -1951,6 +2080,9 @@ createApp({
if (nextValue === "services") { if (nextValue === "services") {
this.detailPanelOpen = true; this.detailPanelOpen = true;
} }
if (nextValue === "common") {
this.ensureCommonWorkbenchLoaded();
}
if (nextValue === "database") { if (nextValue === "database") {
this.ensureDatabaseWorkbenchLoaded(); this.ensureDatabaseWorkbenchLoaded();
} }
@ -1972,8 +2104,25 @@ createApp({
this.ensureDatabaseWorkbenchLoaded(); this.ensureDatabaseWorkbenchLoaded();
} }
}, },
commonTool() {
if (this.activeView === "common") {
this.ensureCommonWorkbenchLoaded();
}
},
}, },
methods: { 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 } = {}) { async ensureReleaseWorkbenchLoaded({ force = false } = {}) {
const shouldRefreshUpdate = force const shouldRefreshUpdate = force
|| ((!this.updateOps.loaded || this.updateOps.items.length === 0 || Boolean(this.updateOps.error)) || ((!this.updateOps.loaded || this.updateOps.items.length === 0 || Boolean(this.updateOps.error))
@ -2016,6 +2165,15 @@ createApp({
await this.ensureDatabaseWorkbenchLoaded(engine); 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() { async refreshDatabaseTree() {
if (this.databaseEngine === "mongo") { if (this.databaseEngine === "mongo") {
await this.refreshMongoAdminOverview({ preserveSelection: true }); await this.refreshMongoAdminOverview({ preserveSelection: true });
@ -3249,8 +3407,9 @@ createApp({
return; return;
} }
if (CURRENT_APP_CONTEXT.mode === "mysql") { if (CURRENT_APP_CONTEXT.mode === "common") {
await this.refresh(); await this.refresh();
this.applyRefreshTimer();
return; return;
} }
@ -3348,9 +3507,25 @@ createApp({
this.loading = false; 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() { async refresh() {
if (CURRENT_APP_CONTEXT.mode === "mysql") { if (CURRENT_APP_CONTEXT.mode === "common") {
await this.refreshMySqlModule(); await this.refreshCommonModule();
return; return;
} }
@ -3633,6 +3808,158 @@ createApp({
this.mySqlAdmin.message = message || ""; this.mySqlAdmin.message = message || "";
this.mySqlAdmin.messageTone = tone || "neutral"; 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() { clearMySqlSchemaState() {
this.mySqlAdmin.schema = { this.mySqlAdmin.schema = {
updatedAt: "", updatedAt: "",

View File

@ -207,6 +207,51 @@
</section> </section>
</template> </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'"> <template v-else-if="activeView === 'config'">
<section class="control-bar task-control-bar"> <section class="control-bar task-control-bar">
<div class="taskbar-context"> <div class="taskbar-context">
@ -1298,8 +1343,8 @@
</section> </section>
</template> </template>
<template v-if="activeView === 'database'"> <template v-if="showDatabaseWorkbench">
<section v-if="databaseEngine === 'mysql'" class="panel infra-panel database-workbench-panel mysql-workbench-panel"> <section v-if="showMySqlWorkbench" class="panel infra-panel database-workbench-panel mysql-workbench-panel">
<div class="compact-workbench-head mysql-workbench-head"> <div class="compact-workbench-head mysql-workbench-head">
<div class="metadata-strip metadata-strip-tight"> <div class="metadata-strip metadata-strip-tight">
<span class="mono">{{ truncateMiddle(mySqlAdmin.overview.info.host || '未配置 MySQL', 20, 12) }}</span> <span class="mono">{{ truncateMiddle(mySqlAdmin.overview.info.host || '未配置 MySQL', 20, 12) }}</span>
@ -2709,6 +2754,151 @@
</section> </section>
</template> </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'"> <template v-if="!showLoadingSkeleton && activeView === 'config'">
<section v-if="configTab === 'nacos'" class="panel infra-panel config-workbench-panel"> <section v-if="configTab === 'nacos'" class="panel infra-panel config-workbench-panel">
<div class="compact-workbench-head config-head-strip"> <div class="compact-workbench-head config-head-strip">

View File

@ -33,9 +33,9 @@
<strong>Yumi</strong> <strong>Yumi</strong>
<span>监控 / 配置 / 发布</span> <span>监控 / 配置 / 发布</span>
</a> </a>
<a class="entry-card" href="/mysql/"> <a class="entry-card" href="/common/">
<strong>MySQL</strong> <strong>通用</strong>
<span>数据库工作台</span> <span>MySQL / CDN 刷新</span>
</a> </a>
<a class="entry-card" href="/haiyi/"> <a class="entry-card" href="/haiyi/">
<strong>HaiYi</strong> <strong>HaiYi</strong>

117
tests/test_cdn_refresh.py Normal file
View 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()

View File

@ -19,6 +19,19 @@ class GatewayMysqlAliasTests(unittest.TestCase):
self.assertEqual(rewritten, "/yumi/api/mysql/overview?refresh=1") 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__": if __name__ == "__main__":
unittest.main() unittest.main()