diff --git a/domain-api/app/api/routes/runtime.py b/domain-api/app/api/routes/runtime.py index 722780d..821e22e 100644 --- a/domain-api/app/api/routes/runtime.py +++ b/domain-api/app/api/routes/runtime.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Header from app.schemas.common import ApiResponse from app.services.cluster_runtime_service import get_cluster_snapshot +from app.services.debug_event_service import get_debug_diagnosis, get_debug_event_overview, get_debug_handoff_report, ingest_debug_event, list_debug_events from app.services.runtime_control_service import runtime_action from app.services.runtime_status_service import get_runtime_preflight, get_runtime_status from app.services.sync_push_service import ( @@ -46,6 +47,56 @@ def runtime_sync_records(limit: int = 20) -> ApiResponse: return ApiResponse(data={"records": list_sync_records(limit=limit)}) +@router.get("/runtime/debug-events", response_model=ApiResponse) +def runtime_debug_events( + limit: int = 50, + service: Optional[str] = None, + event_type: Optional[str] = None, + source_region: Optional[str] = None, + level: Optional[str] = None, + before_id: Optional[int] = None, + after_id: Optional[int] = None, + created_after: Optional[str] = None, +) -> ApiResponse: + return ApiResponse( + data=list_debug_events( + limit=limit, + service=service, + event_type=event_type, + source_region=source_region, + level=level, + before_id=before_id, + after_id=after_id, + created_after=created_after, + ) + ) + + +@router.get("/runtime/debug-overview", response_model=ApiResponse) +def runtime_debug_overview(window_minutes: int = 10, source_region: Optional[str] = None) -> ApiResponse: + return ApiResponse(data=get_debug_event_overview(window_minutes=window_minutes, source_region=source_region)) + + +@router.get("/runtime/debug-diagnosis", response_model=ApiResponse) +def runtime_debug_diagnosis(window_minutes: int = 10, source_region: Optional[str] = None) -> ApiResponse: + sync_summary = get_sync_summary() + return ApiResponse(data=get_debug_diagnosis(window_minutes=window_minutes, source_region=source_region, sync_summary=sync_summary)) + + +@router.get("/runtime/debug-handoff", response_model=ApiResponse) +def runtime_debug_handoff(window_minutes: int = 10, source_region: Optional[str] = None) -> ApiResponse: + sync_summary = get_sync_summary() + runtime_status_payload = get_runtime_status() + return ApiResponse( + data=get_debug_handoff_report( + window_minutes=window_minutes, + source_region=source_region, + sync_summary=sync_summary, + readiness=runtime_status_payload.get("readiness") or {}, + ) + ) + + @router.post("/runtime/sync-ingest", response_model=ApiResponse) def runtime_sync_ingest(payload: dict, x_domaincheck_sync_token: Optional[str] = Header(default=None)) -> ApiResponse: ok, message, data = ingest_runtime_projection(payload, shared_token=x_domaincheck_sync_token) @@ -64,6 +115,12 @@ def runtime_task_ack(payload: dict, x_domaincheck_sync_token: Optional[str] = He return ApiResponse(code=0 if ok else 1, message=message, data=data) +@router.post("/runtime/debug-ingest", response_model=ApiResponse) +def runtime_debug_ingest(payload: dict, x_domaincheck_sync_token: Optional[str] = Header(default=None)) -> ApiResponse: + ok, message, data = ingest_debug_event(payload, shared_token=x_domaincheck_sync_token) + return ApiResponse(code=0 if ok else 1, message=message, data=data) + + @router.post("/runtime/actions/{action}", response_model=ApiResponse) def runtime_action_trigger(action: str) -> ApiResponse: ok, message, data = runtime_action(action) diff --git a/domain-api/app/services/debug_event_service.py b/domain-api/app/services/debug_event_service.py new file mode 100644 index 0000000..68913bc --- /dev/null +++ b/domain-api/app/services/debug_event_service.py @@ -0,0 +1,735 @@ +from __future__ import annotations + +import json +import socket +import urllib.error +import urllib.request +from datetime import datetime, timedelta + +from app.core.config import settings +from app.core.db import get_db + + +_DEBUG_SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS detect_debug_events ( + id BIGSERIAL PRIMARY KEY, + source_region VARCHAR(32) NOT NULL DEFAULT '', + node_code VARCHAR(64) NOT NULL DEFAULT '', + service VARCHAR(64) NOT NULL DEFAULT '', + event_type VARCHAR(64) NOT NULL DEFAULT '', + level VARCHAR(16) NOT NULL DEFAULT 'info', + message TEXT NOT NULL DEFAULT '', + payload_json JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_detect_debug_events_created +ON detect_debug_events(created_at DESC); +""" + + +def _format_time(value: datetime | None) -> str: + return value.isoformat(sep=" ", timespec="seconds") if value else "" + + +def _parse_time(value: str | None) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + normalized = text.replace("T", " ") + for pattern in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + return datetime.strptime(normalized, pattern) + except ValueError: + continue + return None + + +def _normalize_region(value: str | None, fallback: str) -> str: + text = str(value or "").strip() + if not text or text == "unknown": + return str(fallback or "unknown").strip() or "unknown" + return text + + +def _safe_json_dumps(payload: object) -> str: + try: + return json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str) + except Exception: + return json.dumps({"_serialization_error": "payload_not_json_serializable"}, ensure_ascii=False, sort_keys=True) + + +def _normalize_message(value: str | None, fallback: str = "") -> str: + text = str(value or "").strip() + if not text: + text = fallback + return text[:2000] + + +def _response_code_ok(value: object) -> bool: + return value in (0, "0", None, "") + + +def _debug_ingest_url(base_url: str) -> str: + text = str(base_url or "").strip().rstrip("/") + if not text: + return "" + if text.endswith("/api/v1"): + return f"{text}/runtime/debug-ingest" + if text.endswith("/api/v1/runtime"): + return f"{text}/debug-ingest" + return f"{text}/api/v1/runtime/debug-ingest" + + +def ensure_debug_event_schema() -> None: + with get_db() as conn: + conn.autocommit = False + with conn.cursor() as cur: + cur.execute(_DEBUG_SCHEMA_SQL) + conn.commit() + + +def append_debug_event( + *, + service: str, + event_type: str, + message: str, + level: str = "info", + payload: dict | None = None, + source_region: str | None = None, + node_code: str | None = None, +) -> int: + ensure_debug_event_schema() + normalized_region = _normalize_region(source_region, settings.node_region) + normalized_node_code = str(node_code or settings.node_code).strip() or settings.node_code + normalized_service = str(service or "").strip() or "unknown" + normalized_event_type = str(event_type or "").strip() or "event" + normalized_level = str(level or "info").strip() or "info" + normalized_message = _normalize_message(message, fallback=f"{normalized_service}/{normalized_event_type}") + payload_text = _safe_json_dumps(payload or {}) + with get_db() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id + FROM detect_debug_events + WHERE source_region = %s + AND node_code = %s + AND service = %s + AND event_type = %s + AND level = %s + AND message = %s + AND COALESCE(payload_json, '{}'::jsonb) = %s::jsonb + AND created_at >= CURRENT_TIMESTAMP - interval '60 seconds' + ORDER BY created_at DESC, id DESC + LIMIT 1 + """, + ( + normalized_region, + normalized_node_code, + normalized_service, + normalized_event_type, + normalized_level, + normalized_message, + payload_text, + ), + ) + existing = cur.fetchone() + if existing: + return int(existing[0]) + cur.execute( + """ + INSERT INTO detect_debug_events ( + source_region, node_code, service, event_type, level, message, payload_json, created_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP) + RETURNING id + """, + ( + normalized_region, + normalized_node_code, + normalized_service, + normalized_event_type, + normalized_level, + normalized_message, + payload_text, + ), + ) + record_id = int(cur.fetchone()[0]) + conn.commit() + return record_id + + +def list_debug_events( + limit: int = 50, + *, + service: str | None = None, + event_type: str | None = None, + source_region: str | None = None, + level: str | None = None, + before_id: int | None = None, + after_id: int | None = None, + created_after: str | None = None, +) -> dict: + ensure_debug_event_schema() + safe_limit = max(1, min(int(limit or 50), 500)) + conditions: list[str] = [] + params: list[object] = [] + if str(service or "").strip(): + conditions.append("service = %s") + params.append(str(service).strip()) + if str(event_type or "").strip(): + conditions.append("event_type = %s") + params.append(str(event_type).strip()) + if str(source_region or "").strip(): + conditions.append("source_region = %s") + params.append(str(source_region).strip()) + if str(level or "").strip(): + conditions.append("level = %s") + params.append(str(level).strip()) + if before_id is not None and int(before_id or 0) > 0: + conditions.append("id < %s") + params.append(int(before_id)) + if after_id is not None and int(after_id or 0) > 0: + conditions.append("id > %s") + params.append(int(after_id)) + parsed_created_after = _parse_time(created_after) + if parsed_created_after: + conditions.append("created_at >= %s") + params.append(parsed_created_after) + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" + with get_db() as conn: + with conn.cursor() as cur: + cur.execute( + f""" + SELECT id, source_region, node_code, service, event_type, level, message, payload_json, created_at + FROM detect_debug_events + {where_clause} + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + (*params, safe_limit + 1), + ) + rows = cur.fetchall() + + has_more = len(rows) > safe_limit + rows = rows[:safe_limit] + records = [ + { + "id": row[0], + "source_region": row[1], + "node_code": row[2], + "service": row[3], + "event_type": row[4], + "level": row[5], + "message": row[6], + "payload": row[7] if isinstance(row[7], dict) else {}, + "created_at": _format_time(row[8]), + } + for row in rows + ] + next_before_id = records[-1]["id"] if has_more and records else None + latest_id = records[0]["id"] if records else None + return { + "records": records, + "has_more": has_more, + "next_before_id": next_before_id, + "latest_id": latest_id, + } + + +def get_debug_event_overview(*, window_minutes: int = 10, source_region: str | None = None) -> dict: + ensure_debug_event_schema() + safe_window = max(1, min(int(window_minutes or 10), 180)) + conditions = ["created_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval"] + params: list[object] = [safe_window] + if str(source_region or "").strip(): + conditions.append("source_region = %s") + params.append(str(source_region).strip()) + where_clause = f"WHERE {' AND '.join(conditions)}" + + with get_db() as conn: + with conn.cursor() as cur: + cur.execute( + f""" + SELECT level, count(*) + FROM detect_debug_events + {where_clause} + GROUP BY level + """, + tuple(params), + ) + level_counts = {str(level or "info"): int(count) for level, count in cur.fetchall()} + + cur.execute( + f""" + SELECT service, event_type, level, count(*) AS total + FROM detect_debug_events + {where_clause} + GROUP BY service, event_type, level + ORDER BY total DESC, service ASC, event_type ASC + LIMIT 12 + """, + tuple(params), + ) + hot_events = [ + { + "service": str(row[0] or ""), + "event_type": str(row[1] or ""), + "level": str(row[2] or "info"), + "count": int(row[3] or 0), + } + for row in cur.fetchall() + ] + + cur.execute( + f""" + SELECT + CASE + WHEN event_type LIKE 'task_pull%%' THEN 'task_pull' + WHEN event_type LIKE 'detect_result_projection_sync%%' THEN 'detect_result_sync' + WHEN event_type LIKE 'runtime_projection_sync%%' THEN 'runtime_projection_sync' + WHEN event_type LIKE 'sync_push%%' THEN 'sync_push' + WHEN event_type LIKE 'runtime_action%%' THEN 'runtime_action' + ELSE 'other' + END AS bucket, + count(*) AS total, + count(*) FILTER (WHERE level = 'warning') AS warnings, + count(*) FILTER (WHERE level = 'error') AS errors + FROM detect_debug_events + {where_clause} + GROUP BY bucket + ORDER BY total DESC, bucket ASC + """, + tuple(params), + ) + trend_buckets = [ + { + "bucket": str(row[0] or "other"), + "total": int(row[1] or 0), + "warnings": int(row[2] or 0), + "errors": int(row[3] or 0), + } + for row in cur.fetchall() + ] + + cur.execute( + f""" + SELECT id, source_region, node_code, service, event_type, level, message, payload_json, created_at + FROM detect_debug_events + {where_clause} + AND level IN ('warning', 'error') + ORDER BY created_at DESC, id DESC + LIMIT 8 + """, + tuple(params), + ) + recent_issues = [ + { + "id": row[0], + "source_region": row[1], + "node_code": row[2], + "service": row[3], + "event_type": row[4], + "level": row[5], + "message": row[6], + "payload": row[7] if isinstance(row[7], dict) else {}, + "created_at": _format_time(row[8]), + } + for row in cur.fetchall() + ] + + cur.execute( + f""" + SELECT + source_region, + service, + event_type, + level, + message, + count(*) AS total, + min(created_at) AS first_seen_at, + max(created_at) AS last_seen_at + FROM detect_debug_events + {where_clause} + AND level IN ('warning', 'error') + GROUP BY source_region, service, event_type, level, message + ORDER BY total DESC, last_seen_at DESC + LIMIT 12 + """, + tuple(params), + ) + issue_groups = [ + { + "source_region": str(row[0] or ""), + "service": str(row[1] or ""), + "event_type": str(row[2] or ""), + "level": str(row[3] or "info"), + "message": str(row[4] or ""), + "count": int(row[5] or 0), + "first_seen_at": _format_time(row[6]), + "last_seen_at": _format_time(row[7]), + "risk_score": int(row[5] or 0) * (3 if str(row[3] or "info") == "error" else 2), + "risk_label": "high" if (int(row[5] or 0) >= 3 and str(row[3] or "info") == "error") else ("medium" if int(row[5] or 0) >= 3 or str(row[3] or "info") == "error" else "low"), + } + for row in cur.fetchall() + ] + + total = sum(level_counts.values()) + return { + "window_minutes": safe_window, + "source_region": str(source_region or "").strip(), + "total": total, + "level_counts": { + "info": int(level_counts.get("info", 0)), + "warning": int(level_counts.get("warning", 0)), + "error": int(level_counts.get("error", 0)), + }, + "trend_buckets": trend_buckets, + "hot_events": hot_events, + "recent_issues": recent_issues, + "issue_groups": issue_groups, + } + + +def get_debug_diagnosis(*, window_minutes: int = 10, source_region: str | None = None, sync_summary: dict | None = None) -> dict: + overview = get_debug_event_overview(window_minutes=window_minutes, source_region=source_region) + summary = sync_summary or {} + latest_record = summary.get("latest_record") or {} + latest_result_batch = summary.get("latest_detect_result_batch") or {} + detect_result_batches = summary.get("detect_result_batches") or {} + projected = int((detect_result_batches.get("state_counts") or {}).get("projected", 0) or 0) + + bucket_map = { + str(item.get("bucket") or "other"): item + for item in (overview.get("trend_buckets") or []) + if isinstance(item, dict) + } + task_pull = bucket_map.get("task_pull", {}) + sync_push = bucket_map.get("sync_push", {}) + result_sync = bucket_map.get("detect_result_sync", {}) + runtime_action = bucket_map.get("runtime_action", {}) + + def _build( + *, + status: str, + title: str, + summary_text: str, + suspect: str, + evidence: str, + action: str, + next_step: str, + confidence: str, + suggested_checks: list[str], + recommended_action_api: str, + ) -> dict: + return { + "status": status, + "title": title, + "summary": summary_text, + "suspect": suspect, + "evidence": evidence, + "action": action, + "next_step": next_step, + "confidence": confidence, + "suggested_checks": suggested_checks, + "recommended_action_api": recommended_action_api, + "window_minutes": int(overview.get("window_minutes", window_minutes) or window_minutes), + "source_region": str(source_region or "").strip(), + } + + if int(result_sync.get("errors", 0) or 0) > 0 or str(latest_result_batch.get("latest_push_status") or "").strip().lower() == "failed": + return _build( + status="danger", + title="结果同步失败", + summary_text="任务执行可能仍在继续,但检测结果跨地域回传已经出现明确失败。", + suspect="sync-agent 到海外 ingest / 目标接收面", + evidence=f"detect_result_sync errors={int(result_sync.get('errors', 0) or 0)};latest_push_status={str(latest_result_batch.get('latest_push_status') or '-')}", + action="优先检查结果推送链路", + next_step="先查看 latest_push_error,再核对海外端 /runtime/sync-ingest 接收和网络连通性。", + confidence="high", + suggested_checks=[ + "curl -s http://127.0.0.1:8100/api/v1/runtime/sync-summary", + "journalctl -u domaincheck-sync-agent -n 50 --no-pager", + "curl -s https://api-domain.aakkx.top/api/v1/runtime/readiness", + ], + recommended_action_api="push_sync", + ) + + if projected > 0 or int(result_sync.get("warnings", 0) or 0) > 0: + return _build( + status="warning", + title="结果同步滞留", + summary_text="结果投影已经生成,但尚未稳定进入 delivered/synced。", + suspect="sync-agent 推送窗口 / 远端 ack / 目标侧消费速度", + evidence=f"projected={projected};detect_result_sync warnings={int(result_sync.get('warnings', 0) or 0)}", + action="优先检查 sync-agent 与远端确认", + next_step="结合结果同步摘要中的最近 Push/Ingress 时间,确认是卡在推送前还是目标接收后。", + confidence="medium", + suggested_checks=[ + "curl -s http://127.0.0.1:8100/api/v1/runtime/sync-summary", + "curl -s -X POST http://127.0.0.1:8100/api/v1/runtime/actions/push_sync", + "journalctl -u domaincheck-sync-agent -n 50 --no-pager", + ], + recommended_action_api="push_sync", + ) + + if int(task_pull.get("errors", 0) or 0) > 0 or int(task_pull.get("warnings", 0) or 0) > 0: + return _build( + status="warning", + title="任务拉取链路异常", + summary_text="控制面最近从海外拉取待检测任务时出现失败或半成功。", + suspect="task-export / task-ack / controller 入库", + evidence=f"task_pull warnings={int(task_pull.get('warnings', 0) or 0)} errors={int(task_pull.get('errors', 0) or 0)}", + action="优先检查任务拉取与确认", + next_step="筛选 task_pull_success/partial/failed,确认是 export、ack 还是本地 ingest 失败。", + confidence="high" if int(task_pull.get("errors", 0) or 0) > 0 else "medium", + suggested_checks=[ + "curl -s -X POST http://127.0.0.1:8100/api/v1/runtime/actions/pull_tasks", + "curl -s http://127.0.0.1:8100/api/v1/runtime/debug-overview", + "journalctl -u domaincheck-api -n 80 --no-pager", + ], + recommended_action_api="pull_tasks", + ) + + if int(runtime_action.get("errors", 0) or 0) > 0 or int(runtime_action.get("warnings", 0) or 0) > 0: + return _build( + status="warning", + title="控制动作链路波动", + summary_text="运行时动作请求已发出,但 controller 对 worker/api/sync-agent 的控制链路近期不稳定。", + suspect="systemctl 权限 / 本机环境 / 控制信道", + evidence=f"runtime_action warnings={int(runtime_action.get('warnings', 0) or 0)} errors={int(runtime_action.get('errors', 0) or 0)}", + action="优先检查控制动作执行侧", + next_step="对比 runtime_action_requested/runtime_action_finished,确认是否请求到了但执行失败。", + confidence="medium", + suggested_checks=[ + "curl -s http://127.0.0.1:8100/api/v1/runtime/status", + "systemctl status domaincheck-worker --no-pager -l", + "journalctl -u domaincheck-api -n 80 --no-pager", + ], + recommended_action_api="", + ) + + if str(latest_record.get("status") or "").strip().lower() == "failed" or int(sync_push.get("errors", 0) or 0) > 0: + return _build( + status="warning", + title="运行态同步异常", + summary_text="控制面心跳/运行态同步最近出现失败,可能影响海外端看到的集群状态。", + suspect="runtime_projection 推送 / 海外 runtime ingest", + evidence=f"latest_record_status={str(latest_record.get('status') or '-')};sync_push errors={int(sync_push.get('errors', 0) or 0)}", + action="优先检查运行态同步", + next_step="筛选 runtime_projection_sync_failed,检查海外 /runtime/sync-ingest 是否正常接收。", + confidence="medium", + suggested_checks=[ + "curl -s http://127.0.0.1:8100/api/v1/runtime/readiness", + "curl -s http://127.0.0.1:8100/api/v1/runtime/sync-summary", + "journalctl -u domaincheck-sync-agent -n 50 --no-pager", + ], + recommended_action_api="push_sync", + ) + + return _build( + status="ok", + title="当前链路整体稳定", + summary_text="任务拉取、运行态同步、结果同步和控制动作近窗内都没有明显异常。", + suspect="暂无明确阻塞段", + evidence=( + f"task_pull={int(task_pull.get('total', 0) or 0)} " + f"sync_push={int(sync_push.get('total', 0) or 0)} " + f"detect_result_sync={int(result_sync.get('total', 0) or 0)}" + ), + action="继续观察即可", + next_step="如果要排更细问题,优先看结果同步摘要和最近问题列表。", + confidence="medium", + suggested_checks=[ + "curl -s http://127.0.0.1:8100/api/v1/runtime/readiness", + "curl -s http://127.0.0.1:8100/api/v1/runtime/debug-overview", + ], + recommended_action_api="", + ) + + +def get_debug_handoff_report( + *, + window_minutes: int = 10, + source_region: str | None = None, + sync_summary: dict | None = None, + readiness: dict | None = None, +) -> dict: + summary = sync_summary or {} + overview = get_debug_event_overview(window_minutes=window_minutes, source_region=source_region) + diagnosis = get_debug_diagnosis(window_minutes=window_minutes, source_region=source_region, sync_summary=summary) + readiness_payload = readiness or {} + latest_record = summary.get("latest_record") or {} + latest_result_batch = summary.get("latest_detect_result_batch") or {} + level_counts = overview.get("level_counts") or {} + trend_buckets = overview.get("trend_buckets") or [] + recent_issues = overview.get("recent_issues") or [] + issue_groups = overview.get("issue_groups") or [] + + lines = [ + f"诊断结论: {diagnosis.get('title', '-')}", + f"状态: {diagnosis.get('status', '-')} | 置信度: {diagnosis.get('confidence', '-')}", + f"摘要: {diagnosis.get('summary', '-')}", + f"优先怀疑: {diagnosis.get('suspect', '-')}", + f"证据: {diagnosis.get('evidence', '-')}", + f"建议动作: {diagnosis.get('action', '-')}", + f"下一步: {diagnosis.get('next_step', '-')}", + f"推荐 API 动作: {diagnosis.get('recommended_action_api', '-') or '-'}", + f"集群就绪: {readiness_payload.get('status', '-')}/{readiness_payload.get('summary', '-')}", + "", + f"近 {overview.get('window_minutes', window_minutes)} 分钟事件: total={overview.get('total', 0)} info={level_counts.get('info', 0)} warning={level_counts.get('warning', 0)} error={level_counts.get('error', 0)}", + "趋势桶: " + (" | ".join( + f"{item.get('bucket', 'other')} total={item.get('total', 0)} warn={item.get('warnings', 0)} err={item.get('errors', 0)}" + for item in trend_buckets + ) or "-"), + f"最新同步记录: {latest_record.get('sync_type', '-')}/{latest_record.get('status', '-')}/{latest_record.get('created_at', '-')}", + f"最新结果批次: {latest_result_batch.get('job_code', '-')}/{latest_result_batch.get('sync_state', '-')}/push={latest_result_batch.get('latest_push_status', '-')}/ingest={latest_result_batch.get('latest_ingest_status', '-')}", + "最近问题:", + ] + if recent_issues: + for issue in recent_issues[:5]: + lines.append( + f"- [{issue.get('created_at', '-')}] {issue.get('service', '-')}/{issue.get('event_type', '-')} {issue.get('level', '-')} {issue.get('message', '-')}" + ) + else: + lines.append("- 近窗内没有 warning/error") + + checks = diagnosis.get("suggested_checks") or [] + lines.append("建议命令:") + if checks: + for cmd in checks: + lines.append(f"- {cmd}") + else: + lines.append("- 暂无") + + compact_lines = [ + f"[{diagnosis.get('status', '-')}] {diagnosis.get('title', '-')}", + f"摘要: {diagnosis.get('summary', '-')}", + f"怀疑点: {diagnosis.get('suspect', '-')}", + f"动作: {diagnosis.get('action', '-')} | API: {diagnosis.get('recommended_action_api', '-') or '-'}", + f"证据: {diagnosis.get('evidence', '-')}", + f"就绪: {readiness_payload.get('status', '-')}/{readiness_payload.get('summary', '-')}", + ] + if issue_groups: + compact_lines.append("重复问题:") + for issue in issue_groups[:3]: + compact_lines.append( + f"- {issue.get('service', '-')}/{issue.get('event_type', '-')} " + f"{issue.get('level', '-')} x{issue.get('count', 0)}: {issue.get('message', '-')}" + ) + else: + compact_lines.append("重复问题: 近窗内没有 warning/error 聚集") + compact_lines.append("建议命令:") + if checks: + for cmd in checks[:3]: + compact_lines.append(f"- {cmd}") + else: + compact_lines.append("- 暂无") + + failure_handoff = { + "generated_from": "runtime/debug-handoff", + "source_region": str(source_region or "").strip(), + "window_minutes": overview.get("window_minutes", window_minutes), + "diagnosis": diagnosis, + "readiness": readiness_payload, + "latest_record": latest_record, + "latest_detect_result_batch": latest_result_batch, + "issue_groups": issue_groups[:8], + "recent_issues": recent_issues[:8], + "suggested_checks": checks, + } + + return { + "window_minutes": overview.get("window_minutes", window_minutes), + "source_region": str(source_region or "").strip(), + "diagnosis": diagnosis, + "overview": overview, + "issue_groups": issue_groups, + "readiness": readiness_payload, + "sync_summary": { + "latest_record": latest_record, + "latest_detect_result_batch": latest_result_batch, + }, + "failure_handoff": failure_handoff, + "compact_report_text": "\n".join(compact_lines), + "report_text": "\n".join(lines), + } + + +def ingest_debug_event(payload: dict, *, shared_token: str | None = None) -> tuple[bool, str, dict]: + configured_token = str(settings.sync_shared_token or "").strip() + incoming_token = str(shared_token or "").strip() + if configured_token and incoming_token != configured_token: + return False, "调试事件 token 校验失败", {} + + record_id = append_debug_event( + source_region=str(payload.get("source_region") or settings.node_region), + node_code=str(payload.get("node_code") or ""), + service=str(payload.get("service") or "unknown"), + event_type=str(payload.get("event_type") or "event"), + level=str(payload.get("level") or "info"), + message=_normalize_message(payload.get("message"), fallback="remote debug event"), + payload=payload.get("payload") or {}, + ) + return True, "调试事件接收成功", {"record_id": record_id} + + +def push_debug_event( + *, + service: str, + event_type: str, + message: str, + level: str = "info", + payload: dict | None = None, +) -> tuple[bool, str, dict]: + append_debug_event( + service=service, + event_type=event_type, + message=message, + level=level, + payload=payload, + ) + + if not settings.sync_push_enabled: + return False, "未启用调试事件远端回传", {"local_only": True} + + ingest_url = _debug_ingest_url(settings.sync_target_api_base_url) + if not ingest_url: + return False, "未配置调试事件远端地址", {"local_only": True} + + request_payload = { + "source_region": settings.node_region, + "node_code": settings.node_code, + "hostname": socket.gethostname(), + "service": str(service or "").strip() or "unknown", + "event_type": str(event_type or "").strip() or "event", + "level": str(level or "info").strip() or "info", + "message": _normalize_message(message, fallback=f"{service}/{event_type}"), + "payload": payload or {}, + } + request = urllib.request.Request( + ingest_url, + data=_safe_json_dumps(request_payload).encode("utf-8"), + headers={ + "Content-Type": "application/json", + **({"X-Domaincheck-Sync-Token": settings.sync_shared_token} if settings.sync_shared_token else {}), + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + raw = response.read().decode("utf-8") + response_data = json.loads(raw) if raw else {} + except urllib.error.HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="ignore") if hasattr(exc, "read") else "" + return False, f"调试事件远端回传失败: HTTP {getattr(exc, 'code', 500)}", { + "http_status": getattr(exc, "code", 500), + "response_text": error_body[:500], + } + except json.JSONDecodeError as exc: + return False, f"调试事件远端回传失败: 响应不是合法 JSON ({exc})", {} + except Exception as exc: + return False, f"调试事件远端回传失败: {exc}", {} + if not _response_code_ok(response_data.get("code")): + return False, str(response_data.get("message") or "调试事件远端回传失败"), {"response": response_data} + return True, "调试事件已回传海外", {"response": response_data} diff --git a/domain-api/app/services/detect_job_service.py b/domain-api/app/services/detect_job_service.py index 4f1026f..9dd45fe 100644 --- a/domain-api/app/services/detect_job_service.py +++ b/domain-api/app/services/detect_job_service.py @@ -7,6 +7,7 @@ from uuid import uuid4 from app.core.config import settings from app.core.db import get_db +from app.services.debug_event_service import push_debug_event ACTIVE_JOB_STATUSES = ("pending", "running") @@ -222,6 +223,34 @@ def list_detect_jobs(limit: int = 20) -> list[dict]: return [_fetch_job_summary(cur, row, event_limit=10) for row in rows] +def list_recent_detect_run_events(limit: int = 20) -> list[dict]: + safe_limit = max(1, min(int(limit or 20), 200)) + with get_db() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT job_id, node_code, event_type, level, message, payload_json, created_at + FROM detect_run_events + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + (safe_limit,), + ) + rows = cur.fetchall() + return [ + { + "job_id": int(row[0]) if row[0] is not None else None, + "node_code": row[1] or "", + "event_type": row[2] or "", + "level": row[3] or "info", + "message": row[4] or "", + "payload": _decode_payload(row[5]), + "created_at": _format_time(row[6]), + } + for row in rows + ] + + def get_detect_queue_health(window_minutes: int = 15) -> dict: window_minutes = max(5, min(int(window_minutes or 15), 120)) active_job = get_active_detect_job_summary(event_limit=10) @@ -498,6 +527,20 @@ def append_detect_job_event( ), ) conn.commit() + try: + push_debug_event( + service="detect-job", + event_type=str(event_type or "").strip() or "info", + level=str(level or "info").strip() or "info", + message=str(message or "").strip(), + payload={ + "job_id": int(job_id), + "node_code": node_code or settings.node_code, + **(payload or {}), + }, + ) + except Exception: + pass def create_detect_job_if_needed(limit: int = 1000, created_by: str = "system") -> dict | None: diff --git a/domain-api/app/services/runtime_control_service.py b/domain-api/app/services/runtime_control_service.py index 5c14968..393f90a 100644 --- a/domain-api/app/services/runtime_control_service.py +++ b/domain-api/app/services/runtime_control_service.py @@ -5,6 +5,7 @@ import subprocess from pathlib import Path from app.core.config import settings +from app.services.debug_event_service import push_debug_event from app.services.sync_push_service import pull_detect_task_batch_now, push_runtime_projection_now from app.services.runtime_settings_service import get_runtime_settings from app.services.worker_control_service import _run_systemctl, start_worker, stop_worker @@ -62,6 +63,23 @@ def _run_systemd_action(service_name: str, action: str, *, no_block: bool = Fals return True, f"{service_name} {action} 命令已发送" +def _emit_runtime_action_event(action: str, *, stage: str, ok: bool, message: str, data: dict | None = None) -> None: + try: + push_debug_event( + service="runtime-control", + event_type=f"runtime_action_{stage}", + level="info" if ok else "warning", + message=message, + payload={ + "action": action, + "ok": ok, + **(data or {}), + }, + ) + except Exception: + pass + + def start_sync_agent() -> tuple[bool, str]: runtime = get_runtime_settings() worker_mode = runtime.get("worker_mode", settings.worker_mode) @@ -81,39 +99,79 @@ def stop_sync_agent() -> tuple[bool, str]: def runtime_action(action: str) -> tuple[bool, str, dict]: - if action == "start_worker": + normalized_action = str(action or "").strip().lower().replace("-", "_") + if normalized_action != "push_debug_probe": + _emit_runtime_action_event( + normalized_action, + stage="requested", + ok=True, + message=f"收到运行时动作请求: {normalized_action}", + ) + if normalized_action == "start_worker": ok, message = start_worker() - return ok, message, {"action": action, "poll_after_seconds": 2, "refresh_runtime": True} - if action == "stop_worker": + result = {"action": normalized_action, "poll_after_seconds": 2, "refresh_runtime": True} + _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) + return ok, message, result + if normalized_action == "stop_worker": ok, message = stop_worker() - return ok, message, {"action": action, "poll_after_seconds": 2, "refresh_runtime": True} - if action == "restart_api": + result = {"action": normalized_action, "poll_after_seconds": 2, "refresh_runtime": True} + _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) + return ok, message, result + if normalized_action == "restart_api": ok, message = restart_api() - return ok, message, {"action": action, "poll_after_seconds": 4, "refresh_runtime": True} - if action == "start_sync_agent": + result = {"action": normalized_action, "poll_after_seconds": 4, "refresh_runtime": True} + _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) + return ok, message, result + if normalized_action == "start_sync_agent": ok, message = start_sync_agent() - return ok, message, {"action": action, "poll_after_seconds": 2, "refresh_runtime": True} - if action == "stop_sync_agent": + result = {"action": normalized_action, "poll_after_seconds": 2, "refresh_runtime": True} + _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) + return ok, message, result + if normalized_action == "stop_sync_agent": ok, message = stop_sync_agent() - return ok, message, {"action": action, "poll_after_seconds": 2, "refresh_runtime": True} - if action == "push_sync": + result = {"action": normalized_action, "poll_after_seconds": 2, "refresh_runtime": True} + _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) + return ok, message, result + if normalized_action == "push_sync": ok, message, data = push_runtime_projection_now() - return ok, message, { - "action": action, + result = { + "action": normalized_action, "poll_after_seconds": 2, "refresh_runtime": True, **(data or {}), } - if action == "pull_tasks": + _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) + return ok, message, result + if normalized_action == "pull_tasks": ok, message, data = pull_detect_task_batch_now() - return ok, message, { - "action": action, + result = { + "action": normalized_action, "poll_after_seconds": 2, "refresh_runtime": True, **(data or {}), } - return False, f"不支持的运行时动作: {action}", { - "action": action, + _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) + return ok, message, result + if normalized_action == "push_debug_probe": + ok, message, data = push_debug_event( + service="domain-api", + event_type="debug_probe", + level="info", + message="manual debug probe", + payload={"node_code": settings.node_code, "node_region": settings.node_region}, + ) + result = { + "action": normalized_action, + "poll_after_seconds": 1, + "refresh_runtime": False, + **(data or {}), + } + _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) + return ok, message, result + result = { + "action": normalized_action, "poll_after_seconds": 0, "refresh_runtime": False, } + _emit_runtime_action_event(normalized_action, stage="finished", ok=False, message=f"不支持的运行时动作: {action}", data=result) + return False, f"不支持的运行时动作: {action}", result diff --git a/domain-api/app/services/sync_push_service.py b/domain-api/app/services/sync_push_service.py index 5807643..dce0534 100644 --- a/domain-api/app/services/sync_push_service.py +++ b/domain-api/app/services/sync_push_service.py @@ -842,6 +842,15 @@ def _push_projection_record(source_record: dict, sync_type: str, ingest_url: str raw = response.read().decode("utf-8") data = json.loads(raw) if raw else {} status_code = getattr(response, "status", 200) + except json.JSONDecodeError as exc: + payload = { + "sync_type": sync_type, + "source_record_id": source_record["id"], + "projection_hash": request_payload["projection_hash"], + "ingest_url": ingest_url, + } + _update_push_attempt(attempt_id, status="failed", payload=payload, error_message=f"invalid json response: {exc}") + return False, f"同步推送失败: 远端响应不是合法 JSON ({exc})", {"action": "push_sync", "sync_type": sync_type, "attempt_id": attempt_id} except urllib.error.HTTPError as exc: error_body = exc.read().decode("utf-8", errors="ignore") if hasattr(exc, "read") else "" payload = { @@ -872,6 +881,21 @@ def _push_projection_record(source_record: dict, sync_type: str, ingest_url: str "http_status": status_code, "response": data, } + response_code = data.get("code", 0) + if response_code not in (0, "0", None, ""): + _update_push_attempt( + attempt_id, + status="failed", + payload=payload, + error_message=str(data.get("message") or "remote business error"), + ) + return False, f"同步推送失败: {str(data.get('message') or '远端业务返回失败')}", { + "action": "push_sync", + "sync_type": sync_type, + "attempt_id": attempt_id, + "source_record_id": source_record["id"], + "response": data, + } _update_push_attempt(attempt_id, status="success", payload=payload, error_message="") return True, "投影推送成功", { "action": "push_sync", @@ -923,6 +947,8 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic with urllib.request.urlopen(request, timeout=20) as response: raw = response.read().decode("utf-8") data = json.loads(raw) if raw else {} + except json.JSONDecodeError as exc: + return False, f"拉取待检测任务失败: 远端响应不是合法 JSON ({exc})", {"action": "pull_tasks"} except urllib.error.HTTPError as exc: error_body = exc.read().decode("utf-8", errors="ignore") if hasattr(exc, "read") else "" return False, f"拉取待检测任务失败: HTTP {getattr(exc, 'code', 500)}", { @@ -978,6 +1004,22 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic raw = response.read().decode("utf-8") ack_response = json.loads(raw) if raw else {} ack_data = ack_response.get("data") or {} + ack_code = ack_response.get("code", 0) + if ack_code not in (0, "0", None, ""): + return True, f"{ingest_message};但远端确认失败: {str(ack_response.get('message') or 'ack business error')}", { + "action": "pull_tasks", + "source_record_id": source_record_id, + "projection_hash": projection_hash, + **(ingest_data or {}), + "ack": ack_data, + "ack_response": ack_response, + } + except json.JSONDecodeError as exc: + return True, f"{ingest_message};但远端确认失败: ack 响应不是合法 JSON ({exc})", { + "action": "pull_tasks", + "source_record_id": source_record_id, + **(ingest_data or {}), + } except Exception as exc: return True, f"{ingest_message};但远端确认失败: {exc}", { "action": "pull_tasks", diff --git a/domain-api/app/services/sync_record_service.py b/domain-api/app/services/sync_record_service.py index 8d5217a..434a286 100644 --- a/domain-api/app/services/sync_record_service.py +++ b/domain-api/app/services/sync_record_service.py @@ -38,6 +38,40 @@ def _normalize_region(value: str | None, fallback: str) -> str: return text +def _build_detect_result_batch_digest(batch: dict | None) -> dict: + batch = batch or {} + projection = (batch.get("projection") or {}).get("payload") or {} + latest_push = batch.get("latest_push") or {} + latest_ingest = batch.get("latest_ingest") or {} + job = (projection.get("projection") or {}).get("job") or {} + latest_event = (projection.get("projection") or {}).get("latest_event") or {} + return { + "job_id": int(batch.get("job_id") or 0), + "job_code": str(batch.get("job_code") or ""), + "job_status": str(batch.get("job_status") or ""), + "items_total": int(batch.get("items_total") or 0), + "items_terminal": int(batch.get("items_terminal") or 0), + "items_pending": int(batch.get("items_pending") or 0), + "items_running": int(batch.get("items_running") or 0), + "items_failed": int(batch.get("items_failed") or 0), + "progress_percent": float(batch.get("progress_percent") or 0), + "sync_state": str(batch.get("sync_state") or "unsynced"), + "sync_message": str(batch.get("sync_message") or ""), + "projection_record_id": int((batch.get("projection") or {}).get("id") or 0), + "projection_status": str((batch.get("projection") or {}).get("status") or ""), + "projection_created_at": str((batch.get("projection") or {}).get("created_at") or ""), + "latest_event_type": str(latest_event.get("event_type") or ""), + "latest_event_message": str(latest_event.get("message") or ""), + "latest_event_created_at": str(latest_event.get("created_at") or ""), + "latest_push_status": str(latest_push.get("status") or ""), + "latest_push_error": str(latest_push.get("error_message") or ""), + "latest_push_created_at": str(latest_push.get("created_at") or ""), + "latest_ingest_status": str(latest_ingest.get("status") or ""), + "latest_ingest_created_at": str(latest_ingest.get("created_at") or ""), + "cycle_token": str(job.get("current_cycle_token") or ""), + } + + def _should_append_runtime_projection(previous_payload: dict, current_projection: dict, previous_created_at: datetime | None) -> bool: if not previous_payload: return True @@ -346,6 +380,10 @@ def get_sync_summary(record_limit: int = 10) -> dict: "updated_at": _format_time(latest[8]), } + detect_result_batches = get_detect_result_sync_batches(limit=min(5, record_limit)) + recent_result_batches = list(detect_result_batches.get("batches") or []) + latest_result_batch = recent_result_batches[0] if recent_result_batches else None + return { "enabled": bool(settings.sync_push_enabled), "source_region": source_region, @@ -357,7 +395,9 @@ def get_sync_summary(record_limit: int = 10) -> dict: "status_counts": status_counts, "type_counts": type_counts, "latest_record": latest_record, - "detect_result_batches": get_detect_result_sync_batches(limit=min(5, record_limit)), + "detect_result_batches": detect_result_batches, + "latest_detect_result_batch": _build_detect_result_batch_digest(latest_result_batch), + "detect_result_batch_digests": [_build_detect_result_batch_digest(item) for item in recent_result_batches], "recent_records": list_sync_records(limit=record_limit), } diff --git a/domain-api/app/sync_agent.py b/domain-api/app/sync_agent.py index 3fa8029..2e249a3 100644 --- a/domain-api/app/sync_agent.py +++ b/domain-api/app/sync_agent.py @@ -4,12 +4,77 @@ import logging import time from app.core.config import settings +from app.services.debug_event_service import push_debug_event +from app.services.detect_job_service import get_active_detect_job_summary, get_detect_queue_health, list_recent_detect_run_events from app.services.sync_push_service import pull_detect_task_batch_now, push_runtime_projection_now logger = logging.getLogger("domaincheck.sync_agent") +def _emit_structured_tick( + *, + base_event_type: str, + ok: bool, + message: str, + data: dict | None = None, +) -> None: + payload = {"ok": ok, "data": data or {}} + event_type = f"{base_event_type}_failed" + level = "warning" + if ok: + event_type = f"{base_event_type}_success" + level = "info" + if "但远端确认失败" in str(message or ""): + event_type = f"{base_event_type}_partial" + level = "warning" + results = list((data or {}).get("results") or []) + if results and any(not bool(item.get("ok")) for item in results): + event_type = f"{base_event_type}_partial" + level = "warning" + push_debug_event( + service="sync-agent", + event_type=event_type, + level=level, + message=message, + payload=payload, + ) + + +def _emit_sync_result_breakdown(data: dict | None) -> None: + results = list((data or {}).get("results") or []) + for item in results: + sync_type = str(item.get("sync_type") or "").strip() or "unknown" + ok = bool(item.get("ok")) + message = str(item.get("message") or "").strip() or f"{sync_type} sync result" + result_data = item.get("data") or {} + event_type = f"{sync_type}_sync_failed" + level = "warning" + if ok: + event_type = f"{sync_type}_sync_success" + level = "info" + if isinstance(result_data, dict) and result_data.get("success_count") is not None: + batch_count = int(result_data.get("batch_count") or 0) + success_count = int(result_data.get("success_count") or 0) + if batch_count > 0 and success_count < batch_count: + event_type = f"{sync_type}_sync_partial" + level = "warning" + if "成功 1/" in message or "但" in message: + event_type = f"{sync_type}_sync_partial" + level = "warning" + push_debug_event( + service="sync-agent", + event_type=event_type, + level=level, + message=message, + payload={ + "sync_type": sync_type, + "ok": ok, + "data": result_data, + }, + ) + + def main() -> None: logging.basicConfig( level=logging.INFO, @@ -28,10 +93,85 @@ def main() -> None: try: ok, message, data = push_runtime_projection_now() logger.info("sync tick: ok=%s message=%s data=%s", ok, message, data) + push_debug_event( + service="sync-agent", + event_type="sync_tick", + level="info" if ok else "warning", + message=message, + payload={"ok": ok, "data": data}, + ) + _emit_structured_tick(base_event_type="sync_push", ok=ok, message=message, data=data) + _emit_sync_result_breakdown(data) pull_ok, pull_message, pull_data = pull_detect_task_batch_now() logger.info("task pull tick: ok=%s message=%s data=%s", pull_ok, pull_message, pull_data) + push_debug_event( + service="sync-agent", + event_type="task_pull_tick", + level="info" if pull_ok else "warning", + message=pull_message, + payload={"ok": pull_ok, "data": pull_data}, + ) + _emit_structured_tick(base_event_type="task_pull", ok=pull_ok, message=pull_message, data=pull_data) + active_job = get_active_detect_job_summary(event_limit=10) + if active_job: + queue_health = get_detect_queue_health(window_minutes=15) + recent_events = list_recent_detect_run_events(limit=8) + push_debug_event( + service="detect-runtime", + event_type="active_job_snapshot", + level="info", + message=f"active job {active_job.get('job_code', '')} status={active_job.get('status', '')}", + payload={ + "job": { + "job_id": active_job.get("job_id"), + "job_code": active_job.get("job_code", ""), + "status": active_job.get("status", ""), + "items_total": active_job.get("items_total", 0), + "items_pending": active_job.get("items_pending", 0), + "items_claimed": active_job.get("items_claimed", 0), + "items_running": active_job.get("items_running", 0), + "items_completed": active_job.get("items_completed", 0), + "items_failed": active_job.get("items_failed", 0), + "progress_percent": active_job.get("progress_percent", 0), + "node_stats": list(active_job.get("node_stats") or []), + }, + "queue_health": queue_health, + "recent_events": recent_events, + }, + ) + if queue_health.get("queue", {}).get("overdue_leases", 0): + push_debug_event( + service="detect-runtime", + event_type="queue_overdue_leases", + level="warning", + message=f"检测队列存在过期租约 {queue_health.get('queue', {}).get('overdue_leases', 0)} 个", + payload=queue_health, + ) + for event in recent_events: + event_type = str(event.get("event_type") or "").strip() + if event_type not in {"domain_started", "domain_completed", "domain_failed", "domain_blacklisted"}: + continue + push_debug_event( + service="worker-event", + event_type=event_type, + level=str(event.get("level") or "info"), + message=str(event.get("message") or "").strip(), + payload={ + "job_id": event.get("job_id"), + "node_code": event.get("node_code", ""), + "created_at": event.get("created_at", ""), + **(event.get("payload") or {}), + }, + ) except Exception as exc: logger.exception("sync tick failed: %s", exc) + push_debug_event( + service="sync-agent", + event_type="sync_tick_failed", + level="error", + message=str(exc), + payload={}, + ) time.sleep(interval) diff --git a/domain-web/src/api/modules.ts b/domain-web/src/api/modules.ts index f00f500..f6bfb20 100644 --- a/domain-web/src/api/modules.ts +++ b/domain-web/src/api/modules.ts @@ -13,6 +13,11 @@ export const runtimeApi = { readiness: () => http.get("/runtime/readiness"), preflight: () => http.get("/runtime/preflight"), cluster: () => http.get("/runtime/cluster"), + syncSummary: () => http.get("/runtime/sync-summary"), + debugOverview: (params?: Record) => http.get("/runtime/debug-overview", { params }), + debugDiagnosis: (params?: Record) => http.get("/runtime/debug-diagnosis", { params }), + debugHandoff: (params?: Record) => http.get("/runtime/debug-handoff", { params }), + debugEvents: (params?: Record) => http.get("/runtime/debug-events", { params }), action: (action: string) => http.post(`/runtime/actions/${action}`) }; diff --git a/domain-web/src/layouts/MainLayout.vue b/domain-web/src/layouts/MainLayout.vue index 7d80805..af41d84 100644 --- a/domain-web/src/layouts/MainLayout.vue +++ b/domain-web/src/layouts/MainLayout.vue @@ -61,6 +61,7 @@ let timer: number | null = null; const menuItems = [ { path: "/dashboard", label: "概览" }, { path: "/runtime", label: "运行中心" }, + { path: "/runtime-debug", label: "运行调试" }, { path: "/settings", label: "系统设置" }, { path: "/imports", label: "域名导入" }, { path: "/sensitive-words", label: "敏感词配置" }, diff --git a/domain-web/src/router/index.ts b/domain-web/src/router/index.ts index f757021..e9bf19f 100644 --- a/domain-web/src/router/index.ts +++ b/domain-web/src/router/index.ts @@ -15,6 +15,7 @@ const routes: RouteRecordRaw[] = [ { path: "", redirect: "/dashboard" }, { path: "dashboard", name: "dashboard", component: () => import("@/views/dashboard/DashboardView.vue"), meta: { title: "概览" } }, { path: "runtime", name: "runtime", component: () => import("@/views/runtime/RuntimeView.vue"), meta: { title: "运行中心" } }, + { path: "runtime-debug", name: "runtime-debug", component: () => import("@/views/runtime/RuntimeDebugView.vue"), meta: { title: "运行调试" } }, { path: "settings", name: "settings", component: () => import("@/views/settings/SettingsView.vue"), meta: { title: "系统设置" } }, { path: "imports", name: "imports", component: () => import("@/views/imports/ImportsView.vue"), meta: { title: "域名导入" } }, { path: "sensitive-words", name: "sensitive-words", component: () => import("@/views/sensitive-words/SensitiveWordsView.vue"), meta: { title: "敏感词配置" } }, diff --git a/domain-web/src/views/runtime/RuntimeDebugView.vue b/domain-web/src/views/runtime/RuntimeDebugView.vue new file mode 100644 index 0000000..356fe43 --- /dev/null +++ b/domain-web/src/views/runtime/RuntimeDebugView.vue @@ -0,0 +1,1274 @@ + + + + +