from __future__ import annotations import json import re import subprocess import threading import time from datetime import datetime, timedelta, timezone from app.core.config import settings from app.core.db import get_db from app.core.files import resolve_domain_path, tail_lines from app.core.redis_client import get_redis from app.services.debug_event_service import list_debug_events from app.services.cluster_runtime_service import ensure_runtime_schema from app.services.runtime_settings_service import get_runtime_settings from app.services.detect_run_service import sync_detect_runs from app.services.detect_job_service import get_active_detect_job_summary, get_detect_queue_health from app.services.settings_service import get_settings_payload, resolve_process_count, resolve_thread_count from app.services.sync_record_service import append_detect_result_projection_if_changed from app.services.worker_control_service import detect_worker_runtime _PROXY_COUNT_RE = re.compile(r"当前可用代理数[::]\s*(\d+)") _PROXY_REFRESH_COUNT_RE = re.compile(r"代理池刷新完成,共\s*(\d+)\s*个可用代理") _PROXY_CACHE_COUNT_RE = re.compile(r"继续沿用缓存\s*(\d+)\s*个") _PROXY_SHARED_SNAPSHOT_COUNT_RE = re.compile(r"(?:复用共享代理快照|共享代理快照)\s*(\d+)\s*个") _THREAD_COUNT_RE = re.compile(r"当前实际线程数量[::]\s*(\d+)\s*/\s*(\d+)") _STEP_TRACE_DOMAIN_RE = re.compile(r"domain=([^\s|]+)") _REGISTER_DOMAIN_RE = re.compile(r"检测注册状态[::]\s*([^\s]+)") _RUNTIME_STATE_KEY = "domain_tool:detect_runtime_state" _TIMESTAMP_FORMATS = ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S") _SYSLOG_TIMESTAMP_FORMAT = "%b %d %H:%M:%S" _REMOTE_LOG_MAX_CHARS = 500 _REMOTE_DEBUG_EVENT_TYPES = { "worker_log", "active_job_snapshot", "domain_started", "domain_completed", "domain_failed", "domain_blacklisted", "task_pull_success", "task_pull_partial", "task_pull_failed", "queue_overdue_leases", } _DETECT_STATUS_CACHE_LOCK = threading.Lock() _DETECT_STATUS_CACHE_TTL_SECONDS = 3.0 _DETECT_STATUS_CACHE_VALUE: dict | None = None _DETECT_STATUS_CACHE_EXPIRES_AT = 0.0 _AGGREGATE_RUNTIME_NODE_STALE_AFTER = timedelta(seconds=90) _DISABLED_MANAGED_NODE_CACHE: set[str] = set() _DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT = 0.0 def _clone_detect_status_payload(value: dict | None) -> dict: try: return json.loads(json.dumps(dict(value or {}), ensure_ascii=False)) except Exception: return dict(value or {}) def _extract_debug_event_job_identity(payload: dict | None) -> dict: normalized_payload = dict(payload or {}) if isinstance(payload, dict) else {} nested_job = normalized_payload.get("job") if isinstance(normalized_payload.get("job"), dict) else {} raw_job_id = normalized_payload.get("job_id") if raw_job_id in (None, "", 0, "0"): raw_job_id = normalized_payload.get("target_job_id") if raw_job_id in (None, "", 0, "0"): raw_job_id = nested_job.get("job_id") try: job_id = int(raw_job_id or 0) except Exception: job_id = 0 return { "job_id": job_id, "job_code": str( normalized_payload.get("job_code") or normalized_payload.get("target_job_code") or nested_job.get("job_code") or "" ).strip(), "cycle_token": str(normalized_payload.get("cycle_token") or nested_job.get("cycle_token") or "").strip(), "has_identity": bool(job_id > 0 or str( normalized_payload.get("job_code") or normalized_payload.get("target_job_code") or nested_job.get("job_code") or "" ).strip()), } def _debug_event_matches_active_job(record: dict, active_job: dict | None) -> bool: normalized_active_job = dict(active_job or {}) active_job_id = int(normalized_active_job.get("job_id") or 0) active_job_code = str( normalized_active_job.get("runtime_job_code") or normalized_active_job.get("job_code") or "" ).strip() active_cycle_token = str(normalized_active_job.get("current_cycle_token") or "").strip() if active_job_id <= 0 and not active_job_code and not active_cycle_token: return True identity = _extract_debug_event_job_identity(record.get("payload")) if not identity["has_identity"]: return False event_job_id = int(identity["job_id"] or 0) event_job_code = str(identity["job_code"] or "").strip() event_cycle_token = str(identity["cycle_token"] or "").strip() if active_cycle_token and event_cycle_token and event_cycle_token != active_cycle_token: return False if active_job_id > 0 and event_job_id > 0 and event_job_id != active_job_id: return False if active_job_code and event_job_code and event_job_code != active_job_code: return False if active_job_id > 0 and event_job_id == active_job_id: return True if active_job_code and event_job_code and event_job_code == active_job_code: return True if active_cycle_token and event_cycle_token and event_cycle_token == active_cycle_token: return True return False def _extract_remote_log_node_code(line: str) -> str: text = str(line or "").strip() if not text.startswith("["): return "" first_close = text.find("]") if first_close < 0: return "" second_open = text.find("[", first_close + 1) second_close = text.find("]", second_open + 1) if second_open >= 0 else -1 if second_open < 0 or second_close < 0: return "" return text[second_open + 1:second_close].strip() def _slice_remote_log_lines_fairly(lines: list[str], *, limit: int = 240, min_per_node: int = 12) -> list[str]: safe_limit = max(1, int(limit or 240)) if len(lines) <= safe_limit: return list(lines or []) normalized_lines = [str(line or "").strip() for line in list(lines or []) if str(line or "").strip()] if len(normalized_lines) <= safe_limit: return normalized_lines if min_per_node <= 0: return normalized_lines[-safe_limit:] kept_indexes: set[int] = set() per_node_counts: dict[str, int] = {} for index in range(len(normalized_lines) - 1, -1, -1): node_code = _extract_remote_log_node_code(normalized_lines[index]) if not node_code: continue current_count = int(per_node_counts.get(node_code, 0) or 0) if current_count >= min_per_node: continue kept_indexes.add(index) per_node_counts[node_code] = current_count + 1 if len(kept_indexes) >= safe_limit: break for index in range(len(normalized_lines) - 1, -1, -1): if len(kept_indexes) >= safe_limit: break kept_indexes.add(index) return [normalized_lines[index] for index in sorted(kept_indexes)] def _runtime_state_key(node_code: str | None = None) -> str: normalized_node_code = str(node_code or settings.node_code or "").strip() if not normalized_node_code: return _RUNTIME_STATE_KEY return f"{_RUNTIME_STATE_KEY}:{normalized_node_code}" def _local_worker_expected_on_this_node() -> bool: return not ( str(settings.node_region or "").strip() == "overseas" and str(settings.node_role or "").strip() == "control" ) def _int_value(value: object) -> int: try: return int(value or 0) except Exception: return 0 def _max_runtime_metric(*values: object) -> int: return max((_int_value(value) for value in values), default=0) def _resolve_capacity_node_code(node_code: str, settings_payload: dict) -> tuple[str, bool]: normalized_node_code = str(node_code or "").strip() if not normalized_node_code: return "", False node_thread_counts = dict(settings_payload.get("node_thread_counts") or {}) node_process_counts = dict(settings_payload.get("node_process_counts") or {}) parent_node_code, separator, suffix = normalized_node_code.rpartition("-") if ( separator and parent_node_code and suffix.isalpha() and len(suffix) <= 3 and ( parent_node_code in node_thread_counts or parent_node_code in node_process_counts or bool(re.search(r"\d$", parent_node_code)) ) ): return parent_node_code, True if normalized_node_code in node_thread_counts or normalized_node_code in node_process_counts: return normalized_node_code, False return normalized_node_code, False def _is_current_participant_bucket(item: dict | None) -> bool: payload = dict(item or {}) node_code = str(payload.get("node_code") or "").strip() if not node_code or node_code == "unassigned": return False return any( _int_value(payload.get(field)) > 0 for field in ("items_claimed", "items_running", "display_running", "current_load", "active_threads") ) def _aggregate_runtime_node_is_live(item: dict | None) -> bool: payload = dict(item or {}) node_code = str(payload.get("node_code") or "").strip() if not node_code: return False if node_code == "unassigned": return True status = str(payload.get("status") or "").strip().lower() if status in {"stale", "offline"}: return False last_heartbeat_at = _parse_time(payload.get("last_heartbeat_at")) if last_heartbeat_at is None: return True reference_now = datetime.now(last_heartbeat_at.tzinfo) if last_heartbeat_at.tzinfo else datetime.now() return (reference_now - last_heartbeat_at) <= _AGGREGATE_RUNTIME_NODE_STALE_AFTER def _filter_live_aggregate_runtime_nodes(node_rows: list[dict] | None) -> list[dict]: disabled_node_codes = _load_disabled_managed_node_codes( [ str(item.get("node_code") or "").strip() for item in list(node_rows or []) if isinstance(item, dict) ] ) return [ dict(item) for item in list(node_rows or []) if isinstance(item, dict) and str(item.get("node_code") or "").strip() not in disabled_node_codes and _aggregate_runtime_node_is_live(item) ] def _build_aggregate_detect_capacity(*, active_job: dict | None, settings_payload: dict) -> dict: node_rows = _filter_live_aggregate_runtime_nodes( list((active_job or {}).get("distributed_node_stats") or (active_job or {}).get("node_stats") or []) ) node_thread_counts = dict(settings_payload.get("node_thread_counts") or {}) node_process_counts = dict(settings_payload.get("node_process_counts") or {}) participant_node_codes: list[str] = [] process_count_total = 0 max_threads_total = 0 representative_thread_count = 0 child_parent_codes: set[str] = set() for raw_item in node_rows: if not isinstance(raw_item, dict) or not _is_current_participant_bucket(raw_item): continue node_code = str(raw_item.get("node_code") or "").strip() capacity_node_code, is_child_instance = _resolve_capacity_node_code(node_code, settings_payload) if is_child_instance and capacity_node_code: child_parent_codes.add(capacity_node_code) for raw_item in node_rows: if not isinstance(raw_item, dict) or not _is_current_participant_bucket(raw_item): continue node_code = str(raw_item.get("node_code") or "").strip() capacity_node_code, is_child_instance = _resolve_capacity_node_code(node_code, settings_payload) if not capacity_node_code: continue if not is_child_instance and node_code in child_parent_codes: continue if node_code not in participant_node_codes: participant_node_codes.append(node_code) thread_resolution_node_code = node_code if node_code in node_thread_counts else capacity_node_code thread_resolution = resolve_thread_count(node_code=thread_resolution_node_code, settings_payload=settings_payload) per_process_thread_count = max(1, int(thread_resolution["effective_thread_count"] or 1)) if representative_thread_count <= 0: representative_thread_count = per_process_thread_count if is_child_instance: process_count = 1 else: if node_code in node_process_counts: process_resolution = resolve_process_count(node_code=node_code, settings_payload=settings_payload) process_count = max(1, int(process_resolution["effective_process_count"] or 1)) else: process_count = 1 process_count_total += process_count max_threads_total += process_count * per_process_thread_count return { "participant_node_codes": participant_node_codes, "participant_node_count": len(participant_node_codes), "process_count": process_count_total, "max_threads": max_threads_total, "per_process_thread_count": representative_thread_count, } def _merge_aggregate_active_job_with_queue_health(active_job: dict | None, queue_health: dict | None) -> dict | None: normalized_active_job = dict(active_job or {}) normalized_queue_health = dict(queue_health or {}) if not normalized_queue_health.get("has_active_job"): return normalized_active_job or active_job queue_payload = dict(normalized_queue_health.get("queue") or {}) queue_job = dict(normalized_queue_health.get("job") or {}) raw_queue_nodes = [dict(item) for item in list(normalized_queue_health.get("nodes") or []) if isinstance(item, dict)] queue_nodes = _filter_live_aggregate_runtime_nodes(raw_queue_nodes) if not queue_payload and not queue_nodes and not queue_job: return normalized_active_job or active_job queue_display_running = sum( _max_runtime_metric( item.get("display_running"), item.get("current_load"), item.get("active_threads"), item.get("items_running"), ) for item in queue_nodes if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned" ) if raw_queue_nodes: display_max_threads = sum(_int_value(item.get("max_threads")) for item in queue_nodes) display_items_running = queue_display_running display_active_threads = queue_display_running else: display_max_threads = _max_runtime_metric( sum(_int_value(item.get("max_threads")) for item in queue_nodes), normalized_active_job.get("display_max_threads"), ) display_items_running = _max_runtime_metric( queue_display_running, queue_payload.get("display_running"), normalized_active_job.get("display_items_running"), normalized_active_job.get("display_active_threads"), ) display_active_threads = _max_runtime_metric( queue_display_running, queue_payload.get("display_running"), normalized_active_job.get("display_active_threads"), normalized_active_job.get("display_items_running"), ) merged = dict(normalized_active_job) merged.update( { "job_id": queue_job.get("job_id", merged.get("job_id")), "job_code": queue_job.get("job_code", merged.get("job_code")), "status": queue_job.get("status", merged.get("status")), "progress_percent": queue_job.get("progress_percent", merged.get("progress_percent", 0)), "items_total": _int_value(queue_payload.get("items_total", merged.get("items_total"))), "items_pending": _int_value(queue_payload.get("pending", merged.get("items_pending"))), "items_claimed": _int_value(queue_payload.get("claimed", merged.get("items_claimed"))), "items_running": _int_value(queue_payload.get("running", merged.get("items_running"))), "items_completed": _int_value(queue_payload.get("completed", merged.get("items_completed"))), "items_blacklisted": _int_value(queue_payload.get("blacklisted", merged.get("items_blacklisted"))), "items_failed": _int_value(queue_payload.get("failed", merged.get("items_failed"))), "display_items_running": display_items_running, "display_active_threads": display_active_threads, "display_max_threads": display_max_threads, } ) if raw_queue_nodes: merged["node_stats"] = list(queue_nodes) merged["distributed_node_stats"] = list(queue_nodes) return merged def _extract_dependency_alerts(lines: list[str]) -> list[dict]: alerts: list[dict] = [] recent_lines = lines[-120:] if lines else [] degraded_line = "" for line in reversed(recent_lines): if "外部依赖异常,步骤降级继续执行" in line: degraded_line = line alerts.append( { "kind": "dependency_degraded", "level": "warning", "title": "外部依赖降级继续", "detail": line, } ) break if degraded_line: return alerts for line in reversed(recent_lines): if "WaybackDetector" in line or "web.archive.org" in line: alerts.append( { "kind": "wayback", "level": "warning", "title": "时光机依赖异常", "detail": line, } ) break for line in reversed(recent_lines): if any(keyword in line for keyword in ("HTTPSConnectionPool", "Connection refused", "Read timed out", "ConnectTimeout")): alerts.append( { "kind": "network", "level": "warning", "title": "外部网络波动", "detail": line, } ) break return alerts def _extract_available_proxy_count(lines: list[str]) -> int: for line in reversed(lines): count = _extract_available_proxy_count_from_text(line) if count > 0: return count return 0 def _extract_available_proxy_count_from_text(text: str) -> int: normalized_text = str(text or "").strip() if not normalized_text: return 0 for pattern in ( _PROXY_COUNT_RE, _PROXY_REFRESH_COUNT_RE, _PROXY_CACHE_COUNT_RE, _PROXY_SHARED_SNAPSHOT_COUNT_RE, ): match = pattern.search(normalized_text) if not match: continue try: return int(match.group(1) or 0) except Exception: continue return 0 def _extract_active_thread_snapshot(lines: list[str]) -> dict: for line in reversed(lines): match = _THREAD_COUNT_RE.search(line) if match: return { "active": int(match.group(1)), "max": int(match.group(2)), } return {"active": 0, "max": 0} def _estimate_active_threads_from_recent_lines(lines: list[str], *, limit: int) -> int: if not lines: return 0 active_domains: list[str] = [] seen_domains: set[str] = set() for line in reversed(lines[-80:]): match = _STEP_TRACE_DOMAIN_RE.search(line) or _REGISTER_DOMAIN_RE.search(line) if not match: continue domain = str(match.group(1) or "").strip() if not domain or domain in seen_domains: continue seen_domains.add(domain) active_domains.append(domain) if len(active_domains) >= int(limit): break return len(active_domains) def _parse_time(raw: str | None) -> datetime | None: if not raw: return None text = str(raw).strip() if not text: return None try: return datetime.fromisoformat(text) except ValueError: pass for fmt in _TIMESTAMP_FORMATS: try: return datetime.strptime(text, fmt) except ValueError: continue return None def _extract_log_time(line: str, *, reference_year: int | None = None) -> datetime | None: if len(line) < 19: text = str(line or "").strip() else: text = str(line or "") candidates = [text[:26], text[:19]] for candidate in candidates: for fmt in _TIMESTAMP_FORMATS: if len(candidate) != len(datetime.now().strftime(fmt)): continue try: return datetime.strptime(candidate, fmt) except ValueError: continue syslog_candidate = str(text[:15] or "").strip() if syslog_candidate: try: parsed = datetime.strptime(syslog_candidate, _SYSLOG_TIMESTAMP_FORMAT) return parsed.replace(year=int(reference_year or datetime.now().year)) except ValueError: pass return None def _read_worker_journal_lines(service_name: str, *, max_lines: int) -> tuple[list[str], str | None]: normalized_service_name = str(service_name or "").strip() if not normalized_service_name: return [], None try: completed = subprocess.run( ["journalctl", "-u", normalized_service_name, "-n", str(max_lines), "--no-pager"], capture_output=True, text=True, timeout=12, ) except Exception: return [], None output = str(completed.stdout or "").strip() if completed.returncode != 0 or not output: return [], None lines = [str(line or "").rstrip() for line in output.splitlines() if str(line or "").strip()] if not lines: return [], None return lines[-max_lines:], datetime.now(timezone.utc).isoformat() def _load_recent_worker_lines(runtime_settings: dict, *, max_lines: int = 160) -> tuple[bool, str | None, list[str]]: worker_log = resolve_domain_path("detect_worker.log", "logs/detect_worker.log") worker_online = False last_log_time: str | None = None recent_lines = tail_lines("detect_worker.log", max_lines=max_lines) if worker_log and worker_log.exists(): modified = datetime.fromtimestamp(worker_log.stat().st_mtime, tz=timezone.utc) last_log_time = modified.isoformat() worker_online = (datetime.now(timezone.utc) - modified).total_seconds() < 180 if str(runtime_settings.get("worker_mode") or "").strip() == "linux-systemd": service_name = str(runtime_settings.get("worker_service_name") or "").strip() or "domaincheck-worker" journal_lines, journal_last_time = _read_worker_journal_lines(service_name, max_lines=max_lines) if journal_lines: recent_lines = journal_lines worker_online = True if journal_last_time: last_log_time = journal_last_time return worker_online, last_log_time, recent_lines def _filter_lines_since(lines: list[str], started_at: str | None) -> list[str]: started_time = _parse_time(started_at) if not started_time: return lines filtered: list[str] = [] parsed_any = False for line in lines: line_time = _extract_log_time(line, reference_year=started_time.year) if line_time is None: continue parsed_any = True if line_time >= started_time: filtered.append(line) if not parsed_any: return lines return filtered or lines def _recent_event(lines: list[str]) -> str: interesting_keywords = ( "开始检测", "获取到", "当前批次检测完成", "域名检测任务完成", "代理池刷新完成", "没有需要检测的域名", "检测已停止", ) for line in reversed(lines): if any(keyword in line for keyword in interesting_keywords): return line return "" def _build_remote_log_lines( active_job: dict | None, *, enabled: bool, mode: str, limit: int = 240, ) -> list[str]: return _build_remote_log_snapshot(active_job, enabled=enabled, mode=mode, limit=limit)["lines"] def _build_remote_log_snapshot( active_job: dict | None, *, enabled: bool, mode: str, limit: int = 240, ) -> dict: if not enabled: return { "lines": [], "line_count": 0, "last_at": "", "last_line": "", "source_nodes": [], "source_node_count": 0, } if not active_job: return { "lines": [], "line_count": 0, "last_at": "", "last_line": "", "source_nodes": [], "source_node_count": 0, } events = list(active_job.get("current_cycle_events") or active_job.get("recent_events") or []) if not events: return { "lines": [], "line_count": 0, "last_at": "", "last_line": "", "source_nodes": [], "source_node_count": 0, } lines: list[str] = [] source_nodes: set[str] = set() source_node_summaries: dict[str, dict] = {} last_at = "" last_line = "" 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]}..." formatted_line = f"[{created_at}] [{node_code}] {message}" lines.append(formatted_line) source_nodes.add(node_code) node_summary = source_node_summaries.setdefault( node_code, { "node_code": node_code, "line_count": 0, "key_line_count": 0, "full_line_count": 0, "last_at": "", "last_line": "", }, ) node_summary["line_count"] += 1 if event_mode == "full": node_summary["full_line_count"] += 1 else: node_summary["key_line_count"] += 1 node_summary["last_at"] = created_at node_summary["last_line"] = formatted_line last_at = created_at last_line = formatted_line sliced_lines = lines[-max(1, int(limit or 240)) :] sorted_source_node_summaries = sorted( source_node_summaries.values(), key=lambda item: ( str(item.get("last_at") or ""), str(item.get("node_code") or ""), ), reverse=True, ) return { "lines": sliced_lines, "line_count": len(sliced_lines), "last_at": last_at, "last_line": last_line, "source_nodes": sorted(source_nodes), "source_node_count": len(source_nodes), "source_node_summaries": sorted_source_node_summaries, } def _build_remote_log_snapshot_from_debug_events( active_job: dict | None, *, enabled: bool, mode: str, limit: int = 240, ) -> dict: if not enabled: return { "lines": [], "line_count": 0, "last_at": "", "last_line": "", "source_nodes": [], "source_node_count": 0, "source_node_summaries": [], } normalized_mode = str(mode or "key").strip().lower() if normalized_mode not in {"key", "full"}: normalized_mode = "key" participating_node_codes = { str(item.get("node_code") or "").strip() for item in list((active_job or {}).get("node_stats") or []) if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned" } created_after = (datetime.now() - timedelta(hours=6)).strftime("%Y-%m-%d %H:%M:%S") safe_limit = max(1, int(limit or 240)) node_limit = max(40, min(200, safe_limit)) records: list[dict] = [] if participating_node_codes: for node_code in sorted(participating_node_codes): payload = list_debug_events( limit=node_limit, created_after=created_after, node_code=node_code, ) records.extend(list(payload.get("records") or [])) records.sort( key=lambda item: ( str(item.get("created_at") or ""), int(item.get("id") or 0), ), reverse=True, ) else: payload = list_debug_events(limit=max(safe_limit * 4, 240), created_after=created_after) records = list(payload.get("records") or []) if not records: return { "lines": [], "line_count": 0, "last_at": "", "last_line": "", "source_nodes": [], "source_node_count": 0, "source_node_summaries": [], } lines: list[str] = [] source_nodes: set[str] = set() source_node_summaries: dict[str, dict] = {} last_at = "" last_line = "" for record in reversed(records): event_type = str(record.get("event_type") or "").strip() if event_type not in _REMOTE_DEBUG_EVENT_TYPES: continue node_code = str(record.get("node_code") or "").strip() or "unknown" if participating_node_codes and node_code not in participating_node_codes: continue if not _debug_event_matches_active_job(record, active_job): continue message = str(record.get("message") or "").strip() if not message: continue created_at = str(record.get("created_at") or "").strip() payload = record.get("payload") if isinstance(record.get("payload"), dict) else {} 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]}..." formatted_line = f"[{created_at}] [{node_code}] {message}" lines.append(formatted_line) source_nodes.add(node_code) node_summary = source_node_summaries.setdefault( node_code, { "node_code": node_code, "line_count": 0, "key_line_count": 0, "full_line_count": 0, "last_at": "", "last_line": "", }, ) node_summary["line_count"] += 1 if event_mode == "full": node_summary["full_line_count"] += 1 else: node_summary["key_line_count"] += 1 node_summary["last_at"] = created_at node_summary["last_line"] = formatted_line last_at = created_at last_line = formatted_line sliced_lines = _slice_remote_log_lines_fairly(lines, limit=safe_limit) sorted_source_node_summaries = sorted( source_node_summaries.values(), key=lambda item: ( str(item.get("last_at") or ""), str(item.get("node_code") or ""), ), reverse=True, ) return { "lines": sliced_lines, "line_count": len(sliced_lines), "last_at": last_at, "last_line": last_line, "source_nodes": sorted(source_nodes), "source_node_count": len(source_nodes), "source_node_summaries": sorted_source_node_summaries, } def _merge_remote_log_snapshots(primary: dict, secondary: dict, *, limit: int = 240) -> dict: merged_lines: list[str] = [] seen_lines: set[str] = set() for raw_line in list(primary.get("lines") or []) + list(secondary.get("lines") or []): line = str(raw_line or "").strip() if not line or line in seen_lines: continue seen_lines.add(line) merged_lines.append(line) if limit > 0: merged_lines = _slice_remote_log_lines_fairly(merged_lines, limit=limit) summaries: dict[str, dict] = {} for snapshot in (primary, secondary): for raw_summary in list(snapshot.get("source_node_summaries") or []): if not isinstance(raw_summary, dict): continue node_code = str(raw_summary.get("node_code") or "").strip() if not node_code: continue summary = summaries.setdefault( node_code, { "node_code": node_code, "line_count": 0, "key_line_count": 0, "full_line_count": 0, "last_at": "", "last_line": "", }, ) summary["line_count"] = max(int(summary.get("line_count", 0) or 0), int(raw_summary.get("line_count", 0) or 0)) summary["key_line_count"] = max(int(summary.get("key_line_count", 0) or 0), int(raw_summary.get("key_line_count", 0) or 0)) summary["full_line_count"] = max(int(summary.get("full_line_count", 0) or 0), int(raw_summary.get("full_line_count", 0) or 0)) raw_last_at = str(raw_summary.get("last_at") or "") if raw_last_at >= str(summary.get("last_at") or ""): summary["last_at"] = raw_last_at summary["last_line"] = str(raw_summary.get("last_line") or "") source_nodes = sorted( { str(node_code or "").strip() for node_code in list(primary.get("source_nodes") or []) + list(secondary.get("source_nodes") or []) if str(node_code or "").strip() } ) last_at = max(str(primary.get("last_at") or ""), str(secondary.get("last_at") or "")) last_line = str(primary.get("last_line") or "") if str(secondary.get("last_at") or "") >= str(primary.get("last_at") or ""): last_line = str(secondary.get("last_line") or last_line) return { "lines": merged_lines, "line_count": len(merged_lines), "last_at": last_at, "last_line": last_line, "source_nodes": source_nodes, "source_node_count": len(source_nodes), "source_node_summaries": sorted( summaries.values(), key=lambda item: ( str(item.get("last_at") or ""), str(item.get("node_code") or ""), ), reverse=True, ), } 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 _resolve_remote_log_snapshot( active_job: dict | None, runs: list[dict], *, enabled: bool, mode: str, limit: int = 240, ) -> dict: primary_snapshot = _build_remote_log_snapshot(active_job, enabled=enabled, mode=mode, limit=limit) debug_snapshot = _build_remote_log_snapshot_from_debug_events(active_job, enabled=enabled, mode=mode, limit=limit) if int(primary_snapshot.get("line_count", 0) or 0) <= 0: return debug_snapshot if int(debug_snapshot.get("line_count", 0) or 0) <= 0: return primary_snapshot return _merge_remote_log_snapshots(primary_snapshot, debug_snapshot, limit=limit) def _load_runtime_state() -> dict: try: redis_client = get_redis() for key in (_runtime_state_key(), _RUNTIME_STATE_KEY): raw = redis_client.get(key) if not raw: continue data = json.loads(raw) if not isinstance(data, dict): continue if key == _RUNTIME_STATE_KEY: payload_node_code = str(data.get("node_code") or "").strip() if payload_node_code and payload_node_code != str(settings.node_code or "").strip(): continue return data return {} except Exception: return {} def _load_disabled_managed_node_codes(node_codes: list[str] | None = None) -> set[str]: global _DISABLED_MANAGED_NODE_CACHE, _DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT normalized_codes = [ str(item or "").strip() for item in list(node_codes or []) if str(item or "").strip() ] now_ts = time.time() if not normalized_codes and now_ts < _DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT: return set(_DISABLED_MANAGED_NODE_CACHE) try: with get_db() as conn: with conn.cursor() as cur: if normalized_codes: cur.execute( """ SELECT node_code FROM ops_managed_nodes WHERE is_enabled = FALSE AND node_code = ANY(%s) """, (normalized_codes,), ) else: cur.execute( """ SELECT node_code FROM ops_managed_nodes WHERE is_enabled = FALSE """ ) rows = list(cur.fetchall() or []) except Exception: if normalized_codes: return {code for code in normalized_codes if code in _DISABLED_MANAGED_NODE_CACHE} return set(_DISABLED_MANAGED_NODE_CACHE) disabled_codes = { str(row[0] or "").strip() for row in rows if str(row[0] or "").strip() } if normalized_codes: return disabled_codes _DISABLED_MANAGED_NODE_CACHE = disabled_codes _DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT = now_ts + 5.0 return set(disabled_codes) def _load_runtime_state_from_cluster_node() -> dict: try: with get_db() as conn: with conn.cursor() as cur: cur.execute( """ SELECT current_load, metadata_json, last_heartbeat_at FROM detect_worker_nodes WHERE node_code = %s LIMIT 1 """, (settings.node_code,), ) row = cur.fetchone() if not row: return {} current_load, metadata_json, last_heartbeat_at = row metadata = metadata_json if isinstance(metadata_json, dict) else {} if not metadata: return {} runtime_state = { "node_code": settings.node_code, "phase": str(metadata.get("phase") or metadata.get("phase_label") or "").strip(), "detail": str(metadata.get("detail") or metadata.get("phase_detail") or "").strip(), "service_running": True, "detecting": bool(metadata.get("detecting", False) or int(current_load or 0) > 0), "stop_requested": False, "available_proxy_count": int(metadata.get("available_proxy_count", 0) or 0), "active_threads": int(metadata.get("active_threads", 0) or 0), "max_threads": int(metadata.get("max_threads", 0) or 0), "job_id": metadata.get("job_id"), "job_code": str(metadata.get("job_code") or metadata.get("active_job_code") or "").strip(), "updated_at": str(metadata.get("updated_at") or (_format_time(last_heartbeat_at) if last_heartbeat_at else "")).strip(), } if runtime_state["detail"] or runtime_state["active_threads"] > 0 or runtime_state["max_threads"] > 0: return runtime_state return {} except Exception: return {} def _load_runtime_states_from_cluster_nodes(node_codes: list[str] | tuple[str, ...]) -> dict[str, dict]: normalized_node_codes = [ str(item or "").strip() for item in list(node_codes or []) if str(item or "").strip() ] if not normalized_node_codes: return {} try: with get_db() as conn: with conn.cursor() as cur: cur.execute( """ SELECT node_code, current_load, metadata_json, last_heartbeat_at FROM detect_worker_nodes WHERE node_code = ANY(%s) """, (normalized_node_codes,), ) rows = list(cur.fetchall() or []) except Exception: return {} payload: dict[str, dict] = {} for row in rows: node_code = str(row[0] or "").strip() if not node_code: continue current_load = int(row[1] or 0) metadata_json = row[2] last_heartbeat_at = row[3] metadata = metadata_json if isinstance(metadata_json, dict) else {} payload[node_code] = { "node_code": node_code, "current_load": current_load, "last_heartbeat_at": ( last_heartbeat_at.isoformat(sep=" ", timespec="seconds") if hasattr(last_heartbeat_at, "isoformat") else str(last_heartbeat_at or "").strip() ), "available_proxy_count": int( metadata.get("available_proxy_count", metadata.get("proxy_last_available_count", 0)) or 0 ), "proxy_runtime_label": str(metadata.get("proxy_runtime_label") or "").strip(), "proxy_runtime_reason": str(metadata.get("proxy_runtime_reason") or "").strip(), "proxy_last_refresh_status": str(metadata.get("proxy_last_refresh_status") or "").strip(), "proxy_last_refresh_time": str(metadata.get("proxy_last_refresh_time") or "").strip(), "proxy_last_refresh_source_count": int(metadata.get("proxy_last_refresh_source_count", 0) or 0), "proxy_last_refresh_total_items": int(metadata.get("proxy_last_refresh_total_items", 0) or 0), "proxy_last_validated_count": int(metadata.get("proxy_last_validated_count", 0) or 0), "active_threads": int(metadata.get("active_threads", 0) or 0), "max_threads": int(metadata.get("max_threads", 0) or 0), "detect_participating": bool(metadata.get("detect_participating", False) or current_load > 0), } return payload def _load_recent_proxy_debug_events(node_codes: list[str] | tuple[str, ...], *, window_minutes: int = 20) -> list[dict]: normalized_node_codes = [ str(item or "").strip() for item in list(node_codes or []) if str(item or "").strip() and str(item or "").strip() != "unassigned" ] if not normalized_node_codes: return [] safe_window_minutes = max(5, min(int(window_minutes or 20), 120)) created_after = datetime.now() - timedelta(minutes=safe_window_minutes) safe_limit = max(80, min(len(normalized_node_codes) * 20, 800)) try: with get_db() as conn: with conn.cursor() as cur: cur.execute( """ SELECT node_code, message, payload_json, created_at FROM detect_debug_events WHERE event_type = 'worker_log' AND node_code = ANY(%s) AND created_at >= %s ORDER BY created_at DESC, id DESC LIMIT %s """, (normalized_node_codes, created_after, safe_limit), ) rows = list(cur.fetchall() or []) except Exception: return [] return [ { "event_type": "worker_log", "node_code": str(row[0] or "").strip(), "message": str(row[1] or "").strip(), "payload": row[2] if isinstance(row[2], dict) else {}, "created_at": ( row[3].isoformat(sep=" ", timespec="seconds") if hasattr(row[3], "isoformat") else str(row[3] or "").strip() ), } for row in rows if str(row[0] or "").strip() and str(row[1] or "").strip() ] def _is_proxy_runtime_message(message: str) -> bool: normalized_message = str(message or "").strip() if not normalized_message: return False if _extract_available_proxy_count_from_text(normalized_message) > 0: return True lowered_message = normalized_message.lower() return any( keyword in normalized_message or keyword in lowered_message for keyword in ( "代理", "proxy", "cooldown", "rate limited", ) ) def _infer_proxy_runtime_label_from_message(message: str, *, available_proxy_count: int) -> tuple[str, str]: normalized_message = str(message or "").strip() lowered_message = normalized_message.lower() if available_proxy_count > 0: return "代理正常", "aggregate_log_healthy" if "rate limited" in lowered_message or "cooldown" in lowered_message or "冷却" in normalized_message: return "代理源暂时冷却中", "aggregate_log_cooldown" if any(keyword in normalized_message for keyword in ("未取到新代理", "未取到可用代理数据", "无可用代理", "未返回可用代理数据")): return "代理待补货", "aggregate_log_empty" if "等待首刷" in normalized_message: return "等待首刷", "aggregate_log_waiting" return "", "" def _build_aggregate_proxy_runtime_rows_from_events( *, active_job: dict | None, settings_payload: dict, participant_node_codes: list[str], participant_server_codes: list[str], ) -> dict[str, dict]: normalized_active_job = dict(active_job or {}) events = list(normalized_active_job.get("current_cycle_events") or normalized_active_job.get("recent_events") or []) events.extend(_load_recent_proxy_debug_events(participant_node_codes)) if not events: return {} current_cycle_token = str(normalized_active_job.get("current_cycle_token") or "").strip() allowed_server_codes = {str(item or "").strip() for item in list(participant_server_codes or []) if str(item or "").strip()} rows: dict[str, dict] = {} for event in events: if not isinstance(event, dict): continue if str(event.get("event_type") or "").strip() != "worker_log": 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 node_code = str(event.get("node_code") or "").strip() if not node_code or node_code == "unassigned": continue capacity_node_code, _ = _resolve_capacity_node_code(node_code, settings_payload) server_code = str(capacity_node_code or node_code or "").strip() if not server_code or (allowed_server_codes and server_code not in allowed_server_codes): continue message = str(event.get("message") or "").strip() if not _is_proxy_runtime_message(message): continue created_at = str(event.get("created_at") or "").strip() available_proxy_count = _extract_available_proxy_count_from_text(message) label, reason = _infer_proxy_runtime_label_from_message( message, available_proxy_count=available_proxy_count, ) row = rows.setdefault( server_code, { "node_code": server_code, "available_proxy_count": 0, "proxy_runtime_label": "", "proxy_runtime_reason": "", "proxy_last_refresh_status": "", "proxy_last_refresh_time": "", "proxy_last_refresh_source_count": 0, "proxy_last_refresh_total_items": 0, "proxy_last_validated_count": 0, "_count_seen_at": "", "_status_seen_at": "", }, ) if available_proxy_count > 0 and created_at >= str(row.get("_count_seen_at") or ""): row["available_proxy_count"] = int(available_proxy_count or 0) row["_count_seen_at"] = created_at if created_at >= str(row.get("_status_seen_at") or ""): row["proxy_last_refresh_status"] = message row["proxy_last_refresh_time"] = created_at row["_status_seen_at"] = created_at if label: row["proxy_runtime_label"] = label if reason: row["proxy_runtime_reason"] = reason return { server_code: { key: value for key, value in row.items() if not str(key).startswith("_") } for server_code, row in rows.items() } def _build_aggregate_proxy_runtime( *, active_job: dict | None, settings_payload: dict, fallback_available_proxy_count: int, fallback_proxy_runtime: dict, ) -> tuple[int, dict]: node_rows = list((active_job or {}).get("distributed_node_stats") or (active_job or {}).get("node_stats") or []) participant_node_codes: list[str] = [] participant_server_codes: list[str] = [] for raw_item in node_rows: if not isinstance(raw_item, dict) or not _is_current_participant_bucket(raw_item): continue node_code = str(raw_item.get("node_code") or "").strip() if node_code and node_code != "unassigned" and node_code not in participant_node_codes: participant_node_codes.append(node_code) capacity_node_code, _ = _resolve_capacity_node_code(node_code, settings_payload) normalized_server_code = str(capacity_node_code or node_code or "").strip() if not normalized_server_code or normalized_server_code == "unassigned": continue if normalized_server_code not in participant_server_codes: participant_server_codes.append(normalized_server_code) if not participant_server_codes: return fallback_available_proxy_count, fallback_proxy_runtime event_runtime = _build_aggregate_proxy_runtime_rows_from_events( active_job=active_job, settings_payload=settings_payload, participant_node_codes=participant_node_codes, participant_server_codes=participant_server_codes, ) cluster_runtime = _load_runtime_states_from_cluster_nodes(participant_server_codes) rows: list[dict] = [] for code in participant_server_codes: runtime_row = dict(cluster_runtime.get(code) or {}) event_row = dict(event_runtime.get(code) or {}) if not runtime_row and not event_row: continue merged_row = { "node_code": code, "available_proxy_count": int(runtime_row.get("available_proxy_count", 0) or 0), "proxy_runtime_label": str(runtime_row.get("proxy_runtime_label") or "").strip(), "proxy_runtime_reason": str(runtime_row.get("proxy_runtime_reason") or "").strip(), "proxy_last_refresh_status": str(runtime_row.get("proxy_last_refresh_status") or "").strip(), "proxy_last_refresh_time": str(runtime_row.get("proxy_last_refresh_time") or runtime_row.get("last_heartbeat_at") or "").strip(), "proxy_last_refresh_source_count": int(runtime_row.get("proxy_last_refresh_source_count", 0) or 0), "proxy_last_refresh_total_items": int(runtime_row.get("proxy_last_refresh_total_items", 0) or 0), "proxy_last_validated_count": int(runtime_row.get("proxy_last_validated_count", 0) or 0), } if int(merged_row.get("available_proxy_count", 0) or 0) <= 0 and int(event_row.get("available_proxy_count", 0) or 0) > 0: merged_row["available_proxy_count"] = int(event_row.get("available_proxy_count", 0) or 0) if not str(merged_row.get("proxy_runtime_label") or "").strip(): merged_row["proxy_runtime_label"] = str(event_row.get("proxy_runtime_label") or "").strip() if not str(merged_row.get("proxy_runtime_reason") or "").strip(): merged_row["proxy_runtime_reason"] = str(event_row.get("proxy_runtime_reason") or "").strip() if not str(merged_row.get("proxy_last_refresh_status") or "").strip(): merged_row["proxy_last_refresh_status"] = str(event_row.get("proxy_last_refresh_status") or "").strip() if not str(merged_row.get("proxy_last_refresh_time") or "").strip(): merged_row["proxy_last_refresh_time"] = str(event_row.get("proxy_last_refresh_time") or "").strip() if int(merged_row.get("proxy_last_refresh_source_count", 0) or 0) <= 0: merged_row["proxy_last_refresh_source_count"] = int(event_row.get("proxy_last_refresh_source_count", 0) or 0) if int(merged_row.get("proxy_last_refresh_total_items", 0) or 0) <= 0: merged_row["proxy_last_refresh_total_items"] = int(event_row.get("proxy_last_refresh_total_items", 0) or 0) if int(merged_row.get("proxy_last_validated_count", 0) or 0) <= 0: merged_row["proxy_last_validated_count"] = int(event_row.get("proxy_last_validated_count", 0) or 0) rows.append(merged_row) if not rows: return fallback_available_proxy_count, fallback_proxy_runtime total_available_proxy_count = sum(max(0, int(item.get("available_proxy_count", 0) or 0)) for item in rows) latest_refresh_time = max((str(item.get("proxy_last_refresh_time") or "") for item in rows), default="") source_count = sum(int(item.get("proxy_last_refresh_source_count", 0) or 0) for item in rows) raw_items = sum(int(item.get("proxy_last_refresh_total_items", 0) or 0) for item in rows) validated_count = sum(int(item.get("proxy_last_validated_count", 0) or 0) for item in rows) refresh_status_parts = [ f"{str(item.get('node_code') or '')}:{str(item.get('proxy_last_refresh_status') or '').strip()}" for item in rows if str(item.get("proxy_last_refresh_status") or "").strip() ] refresh_status = ";".join(refresh_status_parts[:6]) if len(refresh_status_parts) > 6: refresh_status = f"{refresh_status} 等 {len(refresh_status_parts)} 台" if total_available_proxy_count > 0: return total_available_proxy_count, { "state": "healthy", "label": "集群代理正常", "detail": ( f"参与服务器 {len(rows)} 台,共可用 {total_available_proxy_count} 个代理" + (f";最近状态:{refresh_status}" if refresh_status else "") ), "direct_fallback_active": False, "reason": "aggregate_healthy", "last_refresh_status": refresh_status, "last_refresh_time": latest_refresh_time, "source_count": source_count, "raw_items": raw_items, "validated_count": validated_count, "available_count": total_available_proxy_count, "source_stats": [], "supplier_empty": False, } fallback_label = next((str(item.get("proxy_runtime_label") or "").strip() for item in rows if str(item.get("proxy_runtime_label") or "").strip()), "") fallback_reason = next((str(item.get("proxy_runtime_reason") or "").strip() for item in rows if str(item.get("proxy_runtime_reason") or "").strip()), "") if fallback_label: return 0, { "state": "warming_up", "label": fallback_label, "detail": ( f"参与服务器 {len(rows)} 台,当前尚未汇总到可用代理" + (f";最近状态:{refresh_status}" if refresh_status else "") ), "direct_fallback_active": bool(fallback_proxy_runtime.get("direct_fallback_active", False)), "reason": fallback_reason or "aggregate_proxy_unavailable", "last_refresh_status": refresh_status, "last_refresh_time": latest_refresh_time, "source_count": source_count, "raw_items": raw_items, "validated_count": validated_count, "available_count": 0, "source_stats": [], "supplier_empty": bool(fallback_proxy_runtime.get("supplier_empty", False)), } return fallback_available_proxy_count, fallback_proxy_runtime def _normalize_recent_warning(runtime_state: dict, recent_lines: list[str], available_proxy_count: int) -> str: runtime_warning = str(runtime_state.get("recent_warning", "") or "").strip() if runtime_warning: if runtime_warning in {"未刷新", "代理未启用", "未配置代理池链接"}: return "" if available_proxy_count > 0 and ("无可用代理" in runtime_warning or "未取到可用代理数据" in runtime_warning): return "" return runtime_warning recent_proxy_warning = next( (line for line in reversed(recent_lines) if "代理池刷新失败" in line or "无可用代理" in line or "Redis订阅失败" in line), "", ) if available_proxy_count > 0 and "无可用代理" in recent_proxy_warning: return "" return recent_proxy_warning def _build_proxy_runtime_snapshot(settings_payload: dict, runtime_state: dict, available_proxy_count: int) -> dict: proxy_config = settings_payload.get("proxy_config") or {} proxy_enable = bool(proxy_config.get("proxy_enable", False)) allow_direct = bool(proxy_config.get("allow_direct", False)) refresh_status = str(runtime_state.get("proxy_last_refresh_status", "") or "").strip() refresh_time = str(runtime_state.get("proxy_last_refresh_time", "") or "").strip() source_count = len(proxy_config.get("proxy_urls", [])) source_stats = runtime_state.get("proxy_last_source_stats") or [] raw_items = int(runtime_state.get("proxy_last_refresh_total_items", 0) or 0) validated = int(runtime_state.get("proxy_last_validated_count", 0) or 0) available = int(runtime_state.get("proxy_last_available_count", available_proxy_count) or available_proxy_count) source_ok_count = sum(1 for item in source_stats if str(item.get("status", "") or "").strip() == "ok") supplier_empty = bool(source_stats) and source_ok_count == len(source_stats) and raw_items <= 0 if not proxy_enable: return { "state": "disabled", "label": "未启用代理", "detail": "当前使用直连模式,未启用代理池", "direct_fallback_active": True, "reason": "proxy_disabled", "last_refresh_status": refresh_status or "代理未启用", "last_refresh_time": refresh_time, "source_count": source_count, "raw_items": raw_items, "validated_count": validated, "available_count": available, "source_stats": source_stats, "supplier_empty": False, } if available_proxy_count > 0: detail = f"代理池当前可用 {available_proxy_count} 个代理,配置来源 {source_count} 个" if refresh_status: detail = f"{detail};最近状态:{refresh_status}" return { "state": "healthy", "label": "代理正常", "detail": detail, "direct_fallback_active": False, "reason": "healthy", "last_refresh_status": refresh_status, "last_refresh_time": refresh_time, "source_count": source_count, "raw_items": raw_items, "validated_count": validated, "available_count": available, "source_stats": source_stats, "supplier_empty": False, } if refresh_status in {"", "未刷新"} and source_count > 0: if allow_direct: return { "state": "degraded_direct", "label": "等待首刷", "detail": f"代理配置已下发,但代理池尚未完成首轮刷新;当前先按直连继续执行;最近状态:{refresh_status or '未刷新'}", "direct_fallback_active": True, "reason": "proxy_not_refreshed_yet", "last_refresh_status": refresh_status or "未刷新", "last_refresh_time": refresh_time, "source_count": source_count, "raw_items": raw_items, "validated_count": validated, "available_count": available, "source_stats": source_stats, "supplier_empty": False, } return { "state": "warming_up", "label": "等待首刷", "detail": "代理配置已下发,但代理池尚未完成首轮刷新;由于未允许直连,检测链路会等待代理刷新完成", "direct_fallback_active": False, "reason": "proxy_not_refreshed_yet", "last_refresh_status": refresh_status or "未刷新", "last_refresh_time": refresh_time, "source_count": source_count, "raw_items": raw_items, "validated_count": validated, "available_count": available, "source_stats": source_stats, "supplier_empty": False, } if allow_direct: detail = "代理池当前无可用代理,已自动降级为直连继续执行" reason = "no_available_proxy" if supplier_empty: reason = "supplier_empty_pool" detail = "代理源最近都返回正常响应,但原始代理数为 0,当前判断为供应池为空;系统已自动降级为直连继续执行" elif raw_items > 0 and validated > 0: reason = "proxy_validation_zero" detail = ( f"代理源最近返回了 {raw_items} 个代理,已验证 {validated} 个,但当前 0 个可用;" "系统已自动降级为直连继续执行" ) if refresh_status: detail = f"{detail};最近状态:{refresh_status}" return { "state": "degraded_direct", "label": "降级直连", "detail": detail, "direct_fallback_active": True, "reason": reason, "last_refresh_status": refresh_status or "当前无可用代理", "last_refresh_time": refresh_time, "source_count": source_count, "raw_items": raw_items, "validated_count": validated, "available_count": available, "source_stats": source_stats, "supplier_empty": supplier_empty, } detail = "代理池当前无可用代理,且未允许直连,检测链路会等待代理恢复" reason = "no_available_proxy" if supplier_empty: reason = "supplier_empty_pool" detail = "代理源最近都返回正常响应,但原始代理数为 0,当前判断为供应池为空;由于未允许直连,检测链路会等待代理恢复" elif raw_items > 0 and validated > 0: reason = "proxy_validation_zero" detail = ( f"代理源最近返回了 {raw_items} 个代理,已验证 {validated} 个,但当前 0 个可用;" "由于未允许直连,检测链路会等待代理恢复" ) if refresh_status: detail = f"{detail};最近状态:{refresh_status}" return { "state": "blocked_no_proxy", "label": "等待代理", "detail": detail, "direct_fallback_active": False, "reason": reason, "last_refresh_status": refresh_status or "当前无可用代理", "last_refresh_time": refresh_time, "source_count": source_count, "raw_items": raw_items, "validated_count": validated, "available_count": available, "source_stats": source_stats, "supplier_empty": supplier_empty, } def get_detect_status() -> dict: global _DETECT_STATUS_CACHE_EXPIRES_AT, _DETECT_STATUS_CACHE_VALUE now_ts = time.monotonic() with _DETECT_STATUS_CACHE_LOCK: if _DETECT_STATUS_CACHE_VALUE is not None and now_ts < _DETECT_STATUS_CACHE_EXPIRES_AT: return _clone_detect_status_payload(_DETECT_STATUS_CACHE_VALUE) try: ensure_runtime_schema() except Exception: # Node agent heartbeats should degrade gracefully even if runtime schema # initialization is temporarily unavailable. pass queries = { "pending": "select count(*) from domains where detect_status = 0", "completed": "select count(*) from domains where detect_status = 1", "running": "select count(*) from domains where detect_status = 2", "blacklisted": "select count(*) from domains where detect_status = 3", "failed": "select count(*) from domains where detect_status = 4", "registerable": "select count(*) from domains where detect_status = 1 and register_status = 2", } progress: dict[str, int] = {key: 0 for key in queries} try: with get_db() as conn: with conn.cursor() as cur: for key, query in queries.items(): try: cur.execute(query) progress[key] = cur.fetchone()[0] except Exception: progress[key] = 0 except Exception: # Worker runtime/status pages should still render using runtime-state and # cluster fallbacks even when the local DB endpoint is temporarily wrong # or unreachable (for example remote worker nodes without direct DB access). pass settings_payload = get_settings_payload() worker_expected_on_this_node = _local_worker_expected_on_this_node() runtime_settings = get_runtime_settings() worker_online, last_log_time, recent_lines = _load_recent_worker_lines(runtime_settings, max_lines=160) runtime = detect_worker_runtime() runtime_state = _load_runtime_state() if not runtime_state: runtime_state = _load_runtime_state_from_cluster_node() if not worker_expected_on_this_node: worker_online = False last_log_time = "" recent_lines = [] runtime_state = {} runtime_started_at = runtime.get("latest_start_time", "") recent_lines = _filter_lines_since(recent_lines, runtime_started_at) available_proxy_count = _extract_available_proxy_count(recent_lines) active_thread_snapshot = _extract_active_thread_snapshot(recent_lines) if runtime_state: available_proxy_count = int(runtime_state.get("available_proxy_count", available_proxy_count) or available_proxy_count) active_thread_snapshot = { "active": int(runtime_state.get("active_threads", active_thread_snapshot["active"]) or active_thread_snapshot["active"]), "max": int(runtime_state.get("max_threads", active_thread_snapshot["max"]) or active_thread_snapshot["max"]), } registerable_count = int(progress.get("registerable", 0) or 0) progress_total = sum( int(progress.get(key, 0) or 0) for key in ("pending", "completed", "running", "blacklisted", "failed") ) progress_done = progress.get("completed", 0) + progress.get("blacklisted", 0) + progress.get("failed", 0) progress_percent = round((progress_done / progress_total) * 100, 2) if progress_total > 0 else 0 runtime_started_at = runtime.get("latest_start_time", "") recent_lines = _filter_lines_since(recent_lines, runtime_started_at) recent_proxy_warning = _normalize_recent_warning(runtime_state, recent_lines, available_proxy_count) proxy_runtime = _build_proxy_runtime_snapshot(settings_payload, runtime_state, available_proxy_count) thread_count_resolution = resolve_thread_count(settings_payload=settings_payload) effective_thread_count = int(thread_count_resolution["effective_thread_count"]) worker_online = worker_online or runtime.get("running", False) if runtime_state.get("service_running") is True: worker_online = True if active_thread_snapshot["active"] <= 0 and (runtime_state.get("detecting", False) or runtime.get("running", False)): estimated_active_threads = _estimate_active_threads_from_recent_lines( recent_lines, limit=max(1, effective_thread_count), ) if estimated_active_threads > 0: active_thread_snapshot["active"] = estimated_active_threads inferred_detecting = bool( runtime_state.get("detecting", False) or int(progress.get("running", 0) or 0) > 0 or int(active_thread_snapshot.get("active", 0) or 0) > 0 ) if not inferred_detecting: active_thread_snapshot = {"active": 0, "max": active_thread_snapshot["max"] or effective_thread_count} settings_summary = { "thread_count": effective_thread_count, "thread_count_default": int(thread_count_resolution["default_thread_count"]), "thread_count_source": str(thread_count_resolution["source"]), "thread_count_override": thread_count_resolution["override_thread_count"], "thread_count_node_code": str(thread_count_resolution["node_code"]), "proxy_enable": settings_payload["proxy_config"].get("proxy_enable", False), "allow_direct": settings_payload["proxy_config"].get("allow_direct", False), "proxy_pool_count": len(settings_payload["proxy_config"].get("proxy_urls", [])), } try: active_job = get_active_detect_job_summary(event_limit=240) except Exception: active_job = None aggregate_detect_view = bool(settings.node_region == "overseas" and settings.node_role == "control" and active_job) aggregate_queue_health = {} if aggregate_detect_view: try: aggregate_queue_health = get_detect_queue_health(window_minutes=15) except Exception: aggregate_queue_health = {} active_job = _merge_aggregate_active_job_with_queue_health(active_job, aggregate_queue_health) aggregate_capacity = ( _build_aggregate_detect_capacity(active_job=active_job, settings_payload=settings_payload) if aggregate_detect_view else { "participant_node_codes": [], "participant_node_count": 0, "process_count": 0, "max_threads": 0, "per_process_thread_count": 0, } ) if aggregate_detect_view: raw_aggregate_node_rows = list(active_job.get("distributed_node_stats") or active_job.get("node_stats") or []) aggregate_node_rows = _filter_live_aggregate_runtime_nodes(raw_aggregate_node_rows) aggregate_display_running = sum( _max_runtime_metric( item.get("display_running"), item.get("current_load"), item.get("active_threads"), item.get("items_running"), ) for item in aggregate_node_rows if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned" ) if raw_aggregate_node_rows: display_running = aggregate_display_running else: display_running = _max_runtime_metric( active_job.get("display_active_threads"), active_job.get("display_items_running"), (aggregate_queue_health.get("queue") or {}).get("display_running"), ) progress = { "pending": int(active_job.get("items_pending", 0) or 0), "running": display_running, "completed": int(active_job.get("items_completed", 0) or 0), "failed": int(active_job.get("items_failed", 0) or 0), "blacklisted": int(active_job.get("items_blacklisted", 0) or 0), "registerable": registerable_count, } progress_percent = float(active_job.get("progress_percent", 0) or 0) local_node_bucket = {} for item in list((active_job or {}).get("node_stats") or []): if str(item.get("node_code") or "").strip() == str(settings.node_code or "").strip(): local_node_bucket = item break local_runtime_load = int( local_node_bucket.get("active_threads") or local_node_bucket.get("items_running") or 0 ) local_runtime_max_threads = int(local_node_bucket.get("max_threads", 0) or 0) if active_thread_snapshot["active"] <= 0 and local_runtime_load > 0: active_thread_snapshot["active"] = local_runtime_load if active_thread_snapshot["max"] <= 0: active_thread_snapshot["max"] = local_runtime_max_threads or effective_thread_count if aggregate_detect_view: raw_distributed_node_stats = list(active_job.get("distributed_node_stats") or active_job.get("node_stats") or []) distributed_node_stats = _filter_live_aggregate_runtime_nodes(raw_distributed_node_stats) raw_participant_count = sum( 1 for item in raw_distributed_node_stats if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned" ) live_participant_count = sum( 1 for item in distributed_node_stats if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned" ) dropped_aggregate_node_count = max(0, raw_participant_count - live_participant_count) aggregated_active_threads = 0 aggregated_max_threads = 0 for item in distributed_node_stats: node_code = str(item.get("node_code") or "").strip() if not node_code or node_code == "unassigned": continue aggregated_active_threads += int( item.get("active_threads") or item.get("items_running") or 0 ) aggregated_max_threads += int(item.get("max_threads", 0) or 0) if aggregated_active_threads > 0: active_thread_snapshot["active"] = aggregated_active_threads elif dropped_aggregate_node_count <= 0 and _max_runtime_metric( active_job.get("display_active_threads"), active_job.get("display_items_running"), progress.get("running"), ) > 0: active_thread_snapshot["active"] = _max_runtime_metric( active_job.get("display_active_threads"), active_job.get("display_items_running"), progress.get("running"), ) if aggregated_max_threads > 0: active_thread_snapshot["max"] = aggregated_max_threads elif dropped_aggregate_node_count <= 0 and _max_runtime_metric( active_job.get("display_max_threads"), aggregate_capacity.get("max_threads"), ) > 0: active_thread_snapshot["max"] = _max_runtime_metric( active_job.get("display_max_threads"), aggregate_capacity.get("max_threads"), ) configured_max_threads = int(aggregate_capacity.get("max_threads", 0) or 0) if configured_max_threads > 0: active_thread_snapshot["max"] = max(active_thread_snapshot["max"], configured_max_threads) available_proxy_count, proxy_runtime = _build_aggregate_proxy_runtime( active_job=active_job, settings_payload=settings_payload, fallback_available_proxy_count=available_proxy_count, fallback_proxy_runtime=proxy_runtime, ) display_worker_process_count = int(runtime.get("process_count", 0) or 0) if aggregate_detect_view and int(aggregate_capacity.get("process_count", 0) or 0) > 0: display_worker_process_count = int(aggregate_capacity.get("process_count", 0) or 0) runtime_snapshot = { **runtime, "detecting": inferred_detecting, "proxy_runtime_state": proxy_runtime["state"], "proxy_runtime_label": proxy_runtime["label"], "proxy_runtime_detail": proxy_runtime["detail"], "proxy_direct_fallback_active": proxy_runtime["direct_fallback_active"], "proxy_runtime_reason": proxy_runtime["reason"], "proxy_supplier_empty": proxy_runtime["supplier_empty"], } 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_snapshot = _resolve_remote_log_snapshot( active_job, runs, enabled=worker_log_sync_enabled, mode=worker_log_sync_mode, limit=240, ) remote_log_lines = list(remote_log_snapshot.get("lines") or []) dependency_alerts = _extract_dependency_alerts(recent_lines) if _local_worker_expected_on_this_node(): append_detect_result_projection_if_changed( detect={ "active_job": active_job, "progress": progress, "phase_label": runtime_state.get("phase", ""), "phase_detail": runtime_state.get("detail", ""), } ) result = { "worker_online": worker_online, "worker_mode": runtime.get("mode", "windows-local"), "worker_service_name": runtime_settings.get("worker_service_name", ""), "api_service_name": runtime_settings.get("api_service_name", ""), "worker_process_count": display_worker_process_count, "worker_latest_start_time": runtime.get("latest_start_time", ""), "worker_runtime_message": runtime.get("message", ""), "runtime_state": runtime_state, "phase_label": runtime_state.get("phase", ""), "phase_detail": runtime_state.get("detail", ""), "detecting": inferred_detecting, "thread_count": effective_thread_count, "thread_count_default": int(thread_count_resolution["default_thread_count"]), "thread_count_source": str(thread_count_resolution["source"]), "thread_count_override": thread_count_resolution["override_thread_count"], "thread_count_node_code": str(thread_count_resolution["node_code"]), "active_thread_count": active_thread_snapshot["active"], "max_thread_count": active_thread_snapshot["max"] or effective_thread_count, "aggregate_process_count": int(aggregate_capacity.get("process_count", 0) or 0), "aggregate_participating_node_count": int(aggregate_capacity.get("participant_node_count", 0) or 0), "aggregate_participating_node_codes": list(aggregate_capacity.get("participant_node_codes") or []), "aggregate_max_thread_count": int(aggregate_capacity.get("max_threads", 0) or 0), "aggregate_thread_count_per_process": int(aggregate_capacity.get("per_process_thread_count", 0) or 0), "proxy_enable": settings_payload["proxy_config"].get("proxy_enable", False), "allow_direct": settings_payload["proxy_config"].get("allow_direct", False), "proxy_pool_count": len(settings_payload["proxy_config"].get("proxy_urls", [])), "available_proxy_count": available_proxy_count, "proxy_runtime_state": proxy_runtime["state"], "proxy_runtime_label": proxy_runtime["label"], "proxy_runtime_detail": proxy_runtime["detail"], "proxy_direct_fallback_active": proxy_runtime["direct_fallback_active"], "proxy_runtime_reason": proxy_runtime["reason"], "proxy_supplier_empty": proxy_runtime["supplier_empty"], "proxy_last_refresh_status": proxy_runtime["last_refresh_status"], "proxy_last_refresh_time": proxy_runtime["last_refresh_time"], "proxy_last_refresh_source_count": proxy_runtime["source_count"], "proxy_last_refresh_total_items": proxy_runtime["raw_items"], "proxy_last_validated_count": proxy_runtime["validated_count"], "proxy_last_available_count": proxy_runtime["available_count"], "proxy_source_stats": proxy_runtime["source_stats"], "dependency_alerts": dependency_alerts, "last_worker_log_time": last_log_time, "progress": progress, "progress_percent": progress_percent, "recent_event": runtime_state.get("detail") or _recent_event(recent_lines), "recent_warning": recent_proxy_warning, "aggregate_detect_view": aggregate_detect_view, "log_lines": recent_lines, "remote_log_lines": remote_log_lines, "remote_log_line_count": int(remote_log_snapshot.get("line_count", 0) or 0), "remote_log_last_at": str(remote_log_snapshot.get("last_at") or ""), "remote_log_last_line": str(remote_log_snapshot.get("last_line") or ""), "remote_log_nodes": list(remote_log_snapshot.get("source_nodes") or []), "remote_log_node_count": int(remote_log_snapshot.get("source_node_count", 0) or 0), "remote_log_node_summaries": list(remote_log_snapshot.get("source_node_summaries") or []), "runs": runs, "active_job": active_job, "worker_log_sync_enabled": worker_log_sync_enabled, "worker_log_sync_mode": worker_log_sync_mode, } with _DETECT_STATUS_CACHE_LOCK: _DETECT_STATUS_CACHE_VALUE = _clone_detect_status_payload(result) _DETECT_STATUS_CACHE_EXPIRES_AT = time.monotonic() + _DETECT_STATUS_CACHE_TTL_SECONDS return result