d
This commit is contained in:
@@ -2,10 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
import subprocess
|
||||
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
|
||||
@@ -16,9 +20,79 @@ from app.services.worker_control_service import detect_worker_runtime
|
||||
|
||||
_PROXY_COUNT_RE = re.compile(r"当前可用代理数[::]\s*(\d+)")
|
||||
_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",
|
||||
}
|
||||
|
||||
|
||||
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 _extract_dependency_alerts(lines: list[str]) -> list[dict]:
|
||||
@@ -83,6 +157,26 @@ def _extract_active_thread_snapshot(lines: list[str]) -> dict:
|
||||
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
|
||||
@@ -101,10 +195,12 @@ def _parse_time(raw: str | None) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_log_time(line: str) -> datetime | None:
|
||||
def _extract_log_time(line: str, *, reference_year: int | None = None) -> datetime | None:
|
||||
if len(line) < 19:
|
||||
return None
|
||||
candidates = [line[:26], 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)):
|
||||
@@ -113,14 +209,79 @@ def _extract_log_time(line: str) -> datetime | None:
|
||||
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 = [line for line in lines if (_extract_log_time(line) or started_time) >= started_time]
|
||||
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
|
||||
|
||||
|
||||
@@ -258,6 +419,204 @@ def _build_remote_log_snapshot(
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
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],
|
||||
@@ -277,17 +636,72 @@ def _resolve_remote_log_snapshot(
|
||||
mode: str,
|
||||
limit: int = 240,
|
||||
) -> dict:
|
||||
return _build_remote_log_snapshot(active_job, enabled=enabled, mode=mode, limit=limit)
|
||||
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()
|
||||
raw = redis_client.get(_RUNTIME_STATE_KEY)
|
||||
if not raw:
|
||||
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_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 {}
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, dict) else {}
|
||||
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 {}
|
||||
|
||||
@@ -361,12 +775,51 @@ def _build_proxy_runtime_snapshot(settings_payload: dict, runtime_state: dict, a
|
||||
"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 {
|
||||
@@ -390,6 +843,12 @@ def _build_proxy_runtime_snapshot(settings_payload: dict, runtime_state: dict, a
|
||||
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 {
|
||||
@@ -410,36 +869,45 @@ def _build_proxy_runtime_snapshot(settings_payload: dict, runtime_state: dict, a
|
||||
|
||||
|
||||
def get_detect_status() -> dict:
|
||||
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] = {}
|
||||
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
|
||||
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_log = resolve_domain_path("detect_worker.log", "logs/detect_worker.log")
|
||||
|
||||
worker_online = False
|
||||
last_log_time = None
|
||||
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
|
||||
|
||||
recent_lines = tail_lines("detect_worker.log", max_lines=160)
|
||||
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()
|
||||
runtime_started_at = runtime.get("latest_start_time", "") if 'runtime' in locals() else ""
|
||||
if not runtime_state:
|
||||
runtime_state = _load_runtime_state_from_cluster_node()
|
||||
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)
|
||||
@@ -449,21 +917,35 @@ def get_detect_status() -> dict:
|
||||
"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"]),
|
||||
}
|
||||
progress_total = sum(progress.values())
|
||||
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 = detect_worker_runtime()
|
||||
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"])
|
||||
runtime_settings = get_runtime_settings()
|
||||
worker_online = worker_online or runtime.get("running", False)
|
||||
if runtime_state.get("service_running") is True:
|
||||
worker_online = True
|
||||
if not runtime_state.get("detecting", False) and not progress.get("running", 0):
|
||||
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,
|
||||
@@ -475,10 +957,59 @@ def get_detect_status() -> dict:
|
||||
"allow_direct": settings_payload["proxy_config"].get("allow_direct", False),
|
||||
"proxy_pool_count": len(settings_payload["proxy_config"].get("proxy_urls", [])),
|
||||
}
|
||||
active_job = get_active_detect_job_summary(event_limit=240)
|
||||
try:
|
||||
active_job = get_active_detect_job_summary(event_limit=240)
|
||||
except Exception:
|
||||
active_job = None
|
||||
if settings.node_region == "overseas" and settings.node_role == "control" and active_job:
|
||||
progress = {
|
||||
"pending": int(active_job.get("items_pending", 0) or 0),
|
||||
"running": int(
|
||||
active_job.get("display_active_threads", active_job.get("display_items_running", active_job.get("items_running", 0)))
|
||||
or 0
|
||||
),
|
||||
"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 settings.node_region == "overseas" and settings.node_role == "control" and active_job:
|
||||
distributed_node_stats = list(active_job.get("distributed_node_stats") or active_job.get("node_stats") or [])
|
||||
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
|
||||
if aggregated_max_threads > 0:
|
||||
active_thread_snapshot["max"] = aggregated_max_threads
|
||||
runtime_snapshot = {
|
||||
**runtime,
|
||||
"detecting": runtime_state.get("detecting", False),
|
||||
"detecting": inferred_detecting,
|
||||
"proxy_runtime_state": proxy_runtime["state"],
|
||||
"proxy_runtime_label": proxy_runtime["label"],
|
||||
"proxy_runtime_detail": proxy_runtime["detail"],
|
||||
@@ -518,7 +1049,7 @@ def get_detect_status() -> dict:
|
||||
"runtime_state": runtime_state,
|
||||
"phase_label": runtime_state.get("phase", ""),
|
||||
"phase_detail": runtime_state.get("detail", ""),
|
||||
"detecting": runtime_state.get("detecting", False),
|
||||
"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"]),
|
||||
@@ -549,6 +1080,7 @@ def get_detect_status() -> dict:
|
||||
"progress_percent": progress_percent,
|
||||
"recent_event": runtime_state.get("detail") or _recent_event(recent_lines),
|
||||
"recent_warning": recent_proxy_warning,
|
||||
"aggregate_detect_view": bool(settings.node_region == "overseas" and settings.node_role == "control" and active_job),
|
||||
"log_lines": recent_lines,
|
||||
"remote_log_lines": remote_log_lines,
|
||||
"remote_log_line_count": int(remote_log_snapshot.get("line_count", 0) or 0),
|
||||
|
||||
Reference in New Issue
Block a user