This commit is contained in:
Your Name
2026-04-17 15:13:46 +08:00
parent fe87c7b343
commit 0e096947fc
19 changed files with 791 additions and 151 deletions

View File

@@ -195,6 +195,23 @@ ln -sfn /www/wwwroot/getDomain/domainCheck /opt/domaincheck/domainCheck
- 默认检测并发由后台系统设置里的 `默认检测线程数` 控制 - 默认检测并发由后台系统设置里的 `默认检测线程数` 控制
- 若某台机器需要单独并发,可在后台系统设置里增加 `节点独立线程覆盖` - 若某台机器需要单独并发,可在后台系统设置里增加 `节点独立线程覆盖`
- 远端日志回传由后台 `系统设置 -> 运行配置``检测控制` 顶部按钮控制
- `关闭回传`
- 不追加远端 Worker 日志镜像
- 适合正式长期运行,减少额外事件写入
- `关键回传`
- 只回传启动、停止、代理刷新、批次完成、异常、完成等关键过程
- 适合大多数联调和线上观察
- `全量回传`
- 在关键模式基础上,额外回传取任务、建线程、线程数变化、域名开始/完成/失败等过程
- 适合临时排查问题,定位完成后建议切回 `关键``关闭`
- 检测控制页日志窗口现在会合并展示:
- controller 本机可见的 Worker 日志尾部
- 远端 Worker 回传的流程事件日志
- 如果开启了远端日志回传但页面仍无新增输出,优先检查:
- `domaincheck-worker` 是否已更新到最新代码
- Worker 是否能连上 Redis
- 检测任务是否由当前控制面发起,并带有 `job_id / cycle_token`
- 规则是: - 规则是:
- 节点已单独配置:走该节点自己的线程数 - 节点已单独配置:走该节点自己的线程数
- 节点未单独配置:回退到默认线程数 - 节点未单独配置:回退到默认线程数

View File

@@ -19,6 +19,39 @@ from app.services.worker_control_service import send_worker_command, start_worke
router = APIRouter(tags=["detect"]) router = APIRouter(tags=["detect"])
def _build_detect_action_result(
*,
action: str,
ok: bool,
message: str,
poll_after_seconds: int = 2,
refresh_status: bool = True,
data: dict | None = None,
) -> dict:
result = {
"action": action,
"poll_after_seconds": poll_after_seconds,
"refresh_status": refresh_status,
**(data or {}),
}
lowered = str(message or "").strip().lower()
if "ui_level" not in result:
if not ok:
result["ui_level"] = "warning" if any(keyword in lowered for keyword in ("当前没有", "无需", "未运行", "未启动")) else "error"
elif any(keyword in str(message or "") for keyword in ("命令已发送", "已发送检测启动请求", "控制指令")):
result["ui_level"] = "warning"
else:
result["ui_level"] = "success"
if "poll_schedule_seconds" not in result:
if ok:
result["poll_schedule_seconds"] = [1, max(2, poll_after_seconds)]
elif poll_after_seconds > 0:
result["poll_schedule_seconds"] = [poll_after_seconds]
else:
result["poll_schedule_seconds"] = []
return result
def _build_settings_summary(settings_payload: dict) -> dict: def _build_settings_summary(settings_payload: dict) -> dict:
thread_count_resolution = resolve_thread_count(settings_payload=settings_payload) thread_count_resolution = resolve_thread_count(settings_payload=settings_payload)
return { return {
@@ -65,15 +98,16 @@ def detect_job_detail(job_id: int) -> ApiResponse:
def start_detect() -> ApiResponse: def start_detect() -> ApiResponse:
job_summary = create_detect_job_if_needed(limit=1000, created_by="api") job_summary = create_detect_job_if_needed(limit=1000, created_by="api")
if not job_summary: if not job_summary:
result = _build_detect_action_result(
action="start",
ok=False,
message="当前没有可创建的检测任务",
data={"job": None},
)
return ApiResponse( return ApiResponse(
code=0, code=0,
message="当前没有可创建的检测任务", message="当前没有可创建的检测任务",
data={ data=result,
"action": "start",
"job": None,
"poll_after_seconds": 2,
"refresh_status": True,
},
) )
cycle_token = uuid4().hex[:10] cycle_token = uuid4().hex[:10]
append_detect_job_event( append_detect_job_event(
@@ -91,6 +125,12 @@ def start_detect() -> ApiResponse:
ok, message = start_worker() ok, message = start_worker()
if not ok: if not ok:
result = _build_detect_action_result(
action="start",
ok=False,
message=message,
data={"job": job_summary},
)
append_detect_job_event( append_detect_job_event(
job_summary["job_id"], job_summary["job_id"],
event_type="job_dispatch_failed", event_type="job_dispatch_failed",
@@ -101,12 +141,7 @@ def start_detect() -> ApiResponse:
return ApiResponse( return ApiResponse(
code=1, code=1,
message=message, message=message,
data={ data=result,
"action": "start",
"job": job_summary,
"poll_after_seconds": 2,
"refresh_status": True,
},
) )
command_ok, command_message = send_worker_command( command_ok, command_message = send_worker_command(
@@ -140,16 +175,14 @@ def start_detect() -> ApiResponse:
progress=snapshot.get("progress", {}), progress=snapshot.get("progress", {}),
settings_summary=settings_summary, settings_summary=settings_summary,
) )
return ApiResponse( response_message = f"{message}{command_message}" if command_ok else command_message
code=0 if command_ok else 1, result = _build_detect_action_result(
message=f"{message}{command_message}" if command_ok else command_message, action="start",
data={ ok=command_ok,
"action": "start", message=response_message,
"job": job_summary, data={"job": job_summary},
"poll_after_seconds": 2,
"refresh_status": True,
},
) )
return ApiResponse(code=0 if command_ok else 1, message=response_message, data=result)
@router.post("/detect/stop", response_model=ApiResponse) @router.post("/detect/stop", response_model=ApiResponse)
@@ -193,12 +226,5 @@ def stop_detect() -> ApiResponse:
settings_summary=settings_summary, settings_summary=settings_summary,
active_job=active_job, active_job=active_job,
) )
return ApiResponse( result = _build_detect_action_result(action="stop", ok=ok, message=message)
code=0 if ok else 1, return ApiResponse(code=0 if ok else 1, message=message, data=result)
message=message,
data={
"action": "stop",
"poll_after_seconds": 2,
"refresh_status": True,
},
)

View File

@@ -11,6 +11,31 @@ def domain_root() -> Path:
return Path(settings.domain_root) return Path(settings.domain_root)
def resolve_domain_path(*relative_paths: str) -> Path | None:
root = domain_root()
candidates: list[Path] = []
seen: set[str] = set()
for relative_path in relative_paths:
text = str(relative_path or "").strip()
if not text:
continue
candidate = root / text
candidate_key = str(candidate)
if candidate_key not in seen:
seen.add(candidate_key)
candidates.append(candidate)
if "/" not in text and "\\" not in text:
nested_candidate = root / "logs" / text
nested_key = str(nested_candidate)
if nested_key not in seen:
seen.add(nested_key)
candidates.append(nested_candidate)
for candidate in candidates:
if candidate.exists():
return candidate
return candidates[0] if candidates else None
def read_json(relative_path: str, default: dict | list | None = None): def read_json(relative_path: str, default: dict | list | None = None):
path = domain_root() / relative_path path = domain_root() / relative_path
if not path.exists(): if not path.exists():
@@ -30,8 +55,8 @@ def write_json(relative_path: str, payload) -> None:
def tail_lines(relative_path: str, max_lines: int = 120) -> list[str]: def tail_lines(relative_path: str, max_lines: int = 120) -> list[str]:
path = domain_root() / relative_path path = resolve_domain_path(relative_path)
if not path.exists(): if path is None or not path.exists():
return [] return []
with path.open("r", encoding="utf-8", errors="replace") as handle: with path.open("r", encoding="utf-8", errors="replace") as handle:
return handle.read().splitlines()[-max_lines:] return handle.read().splitlines()[-max_lines:]

View File

@@ -86,6 +86,11 @@ CREATE TABLE IF NOT EXISTS detect_sync_records (
); );
""" """
_STALE_AFTER_SECONDS = 90
_OFFLINE_AFTER_MINUTES = 5
_PRUNE_IMPORTED_AFTER_MINUTES = 30
_PRUNE_GENERAL_AFTER_HOURS = 6
def _resolve_local_ip() -> str: def _resolve_local_ip() -> str:
try: try:
@@ -190,6 +195,28 @@ def cleanup_imported_runtime_nodes(*, region: str, role: str, keep_node_code: st
conn.commit() conn.commit()
def prune_expired_runtime_nodes() -> None:
imported_cutoff = datetime.now() - timedelta(minutes=_PRUNE_IMPORTED_AFTER_MINUTES)
general_cutoff = datetime.now() - timedelta(hours=_PRUNE_GENERAL_AFTER_HOURS)
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"""
DELETE FROM detect_worker_nodes
WHERE (
(metadata_json->>'service') = 'runtime-ingest'
AND last_heartbeat_at < %s
)
OR (
COALESCE(metadata_json->>'service', '') <> 'runtime-ingest'
AND last_heartbeat_at < %s
)
""",
(imported_cutoff, general_cutoff),
)
conn.commit()
def register_local_control_heartbeat() -> None: def register_local_control_heartbeat() -> None:
from app.services.detect_job_service import get_active_detect_job_summary from app.services.detect_job_service import get_active_detect_job_summary
from app.services.worker_control_service import detect_worker_runtime from app.services.worker_control_service import detect_worker_runtime
@@ -240,14 +267,15 @@ def _normalize_node_status(raw_status: str, last_heartbeat_at: datetime | None)
return status return status
now = datetime.now(last_heartbeat_at.tzinfo) if last_heartbeat_at.tzinfo else datetime.now() now = datetime.now(last_heartbeat_at.tzinfo) if last_heartbeat_at.tzinfo else datetime.now()
age = now - last_heartbeat_at age = now - last_heartbeat_at
if age > timedelta(minutes=5): if age > timedelta(minutes=_OFFLINE_AFTER_MINUTES):
return "offline" return "offline"
if age > timedelta(seconds=90): if age > timedelta(seconds=_STALE_AFTER_SECONDS):
return "stale" return "stale"
return status return status
def get_cluster_snapshot() -> dict: def get_cluster_snapshot() -> dict:
prune_expired_runtime_nodes()
register_local_control_heartbeat() register_local_control_heartbeat()
with get_db() as conn: with get_db() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:

View File

@@ -18,10 +18,21 @@ def fetch_overview() -> dict:
with get_db() as conn: with get_db() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
for key, query in queries.items(): for key, query in queries.items():
cur.execute(query) try:
result[key] = cur.fetchone()[0] cur.execute(query)
result[key] = cur.fetchone()[0]
except Exception:
result[key] = 0
runtime = get_runtime_status() runtime = get_runtime_status()
cluster_summary = ((runtime.get("cluster") or {}).get("summary") or {})
online_worker_nodes = int(cluster_summary.get("online_worker_nodes", 0) or 0)
dedicated_online_worker_nodes = int(cluster_summary.get("dedicated_online_worker_nodes", 0) or 0)
result["worker_status"] = "online" if runtime["worker"]["running"] else "offline" result["worker_status"] = "online" if runtime["worker"]["running"] else "offline"
result["local_worker_status"] = result["worker_status"]
result["cluster_worker_status"] = "online" if online_worker_nodes > 0 else "offline"
result["cluster_online_worker_nodes"] = online_worker_nodes
result["cluster_dedicated_online_worker_nodes"] = dedicated_online_worker_nodes
result["cluster_online_control_nodes"] = int(cluster_summary.get("online_control_nodes", 0) or 0)
result["api_status"] = "online" result["api_status"] = "online"
result["worker_mode"] = runtime["worker"]["mode"] result["worker_mode"] = runtime["worker"]["mode"]
return result return result

View File

@@ -3,10 +3,8 @@ from __future__ import annotations
import json import json
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from app.core.db import get_db from app.core.db import get_db
from app.core.files import tail_lines from app.core.files import resolve_domain_path, tail_lines
from app.core.redis_client import get_redis from app.core.redis_client import get_redis
from app.services.runtime_settings_service import get_runtime_settings from app.services.runtime_settings_service import get_runtime_settings
from app.services.detect_run_service import sync_detect_runs from app.services.detect_run_service import sync_detect_runs
@@ -20,6 +18,7 @@ _PROXY_COUNT_RE = re.compile(r"当前可用代理数[:]\s*(\d+)")
_THREAD_COUNT_RE = re.compile(r"当前实际线程数量[:]\s*(\d+)\s*/\s*(\d+)") _THREAD_COUNT_RE = re.compile(r"当前实际线程数量[:]\s*(\d+)\s*/\s*(\d+)")
_RUNTIME_STATE_KEY = "domain_tool:detect_runtime_state" _RUNTIME_STATE_KEY = "domain_tool:detect_runtime_state"
_TIMESTAMP_FORMATS = ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S") _TIMESTAMP_FORMATS = ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S")
_REMOTE_LOG_MAX_CHARS = 500
def _extract_dependency_alerts(lines: list[str]) -> list[dict]: def _extract_dependency_alerts(lines: list[str]) -> list[dict]:
@@ -141,6 +140,61 @@ def _recent_event(lines: list[str]) -> str:
return "" return ""
def _build_remote_log_lines(
active_job: dict | None,
*,
enabled: bool,
mode: str,
limit: int = 240,
) -> list[str]:
if not enabled:
return []
if not active_job:
return []
events = list(active_job.get("current_cycle_events") or active_job.get("recent_events") or [])
if not events:
return []
lines: list[str] = []
normalized_mode = str(mode or "key").strip().lower()
if normalized_mode not in {"key", "full"}:
normalized_mode = "key"
current_cycle_token = str(active_job.get("current_cycle_token") or "").strip()
for event in reversed(events):
event_type = str(event.get("event_type") or "").strip()
if event_type != "worker_log":
continue
created_at = str(event.get("created_at") or "").strip()
node_code = str(event.get("node_code") or "").strip() or "unknown"
message = str(event.get("message") or "").strip()
if not message:
continue
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
event_cycle_token = str(payload.get("cycle_token") or "").strip()
if current_cycle_token and event_cycle_token and event_cycle_token != current_cycle_token:
continue
event_mode = str(payload.get("log_mode") or "key").strip().lower()
if event_mode not in {"key", "full"}:
event_mode = "key"
if normalized_mode != "full" and event_mode == "full":
continue
if len(message) > _REMOTE_LOG_MAX_CHARS:
message = f"{message[:_REMOTE_LOG_MAX_CHARS]}..."
lines.append(f"[{created_at}] [{node_code}] {message}")
return lines[-max(1, int(limit or 240)) :]
def _resolve_remote_log_lines(
active_job: dict | None,
runs: list[dict],
*,
enabled: bool,
mode: str,
limit: int = 240,
) -> list[str]:
return _build_remote_log_lines(active_job, enabled=enabled, mode=mode, limit=limit)
def _load_runtime_state() -> dict: def _load_runtime_state() -> dict:
try: try:
redis_client = get_redis() redis_client = get_redis()
@@ -282,17 +336,18 @@ def get_detect_status() -> dict:
with get_db() as conn: with get_db() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
for key, query in queries.items(): for key, query in queries.items():
cur.execute(query) try:
progress[key] = cur.fetchone()[0] cur.execute(query)
progress[key] = cur.fetchone()[0]
except Exception:
progress[key] = 0
settings_payload = get_settings_payload() settings_payload = get_settings_payload()
worker_log = Path(__file__).resolve().parents[3] / "domainCheck" / "detect_worker.log" worker_log = resolve_domain_path("detect_worker.log", "logs/detect_worker.log")
if not worker_log.exists():
worker_log = Path(__file__).resolve().parents[3] / "domainCheck" / "logs" / "detect_worker.log"
worker_online = False worker_online = False
last_log_time = None last_log_time = None
if worker_log.exists(): if worker_log and worker_log.exists():
modified = datetime.fromtimestamp(worker_log.stat().st_mtime, tz=timezone.utc) modified = datetime.fromtimestamp(worker_log.stat().st_mtime, tz=timezone.utc)
last_log_time = modified.isoformat() last_log_time = modified.isoformat()
worker_online = (datetime.now(timezone.utc) - modified).total_seconds() < 180 worker_online = (datetime.now(timezone.utc) - modified).total_seconds() < 180
@@ -335,7 +390,7 @@ def get_detect_status() -> dict:
"allow_direct": settings_payload["proxy_config"].get("allow_direct", False), "allow_direct": settings_payload["proxy_config"].get("allow_direct", False),
"proxy_pool_count": len(settings_payload["proxy_config"].get("proxy_urls", [])), "proxy_pool_count": len(settings_payload["proxy_config"].get("proxy_urls", [])),
} }
active_job = get_active_detect_job_summary() active_job = get_active_detect_job_summary(event_limit=240)
runtime_snapshot = { runtime_snapshot = {
**runtime, **runtime,
"detecting": runtime_state.get("detecting", False), "detecting": runtime_state.get("detecting", False),
@@ -347,6 +402,15 @@ def get_detect_status() -> dict:
"proxy_supplier_empty": proxy_runtime["supplier_empty"], "proxy_supplier_empty": proxy_runtime["supplier_empty"],
} }
runs = sync_detect_runs(runtime_snapshot, progress, settings_summary, active_job=active_job) runs = sync_detect_runs(runtime_snapshot, progress, settings_summary, active_job=active_job)
worker_log_sync_enabled = bool(runtime_settings.get("worker_log_sync_enabled", False))
worker_log_sync_mode = str(runtime_settings.get("worker_log_sync_mode", "key") or "key")
remote_log_lines = _resolve_remote_log_lines(
active_job,
runs,
enabled=worker_log_sync_enabled,
mode=worker_log_sync_mode,
limit=240,
)
dependency_alerts = _extract_dependency_alerts(recent_lines) dependency_alerts = _extract_dependency_alerts(recent_lines)
append_detect_result_projection_if_changed( append_detect_result_projection_if_changed(
detect={ detect={
@@ -400,6 +464,9 @@ def get_detect_status() -> dict:
"recent_event": runtime_state.get("detail") or _recent_event(recent_lines), "recent_event": runtime_state.get("detail") or _recent_event(recent_lines),
"recent_warning": recent_proxy_warning, "recent_warning": recent_proxy_warning,
"log_lines": recent_lines, "log_lines": recent_lines,
"remote_log_lines": remote_log_lines,
"runs": runs, "runs": runs,
"active_job": active_job, "active_job": active_job,
"worker_log_sync_enabled": worker_log_sync_enabled,
"worker_log_sync_mode": worker_log_sync_mode,
} }

View File

@@ -8,7 +8,7 @@ from app.core.config import settings
from app.services.debug_event_service import push_debug_event 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.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.runtime_settings_service import get_runtime_settings
from app.services.worker_control_service import _run_systemctl, start_worker, stop_worker from app.services.worker_control_service import _run_systemctl, normalize_systemctl_error, start_worker, stop_worker
def _workspace_root() -> Path: def _workspace_root() -> Path:
@@ -29,7 +29,7 @@ def restart_api() -> tuple[bool, str]:
# blocking the HTTP request until uvicorn is torn down. # blocking the HTTP request until uvicorn is torn down.
result = _run_systemctl(["restart", api_service_name], timeout=5, no_block=True) result = _run_systemctl(["restart", api_service_name], timeout=5, no_block=True)
if result.returncode != 0: if result.returncode != 0:
return False, (result.stderr or result.stdout or "重启 Linux API 失败").strip() return False, normalize_systemctl_error(result.stderr or result.stdout or "重启 Linux API 失败", service_name=api_service_name)
return True, f"Linux API 重启命令已发送: {api_service_name}" return True, f"Linux API 重启命令已发送: {api_service_name}"
if os.name != "nt": if os.name != "nt":
@@ -59,7 +59,7 @@ def _run_systemd_action(service_name: str, action: str, *, no_block: bool = Fals
systemctl_command.extend([action, service_name]) systemctl_command.extend([action, service_name])
result = _run_systemctl(systemctl_command, timeout=10 if no_block else 30) result = _run_systemctl(systemctl_command, timeout=10 if no_block else 30)
if result.returncode != 0: if result.returncode != 0:
return False, (result.stderr or result.stdout or f"{action} {service_name} 失败").strip() return False, normalize_systemctl_error(result.stderr or result.stdout or f"{action} {service_name} 失败", service_name=service_name)
return True, f"{service_name} {action} 命令已发送" return True, f"{service_name} {action} 命令已发送"
@@ -80,6 +80,42 @@ def _emit_runtime_action_event(action: str, *, stage: str, ok: bool, message: st
pass pass
def _build_runtime_action_result(
*,
action: str,
poll_after_seconds: int,
refresh_runtime: bool,
ok: bool,
message: str,
data: dict | None = None,
) -> dict:
result = {
"action": action,
"poll_after_seconds": poll_after_seconds,
"refresh_runtime": refresh_runtime,
**(data or {}),
}
lowered = str(message or "").strip().lower()
if "ui_level" not in result:
if not ok:
result["ui_level"] = "warning" if any(keyword in lowered for keyword in ("当前没有", "无需", "未启用", "未配置")) else "error"
elif any(keyword in str(message or "") for keyword in ("命令已发送", "确认失败")):
result["ui_level"] = "warning"
else:
result["ui_level"] = "success"
if "poll_schedule_seconds" not in result:
if action in {"start_worker", "stop_worker", "restart_api", "start_sync_agent", "stop_sync_agent"}:
result["poll_schedule_seconds"] = [1, max(2, poll_after_seconds)]
elif action in {"push_sync", "pull_tasks"}:
result["poll_schedule_seconds"] = [1, max(2, poll_after_seconds)]
elif poll_after_seconds > 0:
result["poll_schedule_seconds"] = [poll_after_seconds]
else:
result["poll_schedule_seconds"] = []
return result
def start_sync_agent() -> tuple[bool, str]: def start_sync_agent() -> tuple[bool, str]:
runtime = get_runtime_settings() runtime = get_runtime_settings()
worker_mode = runtime.get("worker_mode", settings.worker_mode) worker_mode = runtime.get("worker_mode", settings.worker_mode)
@@ -109,47 +145,37 @@ def runtime_action(action: str) -> tuple[bool, str, dict]:
) )
if normalized_action == "start_worker": if normalized_action == "start_worker":
ok, message = start_worker() ok, message = start_worker()
result = {"action": normalized_action, "poll_after_seconds": 2, "refresh_runtime": True} result = _build_runtime_action_result(action=normalized_action, poll_after_seconds=2, refresh_runtime=True, ok=ok, message=message)
_emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result)
return ok, message, result return ok, message, result
if normalized_action == "stop_worker": if normalized_action == "stop_worker":
ok, message = stop_worker() ok, message = stop_worker()
result = {"action": normalized_action, "poll_after_seconds": 2, "refresh_runtime": True} result = _build_runtime_action_result(action=normalized_action, poll_after_seconds=2, refresh_runtime=True, ok=ok, message=message)
_emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result)
return ok, message, result return ok, message, result
if normalized_action == "restart_api": if normalized_action == "restart_api":
ok, message = restart_api() ok, message = restart_api()
result = {"action": normalized_action, "poll_after_seconds": 4, "refresh_runtime": True} result = _build_runtime_action_result(action=normalized_action, poll_after_seconds=4, refresh_runtime=True, ok=ok, message=message)
_emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result)
return ok, message, result return ok, message, result
if normalized_action == "start_sync_agent": if normalized_action == "start_sync_agent":
ok, message = start_sync_agent() ok, message = start_sync_agent()
result = {"action": normalized_action, "poll_after_seconds": 2, "refresh_runtime": True} result = _build_runtime_action_result(action=normalized_action, poll_after_seconds=2, refresh_runtime=True, ok=ok, message=message)
_emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result)
return ok, message, result return ok, message, result
if normalized_action == "stop_sync_agent": if normalized_action == "stop_sync_agent":
ok, message = stop_sync_agent() ok, message = stop_sync_agent()
result = {"action": normalized_action, "poll_after_seconds": 2, "refresh_runtime": True} result = _build_runtime_action_result(action=normalized_action, poll_after_seconds=2, refresh_runtime=True, ok=ok, message=message)
_emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result)
return ok, message, result return ok, message, result
if normalized_action == "push_sync": if normalized_action == "push_sync":
ok, message, data = push_runtime_projection_now() ok, message, data = push_runtime_projection_now()
result = { result = _build_runtime_action_result(action=normalized_action, poll_after_seconds=2, refresh_runtime=True, ok=ok, message=message, data=data)
"action": normalized_action,
"poll_after_seconds": 2,
"refresh_runtime": True,
**(data or {}),
}
_emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result)
return ok, message, result return ok, message, result
if normalized_action == "pull_tasks": if normalized_action == "pull_tasks":
ok, message, data = pull_detect_task_batch_now() ok, message, data = pull_detect_task_batch_now()
result = { result = _build_runtime_action_result(action=normalized_action, poll_after_seconds=2, refresh_runtime=True, ok=ok, message=message, data=data)
"action": normalized_action,
"poll_after_seconds": 2,
"refresh_runtime": True,
**(data or {}),
}
_emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result) _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result)
return ok, message, result return ok, message, result
if normalized_action == "push_debug_probe": if normalized_action == "push_debug_probe":
@@ -160,18 +186,9 @@ def runtime_action(action: str) -> tuple[bool, str, dict]:
message="manual debug probe", message="manual debug probe",
payload={"node_code": settings.node_code, "node_region": settings.node_region}, payload={"node_code": settings.node_code, "node_region": settings.node_region},
) )
result = { result = _build_runtime_action_result(action=normalized_action, poll_after_seconds=1, refresh_runtime=False, ok=ok, message=message, data=data)
"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) _emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result)
return ok, message, result return ok, message, result
result = { result = _build_runtime_action_result(action=normalized_action, poll_after_seconds=0, refresh_runtime=False, ok=False, message=f"不支持的运行时动作: {action}")
"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) _emit_runtime_action_event(normalized_action, stage="finished", ok=False, message=f"不支持的运行时动作: {action}", data=result)
return False, f"不支持的运行时动作: {action}", result return False, f"不支持的运行时动作: {action}", result

View File

@@ -9,21 +9,41 @@ DEFAULT_RUNTIME_SETTINGS = {
"worker_service_name": settings.worker_service_name, "worker_service_name": settings.worker_service_name,
"api_service_name": settings.api_service_name, "api_service_name": settings.api_service_name,
"sync_agent_service_name": settings.sync_agent_service_name, "sync_agent_service_name": settings.sync_agent_service_name,
"worker_log_sync_enabled": False,
"worker_log_sync_mode": "key",
} }
def _normalize_worker_log_sync_mode(value: object) -> str:
return "full" if str(value or "").strip().lower() == "full" else "key"
def normalize_runtime_settings(payload: dict | None) -> dict:
merged = dict(DEFAULT_RUNTIME_SETTINGS)
if isinstance(payload, dict):
for key in DEFAULT_RUNTIME_SETTINGS:
if key in payload and payload[key] is not None:
merged[key] = payload[key]
worker_mode = str(merged.get("worker_mode") or "").strip()
if worker_mode not in {"windows-local", "linux-systemd"}:
merged["worker_mode"] = DEFAULT_RUNTIME_SETTINGS["worker_mode"]
for key in ("worker_service_name", "api_service_name", "sync_agent_service_name"):
value = str(merged.get(key) or "").strip()
merged[key] = value or DEFAULT_RUNTIME_SETTINGS[key]
merged["worker_log_sync_enabled"] = bool(merged.get("worker_log_sync_enabled", False))
merged["worker_log_sync_mode"] = _normalize_worker_log_sync_mode(merged.get("worker_log_sync_mode"))
return merged
def get_runtime_settings() -> dict: def get_runtime_settings() -> dict:
stored = read_runtime_json("runtime_settings.json", default={}) stored = read_runtime_json("runtime_settings.json", default={})
result = dict(DEFAULT_RUNTIME_SETTINGS) return normalize_runtime_settings(stored)
result.update(stored or {})
return result
def update_runtime_settings(payload: dict) -> dict: def update_runtime_settings(payload: dict) -> dict:
current = get_runtime_settings() merged = normalize_runtime_settings({**get_runtime_settings(), **(payload or {})})
merged = dict(current)
for key in DEFAULT_RUNTIME_SETTINGS:
if key in payload and payload[key] is not None:
merged[key] = payload[key]
write_runtime_json("runtime_settings.json", merged) write_runtime_json("runtime_settings.json", merged)
return merged return merged

View File

@@ -72,6 +72,19 @@ def _build_multi_region_readiness(
warning_issues: list[str] = [] warning_issues: list[str] = []
info_items: list[str] = [] info_items: list[str] = []
online_pairs = {
(
str(node.get("region") or "").strip(),
str(node.get("role") or "").strip(),
)
for node in nodes
if str(node.get("status") or "") in {"online", "busy"}
}
critical_stale_nodes: list[str] = []
redundant_stale_nodes: list[str] = []
critical_offline_nodes: list[str] = []
redundant_offline_nodes: list[str] = []
if online_control_nodes <= 0: if online_control_nodes <= 0:
blocking_issues.append("当前没有在线控制面节点,无法视为正式可用集群。") blocking_issues.append("当前没有在线控制面节点,无法视为正式可用集群。")
@@ -89,12 +102,33 @@ def _build_multi_region_readiness(
if online_worker_nodes <= 0: if online_worker_nodes <= 0:
warning_issues.append("当前没有在线 Worker 节点,检测任务无法在多机状态下继续推进。") warning_issues.append("当前没有在线 Worker 节点,检测任务无法在多机状态下继续推进。")
offline_nodes = list(summary.get("offline_nodes") or []) for node in nodes:
stale_nodes = list(summary.get("stale_nodes") or []) node_status = str(node.get("status") or "").strip()
if stale_nodes: if node_status not in {"stale", "offline"}:
warning_issues.append(f"存在失活节点: {''.join(stale_nodes)}") continue
if offline_nodes: node_code = str(node.get("node_code") or "").strip()
warning_issues.append(f"存在离线节点: {''.join(offline_nodes)}") node_region = str(node.get("region") or "").strip()
node_role = str(node.get("role") or "").strip()
metadata = node.get("metadata") or {}
replaced_by_online_peer = (node_region, node_role) in online_pairs
redundant = replaced_by_online_peer and (
node_role == "control"
or not bool(node.get("is_effective_worker", False))
or str(metadata.get("service") or "") == "runtime-ingest"
)
if node_status == "stale":
(redundant_stale_nodes if redundant else critical_stale_nodes).append(node_code)
else:
(redundant_offline_nodes if redundant else critical_offline_nodes).append(node_code)
if critical_stale_nodes:
warning_issues.append(f"存在失活节点: {''.join(critical_stale_nodes)}")
if critical_offline_nodes:
warning_issues.append(f"存在离线节点: {''.join(critical_offline_nodes)}")
if redundant_stale_nodes:
info_items.append(f"存在历史失活节点(已被在线同类节点覆盖): {''.join(redundant_stale_nodes)}")
if redundant_offline_nodes:
info_items.append(f"存在历史离线节点(已被在线同类节点覆盖): {''.join(redundant_offline_nodes)}")
failed_batches = int(batch_states.get("failed", 0) or 0) failed_batches = int(batch_states.get("failed", 0) or 0)
projected_batches = int(batch_states.get("projected", 0) or 0) projected_batches = int(batch_states.get("projected", 0) or 0)

View File

@@ -15,6 +15,7 @@ REDIS_KEYS = {
"thread_count": "domain_tool:thread_count", "thread_count": "domain_tool:thread_count",
"node_thread_counts": "domain_tool:node_thread_counts", "node_thread_counts": "domain_tool:node_thread_counts",
"credentials": "domain_tool:credentials", "credentials": "domain_tool:credentials",
"runtime_settings": "domain_tool:runtime_settings",
} }
DETECT_OPTION_KEYS = { DETECT_OPTION_KEYS = {
@@ -198,7 +199,6 @@ def update_settings_payload(payload: dict) -> dict:
write_json("proxy_config.json", proxy_config) write_json("proxy_config.json", proxy_config)
write_json("thread_count.json", {"thread_count": str(thread_count)}) write_json("thread_count.json", {"thread_count": str(thread_count)})
write_json("node_thread_counts.json", node_thread_counts) write_json("node_thread_counts.json", node_thread_counts)
redis_client = get_redis() redis_client = get_redis()
try: try:
redis_client.set(REDIS_KEYS["detect_options"], json.dumps(detect_options, ensure_ascii=False)) redis_client.set(REDIS_KEYS["detect_options"], json.dumps(detect_options, ensure_ascii=False))
@@ -209,6 +209,12 @@ def update_settings_payload(payload: dict) -> dict:
redis_client.publish("domain_tool:thread_count:update", str(thread_count)) redis_client.publish("domain_tool:thread_count:update", str(thread_count))
redis_client.set(REDIS_KEYS["node_thread_counts"], json.dumps(node_thread_counts, ensure_ascii=False)) redis_client.set(REDIS_KEYS["node_thread_counts"], json.dumps(node_thread_counts, ensure_ascii=False))
redis_client.publish("domain_tool:node_thread_counts:update", json.dumps(node_thread_counts, ensure_ascii=False)) redis_client.publish("domain_tool:node_thread_counts:update", json.dumps(node_thread_counts, ensure_ascii=False))
redis_client.set(REDIS_KEYS["runtime_settings"], json.dumps(runtime_settings, ensure_ascii=False))
redis_client.publish("domain_tool:config_update", "runtime_settings")
redis_client.publish("domain_tool:config_update", "node_thread_counts")
redis_client.publish("domain_tool:config_update", "detect_options")
redis_client.publish("domain_tool:config_update", "proxy_config")
redis_client.publish("domain_tool:config_update", "thread_count")
except Exception: except Exception:
pass pass
@@ -278,6 +284,12 @@ def validate_settings_payload(payload: dict) -> None:
for key in ("worker_service_name", "api_service_name", "sync_agent_service_name"): for key in ("worker_service_name", "api_service_name", "sync_agent_service_name"):
if key in runtime_settings and runtime_settings[key] is not None and not str(runtime_settings[key]).strip(): if key in runtime_settings and runtime_settings[key] is not None and not str(runtime_settings[key]).strip():
raise ValueError(f"{key} must not be empty") raise ValueError(f"{key} must not be empty")
if "worker_log_sync_enabled" in runtime_settings and not isinstance(runtime_settings["worker_log_sync_enabled"], bool):
raise ValueError("worker_log_sync_enabled must be a boolean")
if "worker_log_sync_mode" in runtime_settings:
worker_log_sync_mode = str(runtime_settings.get("worker_log_sync_mode") or "").strip().lower()
if worker_log_sync_mode not in {"key", "full"}:
raise ValueError("worker_log_sync_mode must be key or full")
def backup_current_settings(reason: str = "manual") -> dict: def backup_current_settings(reason: str = "manual") -> dict:

View File

@@ -908,11 +908,11 @@ def _push_projection_record(source_record: dict, sync_type: str, ingest_url: str
def push_runtime_projection_now() -> tuple[bool, str, dict]: def push_runtime_projection_now() -> tuple[bool, str, dict]:
if not settings.sync_push_enabled: if not settings.sync_push_enabled:
return False, "未启用同步推送", {"action": "push_sync"} return False, "未启用同步推送", {"action": "push_sync", "sync_state": "disabled", "ui_level": "warning", "poll_schedule_seconds": []}
ingest_url = _ingest_url(settings.sync_target_api_base_url) ingest_url = _ingest_url(settings.sync_target_api_base_url)
if not ingest_url: if not ingest_url:
return False, "未配置同步目标地址", {"action": "push_sync"} return False, "未配置同步目标地址", {"action": "push_sync", "sync_state": "misconfigured", "ui_level": "warning", "poll_schedule_seconds": []}
results = [] results = []
ok, message, data = _push_projection_now("runtime_projection", ingest_url) ok, message, data = _push_projection_now("runtime_projection", ingest_url)
@@ -922,19 +922,52 @@ def push_runtime_projection_now() -> tuple[bool, str, dict]:
results.append({"sync_type": "detect_result_projection", "ok": ok, "message": message, "data": data}) results.append({"sync_type": "detect_result_projection", "ok": ok, "message": message, "data": data})
success_count = sum(1 for item in results if item["ok"]) success_count = sum(1 for item in results if item["ok"])
warning_count = sum(
1
for item in results
if not item["ok"] and any(keyword in str(item.get("message") or "") for keyword in ("当前没有可推送", "已全部同步完成", "无需重复发送", "进行中", "等待下个重试窗口"))
)
if success_count == 0: if success_count == 0:
return False, "当前没有成功推送的同步投影", {"action": "push_sync", "results": results} if warning_count == len(results):
return True, f"同步推送完成,成功 {success_count}/{len(results)}", {"action": "push_sync", "results": results} return False, "当前没有需要立即推送的同步投影", {
"action": "push_sync",
"sync_state": "idle",
"ui_level": "warning",
"poll_schedule_seconds": [],
"results": results,
}
return False, "同步推送未成功,请检查明细结果", {
"action": "push_sync",
"sync_state": "failed",
"ui_level": "error",
"poll_schedule_seconds": [2],
"results": results,
}
if success_count < len(results):
return True, f"同步推送部分完成,成功 {success_count}/{len(results)}", {
"action": "push_sync",
"sync_state": "partial_success",
"ui_level": "warning",
"poll_schedule_seconds": [1, 3],
"results": results,
}
return True, f"同步推送完成,成功 {success_count}/{len(results)}", {
"action": "push_sync",
"sync_state": "success",
"ui_level": "success",
"poll_schedule_seconds": [1, 3],
"results": results,
}
def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dict]: def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dict]:
if settings.node_region != "mainland" or settings.node_role != "control": if settings.node_region != "mainland" or settings.node_role != "control":
return False, "当前节点无需拉取待检测任务批次", {"action": "pull_tasks"} return False, "当前节点无需拉取待检测任务批次", {"action": "pull_tasks", "pull_state": "not_applicable", "ui_level": "warning", "poll_schedule_seconds": []}
export_url = _task_export_url(settings.sync_target_api_base_url) export_url = _task_export_url(settings.sync_target_api_base_url)
ack_url = _task_ack_url(settings.sync_target_api_base_url) ack_url = _task_ack_url(settings.sync_target_api_base_url)
if not export_url or not ack_url: if not export_url or not ack_url:
return False, "未配置任务拉取目标地址", {"action": "pull_tasks"} return False, "未配置任务拉取目标地址", {"action": "pull_tasks", "pull_state": "misconfigured", "ui_level": "warning", "poll_schedule_seconds": []}
safe_limit = max(1, min(int(limit or settings.sync_batch_size or 200), max(1, int(settings.sync_batch_size or 200)))) safe_limit = max(1, min(int(limit or settings.sync_batch_size or 200), max(1, int(settings.sync_batch_size or 200))))
request_url = f"{export_url}?limit={safe_limit}" request_url = f"{export_url}?limit={safe_limit}"
@@ -948,27 +981,30 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic
raw = response.read().decode("utf-8") raw = response.read().decode("utf-8")
data = json.loads(raw) if raw else {} data = json.loads(raw) if raw else {}
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
return False, f"拉取待检测任务失败: 远端响应不是合法 JSON ({exc})", {"action": "pull_tasks"} return False, f"拉取待检测任务失败: 远端响应不是合法 JSON ({exc})", {"action": "pull_tasks", "pull_state": "failed", "ui_level": "error", "poll_schedule_seconds": [2]}
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="ignore") if hasattr(exc, "read") else "" error_body = exc.read().decode("utf-8", errors="ignore") if hasattr(exc, "read") else ""
return False, f"拉取待检测任务失败: HTTP {getattr(exc, 'code', 500)}", { return False, f"拉取待检测任务失败: HTTP {getattr(exc, 'code', 500)}", {
"action": "pull_tasks", "action": "pull_tasks",
"pull_state": "failed",
"ui_level": "error",
"poll_schedule_seconds": [2],
"http_status": getattr(exc, "code", 500), "http_status": getattr(exc, "code", 500),
"response_text": error_body[:500], "response_text": error_body[:500],
} }
except Exception as exc: except Exception as exc:
return False, f"拉取待检测任务失败: {exc}", {"action": "pull_tasks"} return False, f"拉取待检测任务失败: {exc}", {"action": "pull_tasks", "pull_state": "failed", "ui_level": "error", "poll_schedule_seconds": [2]}
payload = data.get("data") or {} payload = data.get("data") or {}
response_code = data.get("code", 0) response_code = data.get("code", 0)
if response_code not in (0, "0", None) and not payload: if response_code not in (0, "0", None) and not payload:
return False, str(data.get("message") or "拉取待检测任务失败"), {"action": "pull_tasks", "response": data} return False, str(data.get("message") or "拉取待检测任务失败"), {"action": "pull_tasks", "pull_state": "failed", "ui_level": "error", "poll_schedule_seconds": [2], "response": data}
source_record_id = int(payload.get("source_record_id") or 0) source_record_id = int(payload.get("source_record_id") or 0)
projection_hash = str(payload.get("projection_hash") or "").strip() projection_hash = str(payload.get("projection_hash") or "").strip()
projection = payload.get("projection") or {} projection = payload.get("projection") or {}
if source_record_id <= 0 or not projection_hash or not projection: if source_record_id <= 0 or not projection_hash or not projection:
return False, "远端当前没有可拉取的待检测任务批次", {"action": "pull_tasks", "batch_size": 0} return False, "远端当前没有可拉取的待检测任务批次", {"action": "pull_tasks", "pull_state": "idle", "ui_level": "warning", "poll_schedule_seconds": [], "batch_size": 0}
ingest_ok, ingest_message, ingest_data = ingest_detect_task_projection( ingest_ok, ingest_message, ingest_data = ingest_detect_task_projection(
{ {
@@ -981,7 +1017,7 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic
shared_token=settings.sync_shared_token, shared_token=settings.sync_shared_token,
) )
if not ingest_ok: if not ingest_ok:
return False, ingest_message, {"action": "pull_tasks", **(ingest_data or {})} return False, ingest_message, {"action": "pull_tasks", "pull_state": "failed", "ui_level": "error", "poll_schedule_seconds": [2], **(ingest_data or {})}
ack_request = urllib.request.Request( ack_request = urllib.request.Request(
ack_url, ack_url,
@@ -1008,6 +1044,9 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic
if ack_code not in (0, "0", None, ""): if ack_code not in (0, "0", None, ""):
return True, f"{ingest_message};但远端确认失败: {str(ack_response.get('message') or 'ack business error')}", { return True, f"{ingest_message};但远端确认失败: {str(ack_response.get('message') or 'ack business error')}", {
"action": "pull_tasks", "action": "pull_tasks",
"pull_state": "ack_warning",
"ui_level": "warning",
"poll_schedule_seconds": [1, 3],
"source_record_id": source_record_id, "source_record_id": source_record_id,
"projection_hash": projection_hash, "projection_hash": projection_hash,
**(ingest_data or {}), **(ingest_data or {}),
@@ -1017,18 +1056,27 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
return True, f"{ingest_message};但远端确认失败: ack 响应不是合法 JSON ({exc})", { return True, f"{ingest_message};但远端确认失败: ack 响应不是合法 JSON ({exc})", {
"action": "pull_tasks", "action": "pull_tasks",
"pull_state": "ack_warning",
"ui_level": "warning",
"poll_schedule_seconds": [1, 3],
"source_record_id": source_record_id, "source_record_id": source_record_id,
**(ingest_data or {}), **(ingest_data or {}),
} }
except Exception as exc: except Exception as exc:
return True, f"{ingest_message};但远端确认失败: {exc}", { return True, f"{ingest_message};但远端确认失败: {exc}", {
"action": "pull_tasks", "action": "pull_tasks",
"pull_state": "ack_warning",
"ui_level": "warning",
"poll_schedule_seconds": [1, 3],
"source_record_id": source_record_id, "source_record_id": source_record_id,
**(ingest_data or {}), **(ingest_data or {}),
} }
return True, "待检测任务批次拉取并入库成功", { return True, "待检测任务批次拉取并入库成功", {
"action": "pull_tasks", "action": "pull_tasks",
"pull_state": "success",
"ui_level": "success",
"poll_schedule_seconds": [1, 3],
"source_record_id": source_record_id, "source_record_id": source_record_id,
"projection_hash": projection_hash, "projection_hash": projection_hash,
"batch_code": str(projection.get("batch_code") or "").strip(), "batch_code": str(projection.get("batch_code") or "").strip(),

View File

@@ -99,7 +99,7 @@ def _should_append_runtime_projection(previous_payload: dict, current_projection
previous_cluster = previous_projection.get("cluster_summary") or {} previous_cluster = previous_projection.get("cluster_summary") or {}
current_cluster = current_projection.get("cluster_summary") or {} current_cluster = current_projection.get("cluster_summary") or {}
for key in ("online_worker_nodes", "online_control_nodes", "busy_nodes", "stale_nodes", "offline_nodes"): for key in ("online_worker_nodes", "dedicated_online_worker_nodes", "online_control_nodes", "busy_nodes", "stale_nodes", "offline_nodes"):
if previous_cluster.get(key) != current_cluster.get(key): if previous_cluster.get(key) != current_cluster.get(key):
return True return True
@@ -480,6 +480,7 @@ def append_runtime_projection_if_changed(
"cluster_summary": { "cluster_summary": {
"nodes_total": int(cluster.get("nodes_total", 0) or 0), "nodes_total": int(cluster.get("nodes_total", 0) or 0),
"online_worker_nodes": int((cluster.get("summary") or {}).get("online_worker_nodes", 0) or 0), "online_worker_nodes": int((cluster.get("summary") or {}).get("online_worker_nodes", 0) or 0),
"dedicated_online_worker_nodes": int((cluster.get("summary") or {}).get("dedicated_online_worker_nodes", 0) or 0),
"online_control_nodes": int((cluster.get("summary") or {}).get("online_control_nodes", 0) or 0), "online_control_nodes": int((cluster.get("summary") or {}).get("online_control_nodes", 0) or 0),
"busy_nodes": list((cluster.get("summary") or {}).get("busy_nodes") or []), "busy_nodes": list((cluster.get("summary") or {}).get("busy_nodes") or []),
"stale_nodes": list((cluster.get("summary") or {}).get("stale_nodes") or []), "stale_nodes": list((cluster.get("summary") or {}).get("stale_nodes") or []),

View File

@@ -51,6 +51,28 @@ def _run_systemctl(
return _run_shell(["sudo", "-n", *systemctl_command], timeout=timeout) return _run_shell(["sudo", "-n", *systemctl_command], timeout=timeout)
def normalize_systemctl_error(raw_message: str, *, service_name: str = "") -> str:
message = str(raw_message or "").strip()
normalized_service_name = str(service_name or "").strip()
if not message:
target = normalized_service_name or "目标服务"
return f"{target} 控制失败,未返回可用错误信息"
lowered = message.lower()
if "sudo: a password is required" in lowered or "authentication is required" in lowered:
target = normalized_service_name or "systemd 服务"
return f"{target} 控制失败:当前运行用户没有免密 systemctl 权限,请为 API 进程授予对应 sudo/systemd 权限"
if "unit " in lowered and " could not be found" in lowered:
target = normalized_service_name or "目标服务"
return f"{target} 控制失败systemd 中未找到该服务,请检查服务名配置是否正确"
if "access denied" in lowered or "permission denied" in lowered:
target = normalized_service_name or "systemd 服务"
return f"{target} 控制失败:权限不足,请检查当前 API 进程用户是否具备 systemctl 控制权限"
if "host is down" in lowered or "failed to connect to bus" in lowered:
return "systemctl 调用失败:当前环境未接入可用的 systemd/dbus请确认部署方式与 worker_mode 是否匹配"
return message
def _parse_systemd_timestamp(raw_timestamp: str) -> str: def _parse_systemd_timestamp(raw_timestamp: str) -> str:
raw_timestamp = (raw_timestamp or "").strip() raw_timestamp = (raw_timestamp or "").strip()
if not raw_timestamp: if not raw_timestamp:
@@ -206,7 +228,7 @@ def start_worker() -> tuple[bool, str]:
if worker_mode == "linux-systemd": if worker_mode == "linux-systemd":
result = _run_systemctl(["start", service_name], timeout=30) result = _run_systemctl(["start", service_name], timeout=30)
if result.returncode != 0: if result.returncode != 0:
return False, (result.stderr or result.stdout or "启动 Linux Worker 失败").strip() return False, normalize_systemctl_error(result.stderr or result.stdout or "启动 Linux Worker 失败", service_name=service_name)
return True, f"Linux Worker 启动命令已发送: {service_name}" return True, f"Linux Worker 启动命令已发送: {service_name}"
if os.name != "nt": if os.name != "nt":
@@ -234,7 +256,7 @@ def stop_worker() -> tuple[bool, str]:
if worker_mode == "linux-systemd": if worker_mode == "linux-systemd":
result = _run_systemctl(["stop", service_name], timeout=30) result = _run_systemctl(["stop", service_name], timeout=30)
if result.returncode != 0: if result.returncode != 0:
return False, (result.stderr or result.stdout or "停止 Linux Worker 失败").strip() return False, normalize_systemctl_error(result.stderr or result.stdout or "停止 Linux Worker 失败", service_name=service_name)
return True, f"Linux Worker 停止命令已发送: {service_name}" return True, f"Linux Worker 停止命令已发送: {service_name}"
if os.name != "nt": if os.name != "nt":

View File

@@ -24,8 +24,11 @@
<span class="status-pill" :class="runtime.apiOnline ? 'online' : 'offline'"> <span class="status-pill" :class="runtime.apiOnline ? 'online' : 'offline'">
API {{ runtime.apiOnline ? "在线" : "离线" }} API {{ runtime.apiOnline ? "在线" : "离线" }}
</span> </span>
<span class="status-pill" :class="runtime.workerOnline ? 'online' : 'offline'"> <span class="status-pill" :class="runtime.localWorkerOnline ? 'online' : 'offline'">
Worker {{ runtime.workerOnline ? "在线" : "离线" }} 本机 Worker {{ runtime.localWorkerOnline ? "在线" : "离线" }}
</span>
<span class="status-pill" :class="runtime.effectiveWorkerNodes > 0 ? 'online' : 'offline'">
有效执行节点 {{ runtime.effectiveWorkerNodes }}
</span> </span>
<span class="status-pill neutral">{{ runtime.workerMode }}</span> <span class="status-pill neutral">{{ runtime.workerMode }}</span>
</div> </div>
@@ -53,7 +56,8 @@ const authStore = useAuthStore();
const runtime = ref({ const runtime = ref({
apiOnline: false, apiOnline: false,
workerOnline: false, localWorkerOnline: false,
effectiveWorkerNodes: 0,
workerMode: "windows-local" workerMode: "windows-local"
}); });
let timer: number | null = null; let timer: number | null = null;
@@ -77,15 +81,18 @@ const title = computed(() => String(route.meta?.title || "概览"));
const loadRuntimeSummary = async () => { const loadRuntimeSummary = async () => {
try { try {
const response = await runtimeApi.status(); const response = await runtimeApi.status();
const summary = response.data?.cluster?.summary || {};
runtime.value = { runtime.value = {
apiOnline: Boolean(response.data?.api?.pid), apiOnline: Boolean(response.data?.api?.pid),
workerOnline: Boolean(response.data?.worker?.running), localWorkerOnline: Boolean(response.data?.worker?.running),
effectiveWorkerNodes: Number(summary.online_worker_nodes || 0),
workerMode: response.data?.worker?.mode || "windows-local" workerMode: response.data?.worker?.mode || "windows-local"
}; };
} catch { } catch {
runtime.value = { runtime.value = {
apiOnline: false, apiOnline: false,
workerOnline: false, localWorkerOnline: false,
effectiveWorkerNodes: 0,
workerMode: "unknown" workerMode: "unknown"
}; };
} }

View File

@@ -50,7 +50,8 @@ const stats = ref([
{ label: "待检测", value: "--", note: "当前待处理域名" }, { label: "待检测", value: "--", note: "当前待处理域名" },
{ label: "黑名单", value: "--", note: "命中风险域名" }, { label: "黑名单", value: "--", note: "命中风险域名" },
{ label: "API", value: "离线", note: "当前 API 运行状态" }, { label: "API", value: "离线", note: "当前 API 运行状态" },
{ label: "Worker", value: "离线", note: "当前 Worker 运行状态" }, { label: "本机 Worker", value: "离线", note: "当前节点本机 Worker 运行状态" },
{ label: "有效执行节点", value: "离线", note: "当前集群可承担检测任务的在线节点" },
{ label: "模式", value: "--", note: "当前 Worker 运行模式" } { label: "模式", value: "--", note: "当前 Worker 运行模式" }
]); ]);
@@ -84,7 +85,12 @@ const loadOverview = async (showError = true) => {
{ label: "待检测", value: String(data.pending_total), note: "当前待处理域名" }, { label: "待检测", value: String(data.pending_total), note: "当前待处理域名" },
{ label: "黑名单", value: String(data.blacklist_total), note: "命中风险域名" }, { label: "黑名单", value: String(data.blacklist_total), note: "命中风险域名" },
{ label: "API", value: data.api_status, note: "当前 API 运行状态" }, { label: "API", value: data.api_status, note: "当前 API 运行状态" },
{ label: "Worker", value: data.worker_status, note: "当前 Worker 运行状态" }, { label: "本机 Worker", value: data.local_worker_status || data.worker_status, note: "当前节点本机 Worker 运行状态" },
{
label: "有效执行节点",
value: `${data.cluster_worker_status || "offline"} / ${data.cluster_online_worker_nodes || 0}`,
note: `可执行检测的在线节点,独立 Worker ${data.cluster_dedicated_online_worker_nodes || 0},控制面 ${data.cluster_online_control_nodes || 0}`
},
{ label: "模式", value: data.worker_mode, note: "当前 Worker 运行模式" } { label: "模式", value: data.worker_mode, note: "当前 Worker 运行模式" }
]; ];
loadError.value = false; loadError.value = false;

View File

@@ -5,6 +5,11 @@
<el-button :loading="actionLoading === 'stop'" @click="invoke('stop')">停止检测</el-button> <el-button :loading="actionLoading === 'stop'" @click="invoke('stop')">停止检测</el-button>
<el-button plain :loading="loading" @click="loadStatus">刷新状态</el-button> <el-button plain :loading="loading" @click="loadStatus">刷新状态</el-button>
<el-button plain @click="goRuntime">前往运行中心</el-button> <el-button plain @click="goRuntime">前往运行中心</el-button>
<el-button-group>
<el-button plain :type="status.worker_log_sync_enabled ? '' : 'primary'" @click="updateWorkerLogSync(false, 'key')">关闭回传</el-button>
<el-button plain :type="status.worker_log_sync_enabled && status.worker_log_sync_mode === 'key' ? 'primary' : ''" @click="updateWorkerLogSync(true, 'key')">关键回传</el-button>
<el-button plain :type="status.worker_log_sync_enabled && status.worker_log_sync_mode === 'full' ? 'primary' : ''" @click="updateWorkerLogSync(true, 'full')">全量回传</el-button>
</el-button-group>
<el-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" /> <el-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" />
<span class="updated-at">最近同步{{ lastUpdatedAt || "暂无" }}</span> <span class="updated-at">最近同步{{ lastUpdatedAt || "暂无" }}</span>
</div> </div>
@@ -19,7 +24,7 @@
/> />
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="Worker 在线">{{ status.worker_online ? "是" : "否" }}</el-descriptions-item> <el-descriptions-item label="本机 Worker 在线">{{ status.worker_online ? "是" : "否" }}</el-descriptions-item>
<el-descriptions-item label="运行模式">{{ status.worker_mode || "windows-local" }}</el-descriptions-item> <el-descriptions-item label="运行模式">{{ status.worker_mode || "windows-local" }}</el-descriptions-item>
<el-descriptions-item label="Worker 服务">{{ status.worker_service_name || "-" }}</el-descriptions-item> <el-descriptions-item label="Worker 服务">{{ status.worker_service_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="API 服务">{{ status.api_service_name || "-" }}</el-descriptions-item> <el-descriptions-item label="API 服务">{{ status.api_service_name || "-" }}</el-descriptions-item>
@@ -35,6 +40,8 @@
<el-descriptions-item label="最近代理刷新">{{ status.proxy_last_refresh_time || "暂无" }}</el-descriptions-item> <el-descriptions-item label="最近代理刷新">{{ status.proxy_last_refresh_time || "暂无" }}</el-descriptions-item>
<el-descriptions-item label="最近 Worker 日志时间">{{ status.last_worker_log_time || "暂无" }}</el-descriptions-item> <el-descriptions-item label="最近 Worker 日志时间">{{ status.last_worker_log_time || "暂无" }}</el-descriptions-item>
<el-descriptions-item label="最近启动时间">{{ status.worker_latest_start_time || "暂无" }}</el-descriptions-item> <el-descriptions-item label="最近启动时间">{{ status.worker_latest_start_time || "暂无" }}</el-descriptions-item>
<el-descriptions-item label="远端日志回传">{{ status.worker_log_sync_enabled ? "已开启" : "已关闭" }}</el-descriptions-item>
<el-descriptions-item label="回传级别">{{ status.worker_log_sync_enabled ? (status.worker_log_sync_mode === "full" ? "全量" : "关键") : "-" }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-alert <el-alert
@@ -90,7 +97,7 @@
:closable="false" :closable="false"
show-icon show-icon
type="info" type="info"
:title="`当前检测状态:${currentPhaseLabel}Worker ${status.worker_online ? '在线' : '离线'}。`" :title="`当前检测状态:${currentPhaseLabel}本机 Worker ${status.worker_online ? '在线' : '离线'}。`"
style="margin-top: 12px" style="margin-top: 12px"
/> />
@@ -261,7 +268,7 @@
<div class="task-detail-meta"> <div class="task-detail-meta">
<el-switch v-model="logAutoFollow" inline-prompt active-text="跟随日志" inactive-text="暂停跟随" size="small" /> <el-switch v-model="logAutoFollow" inline-prompt active-text="跟随日志" inactive-text="暂停跟随" size="small" />
<el-button text size="small" @click="scrollLogToBottom(true)">回到底部</el-button> <el-button text size="small" @click="scrollLogToBottom(true)">回到底部</el-button>
<span>Worker{{ status.worker_online ? "在线" : "离线" }}</span> <span>本机 Worker{{ status.worker_online ? "在线" : "离线" }}</span>
<span v-if="selectedRun?.phase_label">阶段{{ selectedRun.phase_label }}</span> <span v-if="selectedRun?.phase_label">阶段{{ selectedRun.phase_label }}</span>
<span>运行中{{ status.progress.running || 0 }}</span> <span>运行中{{ status.progress.running || 0 }}</span>
<span>线程{{ threadCountSummary }}</span> <span>线程{{ threadCountSummary }}</span>
@@ -284,7 +291,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import PageCard from "@/components/PageCard.vue"; import PageCard from "@/components/PageCard.vue";
import { detectApi } from "@/api/modules"; import { detectApi, settingsApi } from "@/api/modules";
const DETECT_LAST_ACTION_KEY = "domaincheck:detect:last-action"; const DETECT_LAST_ACTION_KEY = "domaincheck:detect:last-action";
const router = useRouter(); const router = useRouter();
@@ -320,7 +327,10 @@ const status = ref({
proxy_last_refresh_time: "", proxy_last_refresh_time: "",
dependency_alerts: [] as any[], dependency_alerts: [] as any[],
log_lines: [] as string[], log_lines: [] as string[],
remote_log_lines: [] as string[],
runs: [] as any[], runs: [] as any[],
worker_log_sync_enabled: false,
worker_log_sync_mode: "key",
progress_percent: 0, progress_percent: 0,
active_job: null as any, active_job: null as any,
progress: { progress: {
@@ -424,13 +434,44 @@ const phaseHistory = computed(() => {
if (!Array.isArray(items)) return []; if (!Array.isArray(items)) return [];
return [...items].reverse(); return [...items].reverse();
}); });
const selectedRunIsLatest = computed(() => {
if (!selectedRun.value || !runs.value.length) {
return false;
}
return selectedRun.value.run_id === runs.value[0]?.run_id;
});
const dedupeConsecutiveLines = (lines: string[]) => {
const result: string[] = [];
for (const rawLine of lines) {
const line = String(rawLine || "");
if (!line) {
continue;
}
if (result[result.length - 1] === line) {
continue;
}
result.push(line);
}
return result;
};
const selectedLogText = computed(() => { const selectedLogText = computed(() => {
const runLogs = selectedRun.value?.logs; const runLogs = selectedRun.value?.logs;
const mergedLines: string[] = [];
if (Array.isArray(runLogs) && runLogs.length) { if (Array.isArray(runLogs) && runLogs.length) {
return runLogs.join("\n"); mergedLines.push(...runLogs);
}
const remoteLogLines = status.value.remote_log_lines || [];
const canAppendRemoteLogs = !selectedRun.value || selectedRunIsLatest.value;
if (canAppendRemoteLogs && Array.isArray(remoteLogLines) && remoteLogLines.length) {
mergedLines.push(...remoteLogLines);
}
if (mergedLines.length) {
return dedupeConsecutiveLines(mergedLines).join("\n");
} }
const liveLogs = status.value.log_lines || []; const liveLogs = status.value.log_lines || [];
return liveLogs.length ? liveLogs.join("\n") : "暂无检测日志"; return liveLogs.length ? dedupeConsecutiveLines(liveLogs).join("\n") : "暂无检测日志";
}); });
const proxyAlertType = computed<"success" | "warning" | "info" | "error">(() => { const proxyAlertType = computed<"success" | "warning" | "info" | "error">(() => {
if (status.value.proxy_runtime_state === "healthy") return "success"; if (status.value.proxy_runtime_state === "healthy") return "success";
@@ -580,18 +621,35 @@ const invoke = async (action: "start" | "stop") => {
actionLoading.value = action; actionLoading.value = action;
try { try {
const response = action === "start" ? await detectApi.start() : await detectApi.stop(); const response = action === "start" ? await detectApi.start() : await detectApi.stop();
ElMessage.success(response.message); const responseMessage = String(response.message || "");
const uiLevel = String(response.data?.ui_level || "").trim().toLowerCase();
const actionType: "success" | "warning" | "error" =
uiLevel === "error" ? "error" : uiLevel === "warning" ? "warning" : "success";
if (actionType === "error") {
ElMessage.error(responseMessage);
} else if (actionType === "warning") {
ElMessage.warning(responseMessage);
} else {
ElMessage.success(responseMessage);
}
lastAction.value = { lastAction.value = {
label: action === "start" ? "检测启动" : "检测停止", label: action === "start" ? "检测启动" : "检测停止",
message: response.message, message: responseMessage,
at: new Date().toLocaleString("zh-CN", { hour12: false }), at: new Date().toLocaleString("zh-CN", { hour12: false }),
type: "success" type: actionType
}; };
persistLastAction(); persistLastAction();
const pollDelaySeconds = Number(response.data?.poll_after_seconds || 2); await loadStatus(false);
window.setTimeout(() => { const pollSchedule = Array.isArray(response.data?.poll_schedule_seconds)
loadStatus(false); ? response.data.poll_schedule_seconds
}, pollDelaySeconds * 1000); : [Number(response.data?.poll_after_seconds || 2)];
[...new Set(pollSchedule.map((item: unknown) => Number(item || 0)).filter((item: number) => item > 0))]
.sort((a, b) => a - b)
.forEach((seconds) => {
window.setTimeout(() => {
loadStatus(false);
}, seconds * 1000);
});
} catch (error: any) { } catch (error: any) {
const message = error?.message || `${action === "start" ? "启动" : "停止"}操作失败`; const message = error?.message || `${action === "start" ? "启动" : "停止"}操作失败`;
lastAction.value = { lastAction.value = {
@@ -615,6 +673,25 @@ const goRuntime = () => {
router.push("/runtime"); router.push("/runtime");
}; };
const updateWorkerLogSync = async (enabled: boolean, mode: "key" | "full") => {
try {
const current = await settingsApi.getSettings();
const payload = {
...current.data,
runtime_settings: {
...(current.data?.runtime_settings || {}),
worker_log_sync_enabled: enabled,
worker_log_sync_mode: mode
}
};
await settingsApi.updateSettings(payload);
ElMessage.success(`远端日志回传已${enabled ? `切到${mode === "full" ? "全量" : "关键"}模式` : "关闭"}`);
await loadStatus();
} catch {
ElMessage.error("更新远端日志回传开关失败");
}
};
watch(autoRefresh, ensureRefreshTimer); watch(autoRefresh, ensureRefreshTimer);
watch(selectedLogText, async (current, previous) => { watch(selectedLogText, async (current, previous) => {
if (current === previous) { if (current === previous) {

View File

@@ -22,9 +22,9 @@
<el-tag :type="runtime.api?.pid ? 'success' : 'info'">{{ runtime.api?.pid ? "运行中" : "未知" }}</el-tag> <el-tag :type="runtime.api?.pid ? 'success' : 'info'">{{ runtime.api?.pid ? "运行中" : "未知" }}</el-tag>
</div> </div>
<div class="summary-card"> <div class="summary-card">
<div class="summary-title">Worker 状态</div> <div class="summary-title">本机 Worker</div>
<div class="summary-value">{{ runtime.worker?.service_name || "-" }}</div> <div class="summary-value">{{ runtime.worker?.service_name || "-" }}</div>
<el-tag :type="runtime.worker?.running ? 'success' : 'danger'">{{ runtime.worker?.running ? "在线" : "离线" }}</el-tag> <el-tag :type="runtime.worker?.running ? 'success' : 'danger'">{{ runtime.worker?.running ? "本机在线" : "本机离线" }}</el-tag>
</div> </div>
<div class="summary-card"> <div class="summary-card">
<div class="summary-title">运行模式</div> <div class="summary-title">运行模式</div>
@@ -68,10 +68,12 @@
<div class="summary-note">当前已接入控制面与执行面节点总数</div> <div class="summary-note">当前已接入控制面与执行面节点总数</div>
</div> </div>
<div class="summary-card"> <div class="summary-card">
<div class="summary-title">在线 Worker</div> <div class="summary-title">有效执行节点</div>
<div class="summary-value">{{ runtime.cluster?.summary?.online_worker_nodes || 0 }}</div> <div class="summary-value">{{ runtime.cluster?.summary?.online_worker_nodes || 0 }}</div>
<div class="summary-note"> <div class="summary-note">
控制面 {{ runtime.cluster?.summary?.online_control_nodes || 0 }} 可承担检测任务的在线节点
/ 独立 Worker {{ runtime.cluster?.summary?.dedicated_online_worker_nodes || 0 }}
/ 控制面 {{ runtime.cluster?.summary?.online_control_nodes || 0 }}
/ 忙碌 {{ runtime.cluster?.summary?.status_counts?.busy || 0 }} / 忙碌 {{ runtime.cluster?.summary?.status_counts?.busy || 0 }}
</div> </div>
</div> </div>
@@ -117,7 +119,10 @@
API {{ runtime.api?.pid ? "运行中" : "未知" }} API {{ runtime.api?.pid ? "运行中" : "未知" }}
</span> </span>
<span class="status-chip" :class="runtime.worker?.running ? 'ok' : 'danger'"> <span class="status-chip" :class="runtime.worker?.running ? 'ok' : 'danger'">
Worker {{ runtime.worker?.running ? "在线" : "离线" }} 本机 Worker {{ runtime.worker?.running ? "在线" : "离线" }}
</span>
<span class="status-chip" :class="(runtime.cluster?.summary?.online_worker_nodes || 0) > 0 ? 'ok' : 'danger'">
有效执行节点 {{ runtime.cluster?.summary?.online_worker_nodes || 0 }}
</span> </span>
<span class="status-chip" :class="preflight.ok ? 'ok' : 'danger'"> <span class="status-chip" :class="preflight.ok ? 'ok' : 'danger'">
预检 {{ preflight.ok ? "通过" : "异常" }} 预检 {{ preflight.ok ? "通过" : "异常" }}
@@ -196,7 +201,7 @@
show-icon show-icon
type="info" type="info"
style="margin-top: 12px" style="margin-top: 12px"
:title="`当前 Worker 状态${runtime.worker?.running ? '运行中' : '未运行'};当前 API 状态:${runtime.api?.pid ? '运行中' : '未知'}Sync Agent${runtime.sync_agent?.running ? '运行中' : '未运行'}。`" :title="`当前本机 Worker${runtime.worker?.running ? '运行中' : '未运行'}有效执行节点:${runtime.cluster?.summary?.online_worker_nodes || 0};独立 Worker${runtime.cluster?.summary?.dedicated_online_worker_nodes || 0}当前 API 状态:${runtime.api?.pid ? '运行中' : '未知'}Sync Agent${runtime.sync_agent?.running ? '运行中' : '未运行'}。`"
/> />
<el-alert <el-alert
v-if="!runtime.sync_agent?.expected_on_this_node" v-if="!runtime.sync_agent?.expected_on_this_node"
@@ -275,10 +280,10 @@
<div class="history-panel"> <div class="history-panel">
<div class="history-header"> <div class="history-header">
<h3>扩容建议</h3> <h3>扩容建议</h3>
<span class="history-note">基于当前积压近窗吞吐和在线 Worker 数自动估算</span> <span class="history-note">基于当前积压近窗吞吐和有效执行节点数自动估算</span>
</div> </div>
<div class="cluster-summary"> <div class="cluster-summary">
<span class="cluster-summary-item">在线 Worker {{ runtime.detect?.capacity_plan?.online_worker_nodes || 0 }}</span> <span class="cluster-summary-item">有效执行节点 {{ runtime.detect?.capacity_plan?.online_worker_nodes || 0 }}</span>
<span class="cluster-summary-item">当前吞吐 {{ runtime.detect?.capacity_plan?.current_processed_per_hour || 0 }}/小时</span> <span class="cluster-summary-item">当前吞吐 {{ runtime.detect?.capacity_plan?.current_processed_per_hour || 0 }}/小时</span>
<span class="cluster-summary-item">预计剩余 {{ runtime.detect?.capacity_plan?.estimated_hours_remaining || 0 }} 小时</span> <span class="cluster-summary-item">预计剩余 {{ runtime.detect?.capacity_plan?.estimated_hours_remaining || 0 }} 小时</span>
<span class="cluster-summary-item">建议总 Worker {{ runtime.detect?.capacity_plan?.recommended_total_workers || 1 }}</span> <span class="cluster-summary-item">建议总 Worker {{ runtime.detect?.capacity_plan?.recommended_total_workers || 1 }}</span>
@@ -377,7 +382,7 @@
</div> </div>
<div class="cluster-summary"> <div class="cluster-summary">
<span class="cluster-summary-item">在线控制面 {{ runtime.cluster?.summary?.online_control_nodes || 0 }}</span> <span class="cluster-summary-item">在线控制面 {{ runtime.cluster?.summary?.online_control_nodes || 0 }}</span>
<span class="cluster-summary-item">在线 Worker {{ runtime.cluster?.summary?.online_worker_nodes || 0 }}</span> <span class="cluster-summary-item">有效执行节点 {{ runtime.cluster?.summary?.online_worker_nodes || 0 }}</span>
<span class="cluster-summary-item">独立 Worker {{ runtime.cluster?.summary?.dedicated_online_worker_nodes || 0 }}</span> <span class="cluster-summary-item">独立 Worker {{ runtime.cluster?.summary?.dedicated_online_worker_nodes || 0 }}</span>
<span class="cluster-summary-item">忙碌 {{ runtime.cluster?.summary?.status_counts?.busy || 0 }}</span> <span class="cluster-summary-item">忙碌 {{ runtime.cluster?.summary?.status_counts?.busy || 0 }}</span>
<span class="cluster-summary-item">失活 {{ runtime.cluster?.summary?.status_counts?.stale || 0 }}</span> <span class="cluster-summary-item">失活 {{ runtime.cluster?.summary?.status_counts?.stale || 0 }}</span>
@@ -803,17 +808,19 @@ const clusterIssueCount = computed(() => {
const clusterAlertText = computed(() => { const clusterAlertText = computed(() => {
const summary = runtime.cluster?.summary || {}; const summary = runtime.cluster?.summary || {};
const busy = Array.isArray(summary.busy_nodes) ? summary.busy_nodes.join("、") : ""; const busy = Array.isArray(summary.busy_nodes) ? summary.busy_nodes.join("、") : "";
const stale = Array.isArray(summary.stale_nodes) ? summary.stale_nodes.join("、") : ""; const staleNodes = Array.isArray(summary.stale_nodes) ? summary.stale_nodes : [];
const offline = Array.isArray(summary.offline_nodes) ? summary.offline_nodes.join("、") : ""; const offlineNodes = Array.isArray(summary.offline_nodes) ? summary.offline_nodes : [];
const parts = []; const parts = [
`当前有效执行节点 ${summary.online_worker_nodes || 0},其中独立 Worker ${summary.dedicated_online_worker_nodes || 0}`
];
if (busy) { if (busy) {
parts.push(`忙碌节点:${busy}`); parts.push(`忙碌节点:${busy}`);
} }
if (stale) { if (staleNodes.length) {
parts.push(`失活节点${stale}`); parts.push(`失活节点 ${staleNodes.length}${staleNodes.length <= 2 ? `${staleNodes.join("、")}` : ",详见下方节点表"}`);
} }
if (offline) { if (offlineNodes.length) {
parts.push(`离线节点${offline}`); parts.push(`离线节点 ${offlineNodes.length}${offlineNodes.length <= 2 ? `${offlineNodes.join("、")}` : ",详见下方节点表"}`);
} }
return parts.join(""); return parts.join("");
}); });
@@ -824,7 +831,8 @@ const participatingNodesAlertText = computed(() => {
return "当前没有节点正在领任务、执行任务或产生近窗吞吐。"; return "当前没有节点正在领任务、执行任务或产生近窗吞吐。";
} }
const names = rows.map((item: Record<string, any>) => String(item.node_code || "-")).join("、"); const names = rows.map((item: Record<string, any>) => String(item.node_code || "-")).join("、");
return `当前参与检测节点 ${rows.length} 台:${names}`; const effectiveCount = rows.filter((item: Record<string, any>) => Boolean(item.is_effective_worker)).length;
return `当前参与检测节点 ${rows.length} 台,其中有效执行节点 ${effectiveCount} 台:${names}`;
}); });
const syncAlertText = computed(() => { const syncAlertText = computed(() => {
@@ -1034,18 +1042,36 @@ const invokeAction = async (action: "start_worker" | "stop_worker" | "restart_ap
stop_sync_agent: "停止 Sync Agent", stop_sync_agent: "停止 Sync Agent",
push_sync: "立即同步" push_sync: "立即同步"
}; };
const responseMessage = String(response.message || "");
const uiLevel = String(response.data?.ui_level || "").trim().toLowerCase();
const actionType: "success" | "warning" | "error" =
uiLevel === "error" ? "error" : uiLevel === "warning" ? "warning" : "success";
lastAction.value = { lastAction.value = {
label: labelMap[action], label: labelMap[action],
message: response.message, message: responseMessage,
at: new Date().toLocaleString("zh-CN", { hour12: false }), at: new Date().toLocaleString("zh-CN", { hour12: false }),
type: "success" type: actionType
}; };
persistLastAction(); persistLastAction();
appendActionHistory(lastAction.value); appendActionHistory(lastAction.value);
ElMessage.success(response.message); if (actionType === "error") {
window.setTimeout(() => { ElMessage.error(responseMessage);
loadAll(false); } else if (actionType === "warning") {
}, Number(response.data?.poll_after_seconds || 2) * 1000); ElMessage.warning(responseMessage);
} else {
ElMessage.success(responseMessage);
}
await loadAll(false);
const pollSchedule = Array.isArray(response.data?.poll_schedule_seconds)
? response.data.poll_schedule_seconds
: [Number(response.data?.poll_after_seconds || 2)];
[...new Set(pollSchedule.map((item: unknown) => Number(item || 0)).filter((item: number) => item > 0))]
.sort((a, b) => a - b)
.forEach((seconds) => {
window.setTimeout(() => {
loadAll(false);
}, seconds * 1000);
});
} catch (error: any) { } catch (error: any) {
const message = error?.message || "运行时动作执行失败"; const message = error?.message || "运行时动作执行失败";
const labelMap = { const labelMap = {

View File

@@ -54,6 +54,25 @@
<el-form-item label="Sync Agent 服务名"> <el-form-item label="Sync Agent 服务名">
<el-input v-model="runtimeSettings.sync_agent_service_name" /> <el-input v-model="runtimeSettings.sync_agent_service_name" />
</el-form-item> </el-form-item>
<el-form-item label="远端日志回传">
<el-switch v-model="runtimeSettings.worker_log_sync_enabled" />
</el-form-item>
<el-form-item label="回传级别">
<el-select v-model="runtimeSettings.worker_log_sync_mode" :disabled="!runtimeSettings.worker_log_sync_enabled">
<el-option label="关键" value="key" />
<el-option label="全量" value="full" />
</el-select>
</el-form-item>
<el-alert
:closable="false"
type="info"
show-icon
style="margin-top: 12px"
title="关闭时不追加远端日志镜像;关键模式回传阶段/代理/异常等关键过程;全量模式会追加更多执行过程,便于临时分析。"
/>
</el-form> </el-form>
</PageCard> </PageCard>
@@ -232,7 +251,9 @@ const runtimeSettings = ref({
worker_mode: "windows-local", worker_mode: "windows-local",
worker_service_name: "domaincheck-worker", worker_service_name: "domaincheck-worker",
api_service_name: "domaincheck-api", api_service_name: "domaincheck-api",
sync_agent_service_name: "domaincheck-sync-agent" sync_agent_service_name: "domaincheck-sync-agent",
worker_log_sync_enabled: false,
worker_log_sync_mode: "key"
}); });
const importInput = ref<HTMLInputElement | null>(null); const importInput = ref<HTMLInputElement | null>(null);
const juziseoLoggingIn = ref(false); const juziseoLoggingIn = ref(false);
@@ -316,6 +337,10 @@ const normalizeNodeThreadOverrides = (payload: Record<string, number | string>)
.sort((a, b) => a.node_code.localeCompare(b.node_code)); .sort((a, b) => a.node_code.localeCompare(b.node_code));
}; };
const normalizeWorkerLogSyncMode = (value: unknown) => {
return String(value || "").trim().toLowerCase() === "full" ? "full" : "key";
};
const addNodeThreadOverride = () => { const addNodeThreadOverride = () => {
nodeThreadOverrides.value.push({ nodeThreadOverrides.value.push({
node_code: "", node_code: "",
@@ -371,7 +396,9 @@ const loadSettings = async () => {
worker_mode: response.data.runtime_settings?.worker_mode || "windows-local", worker_mode: response.data.runtime_settings?.worker_mode || "windows-local",
worker_service_name: response.data.runtime_settings?.worker_service_name || "domaincheck-worker", worker_service_name: response.data.runtime_settings?.worker_service_name || "domaincheck-worker",
api_service_name: response.data.runtime_settings?.api_service_name || "domaincheck-api", api_service_name: response.data.runtime_settings?.api_service_name || "domaincheck-api",
sync_agent_service_name: response.data.runtime_settings?.sync_agent_service_name || "domaincheck-sync-agent" sync_agent_service_name: response.data.runtime_settings?.sync_agent_service_name || "domaincheck-sync-agent",
worker_log_sync_enabled: Boolean(response.data.runtime_settings?.worker_log_sync_enabled),
worker_log_sync_mode: normalizeWorkerLogSyncMode(response.data.runtime_settings?.worker_log_sync_mode)
}; };
} catch { } catch {
ElMessage.error("读取系统设置失败"); ElMessage.error("读取系统设置失败");

View File

@@ -41,6 +41,7 @@ CONFIG_UPDATE_CHANNEL = "domain_tool:config_update"
CONTROL_CHANNEL = "domain_tool:worker_control" CONTROL_CHANNEL = "domain_tool:worker_control"
RUNTIME_STATE_KEY = "domain_tool:detect_runtime_state" RUNTIME_STATE_KEY = "domain_tool:detect_runtime_state"
PENDING_CONTROL_KEY = "domain_tool:worker_pending_command" PENDING_CONTROL_KEY = "domain_tool:worker_pending_command"
RUNTIME_SETTINGS_KEY = "domain_tool:runtime_settings"
class DetectThread(QThread): class DetectThread(QThread):
""" """
@@ -814,6 +815,11 @@ class DetectWorker:
self.current_cycle_token = "" self.current_cycle_token = ""
self.current_job_id = None self.current_job_id = None
self.current_job_code = "" self.current_job_code = ""
self.runtime_settings = {
"worker_log_sync_enabled": False,
"worker_log_sync_mode": "key",
}
self._last_synced_worker_log = ""
# 初始化数据库连接 # 初始化数据库连接
self.db = Database() self.db = Database()
@@ -868,6 +874,7 @@ class DetectWorker:
self.detect_options = self.load_detect_options() self.detect_options = self.load_detect_options()
self.proxy_config = self.load_proxy_config() self.proxy_config = self.load_proxy_config()
self.thread_count = self.load_thread_count() # 从配置文件加载线程数 self.thread_count = self.load_thread_count() # 从配置文件加载线程数
self.runtime_settings = self.load_runtime_settings()
# 初始化代理池 # 初始化代理池
self.proxy_pool = [] self.proxy_pool = []
@@ -1042,6 +1049,7 @@ class DetectWorker:
def _set_active_cycle_context(self, control_payload=None): def _set_active_cycle_context(self, control_payload=None):
payload = control_payload or {} payload = control_payload or {}
self._last_synced_worker_log = ""
self.current_cycle_token = str(payload.get("cycle_token") or "").strip() self.current_cycle_token = str(payload.get("cycle_token") or "").strip()
job_id = payload.get("job_id") job_id = payload.get("job_id")
try: try:
@@ -1051,6 +1059,7 @@ class DetectWorker:
self.current_job_code = str(payload.get("job_code") or "").strip() self.current_job_code = str(payload.get("job_code") or "").strip()
def _clear_active_cycle_context(self): def _clear_active_cycle_context(self):
self._last_synced_worker_log = ""
self.current_cycle_token = "" self.current_cycle_token = ""
self.current_job_id = None self.current_job_id = None
self.current_job_code = "" self.current_job_code = ""
@@ -1091,6 +1100,7 @@ class DetectWorker:
self.detecting = True self.detecting = True
try: try:
logger.info(f"开始执行远程检测任务,来源: {source}") logger.info(f"开始执行远程检测任务,来源: {source}")
self._sync_worker_log_event(f"开始执行检测任务,来源: {source}", payload={"source": source}, mode='key')
self._update_runtime_state( self._update_runtime_state(
"starting", "starting",
f"开始执行检测任务,来源: {source}", f"开始执行检测任务,来源: {source}",
@@ -1136,6 +1146,7 @@ class DetectWorker:
self._update_runtime_state("idle", "当前没有运行中的检测任务") self._update_runtime_state("idle", "当前没有运行中的检测任务")
return False return False
logger.info(f"收到停止检测指令,来源: {source}") logger.info(f"收到停止检测指令,来源: {source}")
self._sync_worker_log_event(f"收到停止检测指令,来源: {source}", payload={"source": source}, mode='key')
self.stop_requested = True self.stop_requested = True
self._update_runtime_state( self._update_runtime_state(
"stopping", "stopping",
@@ -1351,6 +1362,79 @@ class DetectWorker:
logger.info(f"使用默认检测线程数: {default_thread_count}") logger.info(f"使用默认检测线程数: {default_thread_count}")
return default_thread_count return default_thread_count
def load_runtime_settings(self):
default_settings = {
"worker_log_sync_enabled": False,
"worker_log_sync_mode": "key",
}
try:
if self.use_redis:
runtime_settings_raw = self.redis_client.get(RUNTIME_SETTINGS_KEY)
if runtime_settings_raw:
runtime_settings = default_settings.copy()
runtime_settings.update(json.loads(runtime_settings_raw))
runtime_settings["worker_log_sync_enabled"] = bool(runtime_settings.get("worker_log_sync_enabled", False))
runtime_settings["worker_log_sync_mode"] = "full" if str(runtime_settings.get("worker_log_sync_mode", "key")).strip().lower() == "full" else "key"
logger.info(f"从Redis加载运行时设置成功: {runtime_settings}")
return runtime_settings
candidate_paths = [
'runtime_settings.json',
os.path.join('runtime', 'runtime_settings.json'),
]
for candidate_path in candidate_paths:
if os.path.exists(candidate_path):
with open(candidate_path, 'r', encoding='utf-8') as f:
runtime_settings = default_settings.copy()
runtime_settings.update(json.load(f))
runtime_settings["worker_log_sync_enabled"] = bool(runtime_settings.get("worker_log_sync_enabled", False))
runtime_settings["worker_log_sync_mode"] = "full" if str(runtime_settings.get("worker_log_sync_mode", "key")).strip().lower() == "full" else "key"
logger.info(f"从本地文件加载运行时设置成功: {runtime_settings}")
return runtime_settings
except Exception as e:
logger.error(f"加载运行时设置失败: {e}")
return default_settings
def _worker_log_sync_mode(self):
if not bool((self.runtime_settings or {}).get("worker_log_sync_enabled", False)):
return "off"
return "full" if str((self.runtime_settings or {}).get("worker_log_sync_mode", "key")).strip().lower() == "full" else "key"
def _sync_worker_log_event(self, message, level='info', payload=None, *, mode='key', job_item_id=None):
current_mode = self._worker_log_sync_mode()
if current_mode == "off":
return
if mode == 'full' and current_mode != "full":
return
normalized_message = str(message or '').strip()
if not normalized_message:
return
if normalized_message == self._last_synced_worker_log and mode != 'full':
return
job_id = self.current_job_id
if not job_id:
return
event_payload = dict(payload or {})
if self.current_cycle_token and not event_payload.get("cycle_token"):
event_payload["cycle_token"] = self.current_cycle_token
if self.current_job_code and not event_payload.get("job_code"):
event_payload["job_code"] = self.current_job_code
event_payload["log_mode"] = mode
event_payload["synced_by"] = "worker_log_callback"
try:
self.db.append_detect_run_event(
job_id,
job_item_id,
config.NODE_CODE,
event_type='worker_log',
message=normalized_message,
level=level,
payload=event_payload,
)
self._last_synced_worker_log = normalized_message
except Exception as e:
logger.debug(f"回传Worker日志事件失败: {e}")
def test_proxy(self, proxy_item, result_queue): def test_proxy(self, proxy_item, result_queue):
""" """
测试单个代理的可用性 测试单个代理的可用性
@@ -1495,6 +1579,7 @@ class DetectWorker:
"idle" if not self.detecting else "running", "idle" if not self.detecting else "running",
"代理未启用,使用直接连接", "代理未启用,使用直接连接",
) )
self._sync_worker_log_event("代理未启用,使用直接连接", mode='key')
self.proxy_refresh_lock.release() self.proxy_refresh_lock.release()
return return
@@ -1510,6 +1595,7 @@ class DetectWorker:
"refreshing_proxy" if self.detecting else "idle", "refreshing_proxy" if self.detecting else "idle",
f"代理池刷新冷却中,{wait_seconds} 秒后再试", f"代理池刷新冷却中,{wait_seconds} 秒后再试",
) )
self._sync_worker_log_event(f"代理池刷新冷却中,{wait_seconds} 秒后再试", mode='full')
return return
proxy_api_urls = self.proxy_config.get('proxy_urls') or [] proxy_api_urls = self.proxy_config.get('proxy_urls') or []
@@ -1622,6 +1708,16 @@ class DetectWorker:
"running" if self.detecting else "idle", "running" if self.detecting else "idle",
f"代理池刷新完成,共 {len(new_proxies)} 个可用代理,来源链接 {len(proxy_api_urls)} 个,原始 {self.proxy_last_refresh_total_items} 个,验证 {self.proxy_last_validated_count}", f"代理池刷新完成,共 {len(new_proxies)} 个可用代理,来源链接 {len(proxy_api_urls)} 个,原始 {self.proxy_last_refresh_total_items} 个,验证 {self.proxy_last_validated_count}",
) )
self._sync_worker_log_event(
f"代理池刷新完成,共 {len(new_proxies)} 个可用代理,来源链接 {len(proxy_api_urls)} 个,原始 {self.proxy_last_refresh_total_items} 个,验证 {self.proxy_last_validated_count}",
payload={
"available_proxy_count": len(new_proxies),
"source_count": len(proxy_api_urls),
"raw_items": self.proxy_last_refresh_total_items,
"validated_count": self.proxy_last_validated_count,
},
mode='key',
)
else: else:
with self.proxy_pool_lock: with self.proxy_pool_lock:
self.proxy_pool = [] self.proxy_pool = []
@@ -1636,6 +1732,7 @@ class DetectWorker:
"refreshing_proxy" if self.detecting else "idle", "refreshing_proxy" if self.detecting else "idle",
"所有代理池链接均未返回可用代理数据", "所有代理池链接均未返回可用代理数据",
) )
self._sync_worker_log_event("所有代理池链接均未返回可用代理数据", level='warning', mode='key')
else: else:
with self.proxy_pool_lock: with self.proxy_pool_lock:
self.proxy_pool = [] self.proxy_pool = []
@@ -1649,6 +1746,7 @@ class DetectWorker:
"idle" if not self.detecting else "running", "idle" if not self.detecting else "running",
"未配置代理池链接", "未配置代理池链接",
) )
self._sync_worker_log_event("未配置代理池链接", level='warning', mode='key')
except Exception as e: except Exception as e:
with self.proxy_pool_lock: with self.proxy_pool_lock:
self.proxy_pool = [] self.proxy_pool = []
@@ -1663,6 +1761,7 @@ class DetectWorker:
"refreshing_proxy" if self.detecting else "failed", "refreshing_proxy" if self.detecting else "failed",
f"刷新代理池失败: {e}", f"刷新代理池失败: {e}",
) )
self._sync_worker_log_event(f"刷新代理池失败: {e}", level='error', mode='key')
finally: finally:
self.proxy_refresh_lock.release() self.proxy_refresh_lock.release()
@@ -2383,6 +2482,15 @@ class DetectWorker:
"job_code": job_code, "job_code": job_code,
}, },
) )
self._sync_worker_log_event(
f"开始检测域名: {domain_name}",
payload={
"domain_id": domain_id,
"domain": domain_name,
},
mode='full',
job_item_id=job_item_id,
)
# 使用共享的敏感词列表 # 使用共享的敏感词列表
sensitive_words = self.sensitive_words sensitive_words = self.sensitive_words
@@ -2433,11 +2541,32 @@ class DetectWorker:
self._complete_detection(domain_id, domain_name) self._complete_detection(domain_id, domain_name)
finalize_job_item('completed', f"域名检测完成: {domain_name}") finalize_job_item('completed', f"域名检测完成: {domain_name}")
logger.info(f"域名检测完成: {domain_name}") logger.info(f"域名检测完成: {domain_name}")
self._sync_worker_log_event(
f"域名检测完成: {domain_name}",
payload={
"domain_id": domain_id,
"domain": domain_name,
"status": "completed",
},
mode='full',
job_item_id=job_item_id,
)
except Exception as e: except Exception as e:
logger.error(f"检测域名出错: {domain_name}, 错误: {e}") logger.error(f"检测域名出错: {domain_name}, 错误: {e}")
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_FAILED) self.db.update_domain_detect_status(domain_id, DETECT_STATUS_FAILED)
finalize_job_item('failed', str(e)) finalize_job_item('failed', str(e))
self._sync_worker_log_event(
f"检测域名出错: {domain_name}, 错误: {e}",
level='error',
payload={
"domain_id": domain_id,
"domain": domain_name,
"status": "failed",
},
mode='full',
job_item_id=job_item_id,
)
def start_detection(self): def start_detection(self):
""" """
@@ -2447,6 +2576,7 @@ class DetectWorker:
self.detecting = True self.detecting = True
self.stop_requested = False self.stop_requested = False
self._mark_detection_phase("preparing", "开始执行域名检测任务,正在加载配置") self._mark_detection_phase("preparing", "开始执行域名检测任务,正在加载配置")
self._sync_worker_log_event("开始执行域名检测任务,正在加载配置", mode='key')
# 重新加载配置,确保获取最新的配置 # 重新加载配置,确保获取最新的配置
try: try:
@@ -2468,6 +2598,7 @@ class DetectWorker:
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
self._mark_detection_phase("failed", f"加载配置失败: {e}") self._mark_detection_phase("failed", f"加载配置失败: {e}")
self._sync_worker_log_event(f"加载配置失败: {e}", level='error', mode='key')
return return
# 重新加载cookies # 重新加载cookies
@@ -2486,6 +2617,7 @@ class DetectWorker:
if self.proxy_config.get('proxy_enable', False): if self.proxy_config.get('proxy_enable', False):
logger.info("开始检测,刷新代理池") logger.info("开始检测,刷新代理池")
self._mark_detection_phase("refreshing_proxy", "开始检测,正在刷新代理池") self._mark_detection_phase("refreshing_proxy", "开始检测,正在刷新代理池")
self._sync_worker_log_event("开始检测,正在刷新代理池", mode='key')
self.refresh_proxy_pool() self.refresh_proxy_pool()
# 更新JC和Juziseo实例的代理设置 # 更新JC和Juziseo实例的代理设置
@@ -2516,6 +2648,7 @@ class DetectWorker:
if self.stop_requested: if self.stop_requested:
logger.info("检测任务收到停止请求,停止继续领取任务") logger.info("检测任务收到停止请求,停止继续领取任务")
self._mark_detection_phase("stopping", "检测任务收到停止请求,准备安全退出") self._mark_detection_phase("stopping", "检测任务收到停止请求,准备安全退出")
self._sync_worker_log_event("检测任务收到停止请求,准备安全退出", level='warning', mode='key')
break break
recycled_job_items = self.db.recycle_expired_detect_job_items() recycled_job_items = self.db.recycle_expired_detect_job_items()
if recycled_job_items: if recycled_job_items:
@@ -2538,10 +2671,19 @@ class DetectWorker:
batch_size=current_batch_size, batch_size=current_batch_size,
queue_source="detect_job_items" if using_job_queue else "domains", queue_source="detect_job_items" if using_job_queue else "domains",
) )
self._sync_worker_log_event(
f"{queue_label}获取到 {current_batch_size} 个需要检测的域名",
payload={
"batch_size": current_batch_size,
"queue_source": "detect_job_items" if using_job_queue else "domains",
},
mode='full',
)
if not domains: if not domains:
logger.info("没有需要检测的域名") logger.info("没有需要检测的域名")
self._mark_detection_phase("idle", "当前没有需要检测的域名Worker 等待下一次启动") self._mark_detection_phase("idle", "当前没有需要检测的域名Worker 等待下一次启动")
self._sync_worker_log_event("当前没有需要检测的域名Worker 等待下一次启动", mode='key')
break break
# 旧链路直接扫 domains 表时,少于 batch_size 代表已接近尾批; # 旧链路直接扫 domains 表时,少于 batch_size 代表已接近尾批;
@@ -2560,6 +2702,11 @@ class DetectWorker:
batch_size=current_batch_size, batch_size=current_batch_size,
max_threads=max_threads, max_threads=max_threads,
) )
self._sync_worker_log_event(
f"开始创建线程,当前批次域名数: {current_batch_size},最大线程数: {max_threads}",
payload={"batch_size": current_batch_size, "max_threads": max_threads},
mode='full',
)
for i, domain in enumerate(domains): for i, domain in enumerate(domains):
# 检查是否需要停止 # 检查是否需要停止
if not self.running or self.stop_requested: if not self.running or self.stop_requested:
@@ -2627,6 +2774,15 @@ class DetectWorker:
max_threads=max_threads, max_threads=max_threads,
batch_size=current_batch_size, batch_size=current_batch_size,
) )
self._sync_worker_log_event(
f"当前实际线程数量: {current_active}/{max_threads}",
payload={
"active_threads": current_active,
"max_threads": max_threads,
"batch_size": current_batch_size,
},
mode='full',
)
# 更新GUI显示 # 更新GUI显示
if self.detect_thread: if self.detect_thread:
@@ -2705,9 +2861,19 @@ class DetectWorker:
f"当前批次域名数量 ({current_batch_size}) 少于 {batch_size},本轮检测即将完成", f"当前批次域名数量 ({current_batch_size}) 少于 {batch_size},本轮检测即将完成",
processed=total_processed, processed=total_processed,
) )
self._sync_worker_log_event(
f"当前批次域名数量 ({current_batch_size}) 少于 {batch_size},本轮检测即将完成",
payload={"processed": total_processed},
mode='key',
)
break break
logger.info(f"当前批次检测完成,累计处理 {total_processed} 个域名") logger.info(f"当前批次检测完成,累计处理 {total_processed} 个域名")
self._mark_detection_phase("batch_completed", f"当前批次检测完成,累计处理 {total_processed} 个域名", processed=total_processed) self._mark_detection_phase("batch_completed", f"当前批次检测完成,累计处理 {total_processed} 个域名", processed=total_processed)
self._sync_worker_log_event(
f"当前批次检测完成,累计处理 {total_processed} 个域名",
payload={"processed": total_processed},
mode='key',
)
# 完成进度 # 完成进度
if self.detect_thread: if self.detect_thread:
@@ -2721,10 +2887,12 @@ class DetectWorker:
logger.info("域名检测任务完成") logger.info("域名检测任务完成")
self._mark_detection_phase("completed", "域名检测任务完成") self._mark_detection_phase("completed", "域名检测任务完成")
self._sync_worker_log_event("域名检测任务完成", payload={"processed": total_processed}, mode='key')
except Exception as e: except Exception as e:
logger.error(f"执行检测任务出错: {e}") logger.error(f"执行检测任务出错: {e}")
self._mark_detection_phase("failed", f"执行检测任务出错: {e}") self._mark_detection_phase("failed", f"执行检测任务出错: {e}")
self._sync_worker_log_event(f"执行检测任务出错: {e}", level='error', mode='key')
finally: finally:
self.detecting = False self.detecting = False
@@ -2922,6 +3090,7 @@ class DetectWorker:
self.detect_options = self.load_detect_options() self.detect_options = self.load_detect_options()
self.proxy_config = self.load_proxy_config() self.proxy_config = self.load_proxy_config()
self.thread_count = self.load_thread_count() self.thread_count = self.load_thread_count()
self.runtime_settings = self.load_runtime_settings()
self.load_cookies_from_remote() self.load_cookies_from_remote()
self.update_config_labels() self.update_config_labels()
logger.debug("配置已更新") logger.debug("配置已更新")