diff --git a/.env.example b/.env.example index e0e87e8..a6320a5 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 66cf8a7..19c4b22 100644 --- a/README.md +++ b/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 diff --git a/core/config.py b/core/config.py index 1297d88..ea8f329 100644 --- a/core/config.py +++ b/core/config.py @@ -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 摘流 / 恢复异步任务最长等待秒数。 diff --git a/entry/http_app.py b/entry/http_app.py index 5d74278..43da4a2 100644 --- a/entry/http_app.py +++ b/entry/http_app.py @@ -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() # 登录接口只在根路径开放。 diff --git a/monitors/yumi/http_app.py b/monitors/yumi/http_app.py index b6a3e1b..b7c1312 100644 --- a/monitors/yumi/http_app.py +++ b/monitors/yumi/http_app.py @@ -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) diff --git a/monitors/yumi/tencent_cdn.py b/monitors/yumi/tencent_cdn.py new file mode 100644 index 0000000..0baec40 --- /dev/null +++ b/monitors/yumi/tencent_cdn.py @@ -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(), + } diff --git a/static/app.css b/static/app.css index ed60b00..b404748 100644 --- a/static/app.css +++ b/static/app.css @@ -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, diff --git a/static/app.js b/static/app.js index 4b5848a..bee1092 100644 --- a/static/app.js +++ b/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: "", diff --git a/static/index.html b/static/index.html index abecfed..5deb8cb 100644 --- a/static/index.html +++ b/static/index.html @@ -207,6 +207,51 @@ + + -