2464 lines
107 KiB
Python
2464 lines
107 KiB
Python
import json
|
||
import os
|
||
import socket
|
||
import sqlite3
|
||
import threading
|
||
import time
|
||
from collections import Counter, defaultdict
|
||
from contextlib import contextmanager
|
||
from datetime import datetime, timedelta
|
||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from config import (
|
||
MANAGED_WORKER_IDS,
|
||
MONITORING_DB_PATH,
|
||
MONITORING_TIMEZONE,
|
||
MONITORING_TIMELINE_BUCKET_MINUTES,
|
||
normalize_worker_id,
|
||
)
|
||
from log_manager import logger
|
||
from result_codes import (
|
||
APP_ERROR_DESC,
|
||
BUSINESS_ERROR_DESC,
|
||
DOWNLOAD_ERROR_DESC,
|
||
INFRA_ERROR_DESC,
|
||
AppError,
|
||
BusinessError,
|
||
DownloadError,
|
||
InfraError,
|
||
)
|
||
|
||
|
||
SHANGHAI_TZ = ZoneInfo(MONITORING_TIMEZONE)
|
||
RUNNING_STATES = {"running_download", "running_collect"}
|
||
ONLINE_STATES = {"idle_waiting_task", "running_download", "running_collect", "disabled"}
|
||
DISPLAY_STATE_LABELS = {
|
||
"idle_waiting_task": "idle",
|
||
"running_download": "download",
|
||
"running_collect": "collect",
|
||
"disabled": "disabled",
|
||
"offline": "offline",
|
||
}
|
||
RUNTIME_STATE_ORDER = [
|
||
"running_download",
|
||
"running_collect",
|
||
"idle_waiting_task",
|
||
"disabled",
|
||
"offline",
|
||
]
|
||
RUNTIME_STATE_LABELS = {
|
||
"running_download": "下载中",
|
||
"running_collect": "采集中",
|
||
"idle_waiting_task": "空闲等待",
|
||
"disabled": "已禁用",
|
||
"offline": "离线",
|
||
}
|
||
STAGE_LABELS = {
|
||
"download": "下载",
|
||
"collect": "采集",
|
||
}
|
||
STAGE_ORDER = ["download", "collect"]
|
||
FAILURE_DOMAIN_LABELS = {
|
||
"download_network": "下载网络",
|
||
"download_other": "下载其他",
|
||
"infra": "基础设施",
|
||
"app": "应用问题",
|
||
"business": "业务限制",
|
||
"other": "其他",
|
||
}
|
||
DOWNLOAD_NETWORK_ERROR_CODES = {
|
||
int(DownloadError.DOWNLOAD_TIMEOUT),
|
||
int(DownloadError.NETWORK_ERROR),
|
||
}
|
||
DOWNLOAD_NETWORK_MESSAGE_HINTS = (
|
||
"timeout",
|
||
"overtime",
|
||
"jump failed",
|
||
"app page load failed",
|
||
"page load failed",
|
||
"network error",
|
||
"connection error",
|
||
"连接超时",
|
||
"网络错误",
|
||
"超时",
|
||
)
|
||
|
||
|
||
def _safe_json_dumps(payload: Dict[str, Any]) -> str:
|
||
return json.dumps(payload, ensure_ascii=False, sort_keys=True)
|
||
|
||
|
||
def _safe_json_loads(payload: Optional[str], fallback: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
if not payload:
|
||
return fallback or {}
|
||
try:
|
||
value = json.loads(payload)
|
||
return value if isinstance(value, dict) else (fallback or {})
|
||
except (TypeError, ValueError):
|
||
return fallback or {}
|
||
|
||
|
||
def _date_str_from_ts(ts: float) -> str:
|
||
return datetime.fromtimestamp(ts, SHANGHAI_TZ).strftime("%Y-%m-%d")
|
||
|
||
|
||
def _day_bounds(date_str: str, now_ts: Optional[float] = None) -> Tuple[float, float, bool]:
|
||
local_day = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=SHANGHAI_TZ)
|
||
day_start = local_day.timestamp()
|
||
day_end = (local_day + timedelta(days=1)).timestamp()
|
||
now_ts = now_ts or time.time()
|
||
is_today = _date_str_from_ts(now_ts) == date_str
|
||
return day_start, min(day_end, now_ts) if is_today else day_end, is_today
|
||
|
||
|
||
def _interval_overlap(start: float, end: float, bucket_start: float, bucket_end: float) -> bool:
|
||
return max(start, bucket_start) < min(end, bucket_end)
|
||
|
||
|
||
def _build_histogram(values: List[float], edges: List[float]) -> List[Dict[str, Any]]:
|
||
buckets: List[Dict[str, Any]] = []
|
||
previous = 0.0
|
||
for edge in edges:
|
||
buckets.append({"label": _format_bucket_label(previous, edge), "start": previous, "end": edge, "count": 0})
|
||
previous = edge
|
||
buckets.append({"label": _format_bucket_label(previous, None), "start": previous, "end": None, "count": 0})
|
||
for value in values:
|
||
if value is None:
|
||
continue
|
||
placed = False
|
||
for bucket in buckets:
|
||
bucket_end = bucket["end"]
|
||
if bucket_end is None:
|
||
bucket["count"] += 1
|
||
placed = True
|
||
break
|
||
if bucket["start"] <= value < bucket_end:
|
||
bucket["count"] += 1
|
||
placed = True
|
||
break
|
||
if not placed and buckets:
|
||
buckets[-1]["count"] += 1
|
||
return buckets
|
||
|
||
|
||
def _percentile(values: List[float], ratio: float) -> Optional[float]:
|
||
if not values:
|
||
return None
|
||
ordered = sorted(values)
|
||
index = int(round((len(ordered) - 1) * ratio))
|
||
return round(ordered[index], 2)
|
||
|
||
|
||
def _format_minutes(seconds: Optional[float]) -> str:
|
||
seconds = float(seconds or 0.0)
|
||
minutes = seconds / 60.0
|
||
if minutes >= 10:
|
||
return f"{round(minutes):.0f}m"
|
||
if minutes >= 1:
|
||
return f"{minutes:.1f}m"
|
||
if seconds <= 0:
|
||
return "0m"
|
||
return f"{minutes:.1f}m"
|
||
|
||
|
||
def _format_bucket_label(start_seconds: float, end_seconds: Optional[float]) -> str:
|
||
if end_seconds is None:
|
||
return f"{_format_minutes(start_seconds)}+"
|
||
return f"{_format_minutes(start_seconds)}-{_format_minutes(end_seconds)}"
|
||
|
||
|
||
def _humanize_failure_reason(error_type: Optional[str], error_message: Optional[str]) -> str:
|
||
if error_message:
|
||
return str(error_message)
|
||
if not error_type:
|
||
return "未知原因"
|
||
try:
|
||
category_name, code_str = str(error_type).split("/", 1)
|
||
code = int(code_str)
|
||
except (ValueError, TypeError):
|
||
return str(error_type)
|
||
|
||
mapping = {
|
||
"INFRA_ERROR": INFRA_ERROR_DESC,
|
||
"DOWNLOAD_ERROR": DOWNLOAD_ERROR_DESC,
|
||
"APP_ERROR": APP_ERROR_DESC,
|
||
"BUSINESS_ERROR": BUSINESS_ERROR_DESC,
|
||
}.get(category_name)
|
||
if not mapping:
|
||
return str(error_type)
|
||
return mapping.get(code, str(error_type))
|
||
|
||
|
||
def _task_row_total_duration(row: sqlite3.Row) -> float:
|
||
return float(row["total_duration_seconds"] or row["failed_total_duration_seconds"] or 0.0)
|
||
|
||
|
||
def _build_failure_task_sample(row: sqlite3.Row) -> Dict[str, Any]:
|
||
task_started_at = float(row["task_started_at"]) if row["task_started_at"] is not None else None
|
||
task_ended_at = float(row["task_ended_at"]) if row["task_ended_at"] is not None else None
|
||
failure_domain = row["failure_domain"]
|
||
failure_subtype = row["failure_subtype"]
|
||
diagnostics = _extract_task_diagnostics(row)
|
||
if (row["status"] or "unknown") != "success" and (not failure_domain or not failure_subtype):
|
||
failure_domain, failure_subtype = _classify_failure(row["error_type"], row["error_message"])
|
||
return {
|
||
"execution_id": row["execution_id"],
|
||
"task_key": row["task_key"],
|
||
"app_name": row["app_name"] or "--",
|
||
"package_name": row["package_name"] or "--",
|
||
"worker_id": row["worker_id"],
|
||
"status": row["status"] or "unknown",
|
||
"failure_domain": failure_domain or "",
|
||
"failure_domain_label": _humanize_failure_domain(failure_domain),
|
||
"failure_subtype": failure_subtype or "",
|
||
"failure_subtype_label": _humanize_failure_subtype(
|
||
failure_domain,
|
||
failure_subtype,
|
||
row["error_type"],
|
||
row["error_message"],
|
||
),
|
||
"failure_reason": _humanize_failure_reason(row["error_type"], row["error_message"]),
|
||
"error_type": row["error_type"] or "",
|
||
"error_message": row["error_message"] or "",
|
||
"failed_stage": row["failed_stage"] or "--",
|
||
"download_duration_seconds": row["download_duration_seconds"] or 0.0,
|
||
"collect_duration_seconds": row["collect_duration_seconds"] or 0.0,
|
||
"total_duration_seconds": row["total_duration_seconds"] or 0.0,
|
||
"failed_total_duration_seconds": row["failed_total_duration_seconds"] or 0.0,
|
||
"diagnostics": diagnostics,
|
||
"diagnostics_note": _format_diagnostics_note(diagnostics, int(row["retry_count"] or 0)),
|
||
"task_started_at": datetime.fromtimestamp(task_started_at, SHANGHAI_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||
if task_started_at
|
||
else "--",
|
||
"task_ended_at": datetime.fromtimestamp(task_ended_at, SHANGHAI_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||
if task_ended_at
|
||
else "--",
|
||
}
|
||
|
||
|
||
def _parse_error_type(error_type: Optional[str]) -> Tuple[Optional[str], Optional[int]]:
|
||
if not error_type:
|
||
return None, None
|
||
try:
|
||
category_name, code_str = str(error_type).split("/", 1)
|
||
return category_name, int(code_str)
|
||
except (TypeError, ValueError):
|
||
return str(error_type), None
|
||
|
||
|
||
def _download_error_message_is_network(message: Optional[str]) -> bool:
|
||
if not message:
|
||
return False
|
||
lowered = str(message).lower()
|
||
return any(token in lowered for token in DOWNLOAD_NETWORK_MESSAGE_HINTS)
|
||
|
||
|
||
def _normalize_download_errors(download_errors: Any) -> Dict[str, Dict[str, Any]]:
|
||
if isinstance(download_errors, dict):
|
||
return download_errors
|
||
return {}
|
||
|
||
|
||
def _all_download_errors_are_network(download_errors: Any) -> bool:
|
||
errors = _normalize_download_errors(download_errors)
|
||
if not errors:
|
||
return False
|
||
for detail in errors.values():
|
||
if not isinstance(detail, dict):
|
||
return False
|
||
code = detail.get("code")
|
||
try:
|
||
if code is not None and int(code) in DOWNLOAD_NETWORK_ERROR_CODES:
|
||
continue
|
||
except (TypeError, ValueError):
|
||
pass
|
||
if _download_error_message_is_network(detail.get("message")):
|
||
continue
|
||
return False
|
||
return True
|
||
|
||
|
||
def _error_code_name(category_name: Optional[str], code: Optional[int]) -> str:
|
||
if category_name == "DOWNLOAD_ERROR" and code is not None:
|
||
try:
|
||
return DownloadError(int(code)).name
|
||
except ValueError:
|
||
return f"DOWNLOAD_{code}"
|
||
if category_name == "INFRA_ERROR" and code is not None:
|
||
try:
|
||
return InfraError(int(code)).name
|
||
except ValueError:
|
||
return f"INFRA_{code}"
|
||
if category_name == "APP_ERROR" and code is not None:
|
||
try:
|
||
return AppError(int(code)).name
|
||
except ValueError:
|
||
return f"APP_{code}"
|
||
if category_name == "BUSINESS_ERROR" and code is not None:
|
||
try:
|
||
return BusinessError(int(code)).name
|
||
except ValueError:
|
||
return f"BUSINESS_{code}"
|
||
if category_name:
|
||
return str(category_name)
|
||
return "OTHER"
|
||
|
||
|
||
def _classify_failure(
|
||
error_type: Optional[str],
|
||
error_message: Optional[str],
|
||
download_errors: Any = None,
|
||
) -> Tuple[str, str]:
|
||
category_name, code = _parse_error_type(error_type)
|
||
subtype = _error_code_name(category_name, code)
|
||
if category_name == "DOWNLOAD_ERROR":
|
||
if code in DOWNLOAD_NETWORK_ERROR_CODES:
|
||
return "download_network", subtype
|
||
if code == int(DownloadError.ALL_SOURCES_FAILED) and _all_download_errors_are_network(download_errors):
|
||
return "download_network", "ALL_SOURCES_NETWORK_FAILED"
|
||
if _download_error_message_is_network(error_message):
|
||
return "download_network", subtype
|
||
return "download_other", subtype
|
||
if category_name == "INFRA_ERROR":
|
||
return "infra", subtype
|
||
if category_name == "APP_ERROR":
|
||
return "app", subtype
|
||
if category_name == "BUSINESS_ERROR":
|
||
return "business", subtype
|
||
return "other", subtype
|
||
|
||
|
||
def _humanize_failure_domain(domain: Optional[str]) -> str:
|
||
return FAILURE_DOMAIN_LABELS.get(domain or "", "其他")
|
||
|
||
|
||
def _humanize_failure_subtype(
|
||
domain: Optional[str],
|
||
subtype: Optional[str],
|
||
error_type: Optional[str],
|
||
error_message: Optional[str],
|
||
) -> str:
|
||
if domain == "download_network" and subtype == "ALL_SOURCES_NETWORK_FAILED":
|
||
return "所有下载源网络失败"
|
||
reason = _humanize_failure_reason(error_type, None)
|
||
if reason and reason != str(error_type):
|
||
return reason
|
||
if error_message and not subtype:
|
||
return str(error_message)
|
||
if subtype:
|
||
return str(subtype)
|
||
return "未知原因"
|
||
|
||
|
||
def _extract_task_diagnostics(row: sqlite3.Row) -> Dict[str, Any]:
|
||
trace_json = row["trace_json"] if "trace_json" in row.keys() else None
|
||
trace = _safe_json_loads(trace_json)
|
||
diagnostics = trace.get("diagnostics")
|
||
return diagnostics if isinstance(diagnostics, dict) else {}
|
||
|
||
|
||
def _format_diagnostics_note(diagnostics: Dict[str, Any], retry_count: int) -> str:
|
||
labels = []
|
||
if retry_count > 0:
|
||
labels.append(f"重试{retry_count}次")
|
||
if diagnostics.get("emulator_restarted"):
|
||
labels.append("模拟器重启")
|
||
if diagnostics.get("airtest_reinitialized"):
|
||
labels.append("Airtest重连")
|
||
if diagnostics.get("download_recovered"):
|
||
labels.append("下载恢复")
|
||
if diagnostics.get("collect_recovered"):
|
||
labels.append("采集恢复")
|
||
return " / ".join(labels) if labels else "--"
|
||
|
||
|
||
class MonitoringRepository:
|
||
def __init__(self, db_path: str = MONITORING_DB_PATH, initialize_schema: bool = True):
|
||
self.db_path = db_path
|
||
self._write_lock = threading.Lock()
|
||
if initialize_schema:
|
||
self._initialize_schema()
|
||
|
||
@contextmanager
|
||
def _connect(self):
|
||
"""每次操作使用独立连接,避免跨线程共享连接导致游标状态串扰。"""
|
||
connection = sqlite3.connect(self.db_path, timeout=30)
|
||
connection.row_factory = sqlite3.Row
|
||
connection.execute("PRAGMA busy_timeout=30000")
|
||
connection.execute("PRAGMA synchronous=NORMAL")
|
||
connection.execute("PRAGMA wal_autocheckpoint=1000")
|
||
connection.execute("PRAGMA cache_size=-8192")
|
||
connection.execute("PRAGMA temp_store=MEMORY")
|
||
try:
|
||
yield connection
|
||
connection.commit()
|
||
except Exception:
|
||
try:
|
||
connection.rollback()
|
||
except Exception:
|
||
pass
|
||
raise
|
||
finally:
|
||
connection.close()
|
||
|
||
def _table_exists(self, connection: sqlite3.Connection, table_name: str) -> bool:
|
||
row = connection.execute(
|
||
"""
|
||
SELECT 1
|
||
FROM sqlite_master
|
||
WHERE type = 'table' AND name = ?
|
||
""",
|
||
(table_name,),
|
||
).fetchone()
|
||
return bool(row)
|
||
|
||
def close(self):
|
||
"""保留接口兼容;独立连接模式下无需额外关闭。"""
|
||
return
|
||
|
||
def _initialize_schema(self):
|
||
os.makedirs(os.path.dirname(self.db_path) or ".", exist_ok=True)
|
||
with self._connect() as connection:
|
||
connection.execute("PRAGMA journal_mode=WAL")
|
||
connection.executescript(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS controller_session (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
started_at REAL NOT NULL,
|
||
ended_at REAL,
|
||
host TEXT,
|
||
version TEXT,
|
||
stop_reason TEXT,
|
||
last_heartbeat_at REAL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS worker_state_event (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
worker_id TEXT NOT NULL,
|
||
session_id INTEGER,
|
||
event_time REAL NOT NULL,
|
||
state TEXT NOT NULL,
|
||
reason TEXT,
|
||
task_key TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_worker_state_event_worker_time
|
||
ON worker_state_event(worker_id, event_time);
|
||
|
||
CREATE TABLE IF NOT EXISTS task_stage_event (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
execution_id TEXT NOT NULL,
|
||
task_key TEXT NOT NULL,
|
||
worker_id TEXT NOT NULL,
|
||
session_id INTEGER,
|
||
event_time REAL NOT NULL,
|
||
event_type TEXT NOT NULL,
|
||
stage TEXT,
|
||
status TEXT,
|
||
reason TEXT,
|
||
metrics_json TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_task_stage_event_exec_time
|
||
ON task_stage_event(execution_id, event_time);
|
||
|
||
CREATE TABLE IF NOT EXISTS task_execution (
|
||
execution_id TEXT PRIMARY KEY,
|
||
task_key TEXT NOT NULL,
|
||
app_name TEXT,
|
||
package_name TEXT,
|
||
last_updated TEXT,
|
||
collection_task_type TEXT,
|
||
worker_id TEXT NOT NULL,
|
||
session_id INTEGER,
|
||
stat_date TEXT NOT NULL,
|
||
task_started_at REAL,
|
||
task_ended_at REAL,
|
||
status TEXT,
|
||
failed_stage TEXT,
|
||
failure_bucket TEXT,
|
||
retry_count INTEGER DEFAULT 0,
|
||
download_duration_seconds REAL,
|
||
collect_duration_seconds REAL,
|
||
failed_total_duration_seconds REAL,
|
||
total_duration_seconds REAL,
|
||
download_source TEXT,
|
||
error_type TEXT,
|
||
error_message TEXT,
|
||
result_detail TEXT,
|
||
droidbot_steps INTEGER,
|
||
guiagent_steps INTEGER,
|
||
total_steps INTEGER,
|
||
num_nodes INTEGER,
|
||
num_reached_activities INTEGER,
|
||
app_num_total_activities INTEGER,
|
||
failure_domain TEXT,
|
||
failure_subtype TEXT,
|
||
trace_json TEXT,
|
||
last_updated_at REAL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_task_execution_stat_date
|
||
ON task_execution(stat_date, worker_id, task_started_at);
|
||
"""
|
||
)
|
||
columns = {
|
||
row["name"]
|
||
for row in connection.execute("PRAGMA table_info(task_execution)").fetchall()
|
||
}
|
||
if "failure_bucket" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN failure_bucket TEXT")
|
||
if "failure_domain" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN failure_domain TEXT")
|
||
if "failure_subtype" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN failure_subtype TEXT")
|
||
if "trace_json" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN trace_json TEXT")
|
||
if "result_detail" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN result_detail TEXT")
|
||
if "droidbot_steps" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN droidbot_steps INTEGER")
|
||
if "guiagent_steps" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN guiagent_steps INTEGER")
|
||
if "total_steps" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN total_steps INTEGER")
|
||
if "num_nodes" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN num_nodes INTEGER")
|
||
if "num_reached_activities" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN num_reached_activities INTEGER")
|
||
if "app_num_total_activities" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN app_num_total_activities INTEGER")
|
||
if "last_updated" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN last_updated TEXT")
|
||
if "collection_task_type" not in columns:
|
||
connection.execute("ALTER TABLE task_execution ADD COLUMN collection_task_type TEXT")
|
||
|
||
# ── APK 注册表(由 apk_cloud.registry.ApkRegistry 使用)──
|
||
connection.executescript(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS apk_registry (
|
||
package_name TEXT PRIMARY KEY,
|
||
download_date TEXT NOT NULL,
|
||
download_time REAL NOT NULL,
|
||
local_dir TEXT,
|
||
smb_dir TEXT,
|
||
version_name TEXT,
|
||
source TEXT,
|
||
apk_files_json TEXT,
|
||
updated_at REAL NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS apk_pending_download (
|
||
package_name TEXT PRIMARY KEY,
|
||
created_at REAL NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS app_download_record (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
package_name TEXT NOT NULL,
|
||
country_code TEXT NOT NULL DEFAULT '',
|
||
app_name TEXT DEFAULT '',
|
||
last_updated TEXT NOT NULL DEFAULT '',
|
||
downloads INTEGER DEFAULT 0,
|
||
|
||
worker_play_status TEXT NOT NULL DEFAULT 'pending',
|
||
worker_play_error TEXT,
|
||
worker_play_date TEXT,
|
||
|
||
us_play_status TEXT NOT NULL DEFAULT 'pending',
|
||
us_play_error TEXT,
|
||
us_play_date TEXT,
|
||
|
||
us_aurora_status TEXT NOT NULL DEFAULT 'pending',
|
||
us_aurora_error TEXT,
|
||
us_aurora_date TEXT,
|
||
|
||
overall_status TEXT NOT NULL DEFAULT 'pending',
|
||
overall_error TEXT,
|
||
apk_version_name TEXT DEFAULT '',
|
||
|
||
local_apk_dir TEXT DEFAULT '',
|
||
local_apk_file_count INTEGER DEFAULT 0,
|
||
local_apk_is_stale INTEGER DEFAULT 1,
|
||
|
||
created_at REAL NOT NULL,
|
||
updated_at REAL NOT NULL,
|
||
|
||
UNIQUE(package_name, country_code)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_download_record_pkg
|
||
ON app_download_record(package_name);
|
||
CREATE INDEX IF NOT EXISTS idx_download_record_country
|
||
ON app_download_record(country_code);
|
||
CREATE INDEX IF NOT EXISTS idx_download_record_overall
|
||
ON app_download_record(overall_status);
|
||
CREATE INDEX IF NOT EXISTS idx_download_record_stale
|
||
ON app_download_record(local_apk_is_stale);
|
||
"""
|
||
)
|
||
|
||
def clear_monitoring_tables(self):
|
||
table_names = [
|
||
"controller_session",
|
||
"worker_state_event",
|
||
"task_stage_event",
|
||
"task_execution",
|
||
]
|
||
with self._write_lock, self._connect() as connection:
|
||
for table_name in table_names:
|
||
if self._table_exists(connection, table_name):
|
||
connection.execute(f"DELETE FROM {table_name}")
|
||
if self._table_exists(connection, "sqlite_sequence"):
|
||
for table_name in table_names:
|
||
connection.execute("DELETE FROM sqlite_sequence WHERE name = ?", (table_name,))
|
||
|
||
def create_controller_session(self, host: str, version: str, started_at: float) -> int:
|
||
with self._write_lock, self._connect() as connection:
|
||
cursor = connection.execute(
|
||
"""
|
||
INSERT INTO controller_session (
|
||
started_at, host, version, last_heartbeat_at
|
||
) VALUES (?, ?, ?, ?)
|
||
""",
|
||
(started_at, host, version, started_at),
|
||
)
|
||
return int(cursor.lastrowid)
|
||
|
||
def heartbeat_controller_session(self, session_id: int, heartbeat_at: float):
|
||
with self._write_lock, self._connect() as connection:
|
||
connection.execute(
|
||
"""
|
||
UPDATE controller_session
|
||
SET last_heartbeat_at = ?
|
||
WHERE id = ?
|
||
""",
|
||
(heartbeat_at, session_id),
|
||
)
|
||
|
||
def close_controller_session(self, session_id: int, ended_at: float, stop_reason: str):
|
||
with self._write_lock, self._connect() as connection:
|
||
connection.execute(
|
||
"""
|
||
UPDATE controller_session
|
||
SET ended_at = ?, stop_reason = ?, last_heartbeat_at = COALESCE(last_heartbeat_at, ?)
|
||
WHERE id = ?
|
||
""",
|
||
(ended_at, stop_reason, ended_at, session_id),
|
||
)
|
||
|
||
def repair_open_sessions(self, repaired_at: float):
|
||
with self._write_lock, self._connect() as connection:
|
||
rows = connection.execute(
|
||
"""
|
||
SELECT id, last_heartbeat_at
|
||
FROM controller_session
|
||
WHERE ended_at IS NULL
|
||
"""
|
||
).fetchall()
|
||
for row in rows:
|
||
ended_at = float(row["last_heartbeat_at"] or repaired_at)
|
||
connection.execute(
|
||
"""
|
||
UPDATE controller_session
|
||
SET ended_at = ?, stop_reason = COALESCE(stop_reason, 'recovered_after_restart')
|
||
WHERE id = ?
|
||
""",
|
||
(ended_at, row["id"]),
|
||
)
|
||
|
||
def insert_worker_state_event(
|
||
self,
|
||
worker_id: str,
|
||
session_id: Optional[int],
|
||
event_time: float,
|
||
state: str,
|
||
reason: Optional[str],
|
||
task_key: Optional[str],
|
||
):
|
||
with self._write_lock, self._connect() as connection:
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO worker_state_event (
|
||
worker_id, session_id, event_time, state, reason, task_key
|
||
) VALUES (?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(worker_id, session_id, event_time, state, reason, task_key),
|
||
)
|
||
|
||
def insert_task_stage_event(
|
||
self,
|
||
execution_id: str,
|
||
task_key: str,
|
||
worker_id: str,
|
||
session_id: Optional[int],
|
||
event_time: float,
|
||
event_type: str,
|
||
stage: Optional[str],
|
||
status: Optional[str],
|
||
reason: Optional[str],
|
||
metrics: Optional[Dict[str, Any]],
|
||
):
|
||
with self._write_lock, self._connect() as connection:
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO task_stage_event (
|
||
execution_id, task_key, worker_id, session_id, event_time,
|
||
event_type, stage, status, reason, metrics_json
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
execution_id,
|
||
task_key,
|
||
worker_id,
|
||
session_id,
|
||
event_time,
|
||
event_type,
|
||
stage,
|
||
status,
|
||
reason,
|
||
_safe_json_dumps(metrics or {}),
|
||
),
|
||
)
|
||
|
||
def upsert_task_execution(
|
||
self,
|
||
execution_id: str,
|
||
task_key: str,
|
||
worker_id: str,
|
||
session_id: Optional[int],
|
||
stat_date: str,
|
||
task_started_at: float,
|
||
app_name: Optional[str] = None,
|
||
package_name: Optional[str] = None,
|
||
last_updated: Optional[str] = None,
|
||
collection_task_type: Optional[str] = None,
|
||
retry_count: int = 0,
|
||
):
|
||
with self._write_lock, self._connect() as connection:
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO task_execution (
|
||
execution_id, task_key, app_name, package_name, last_updated, collection_task_type, worker_id, session_id,
|
||
stat_date, task_started_at, retry_count, last_updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(execution_id) DO UPDATE SET
|
||
app_name = COALESCE(excluded.app_name, task_execution.app_name),
|
||
package_name = COALESCE(excluded.package_name, task_execution.package_name),
|
||
last_updated = COALESCE(excluded.last_updated, task_execution.last_updated),
|
||
collection_task_type = COALESCE(excluded.collection_task_type, task_execution.collection_task_type),
|
||
retry_count = MAX(task_execution.retry_count, excluded.retry_count),
|
||
worker_id = excluded.worker_id,
|
||
session_id = excluded.session_id,
|
||
last_updated_at = excluded.last_updated_at
|
||
""",
|
||
(
|
||
execution_id,
|
||
task_key,
|
||
app_name,
|
||
package_name,
|
||
last_updated,
|
||
collection_task_type,
|
||
worker_id,
|
||
session_id,
|
||
stat_date,
|
||
task_started_at,
|
||
retry_count,
|
||
time.time(),
|
||
),
|
||
)
|
||
|
||
def update_task_execution_stage(
|
||
self,
|
||
execution_id: str,
|
||
stage: str,
|
||
duration_seconds: Optional[float],
|
||
download_source: Optional[str] = None,
|
||
):
|
||
if duration_seconds is None:
|
||
return
|
||
with self._write_lock, self._connect() as connection:
|
||
if stage == "download":
|
||
connection.execute(
|
||
"""
|
||
UPDATE task_execution
|
||
SET download_duration_seconds = ?,
|
||
download_source = COALESCE(?, download_source),
|
||
last_updated_at = ?
|
||
WHERE execution_id = ?
|
||
""",
|
||
(duration_seconds, download_source, time.time(), execution_id),
|
||
)
|
||
elif stage == "collect":
|
||
connection.execute(
|
||
"""
|
||
UPDATE task_execution
|
||
SET collect_duration_seconds = ?,
|
||
last_updated_at = ?
|
||
WHERE execution_id = ?
|
||
""",
|
||
(duration_seconds, time.time(), execution_id),
|
||
)
|
||
|
||
def finish_task_execution(
|
||
self,
|
||
execution_id: str,
|
||
task_ended_at: float,
|
||
status: str,
|
||
failed_stage: Optional[str],
|
||
failure_bucket: Optional[str],
|
||
total_duration_seconds: Optional[float],
|
||
failed_total_duration_seconds: Optional[float],
|
||
error_type: Optional[str],
|
||
error_message: Optional[str],
|
||
result_detail: Optional[str],
|
||
droidbot_steps: Optional[int],
|
||
guiagent_steps: Optional[int],
|
||
total_steps: Optional[int],
|
||
num_nodes: Optional[int],
|
||
num_reached_activities: Optional[int],
|
||
app_num_total_activities: Optional[int],
|
||
failure_domain: Optional[str],
|
||
failure_subtype: Optional[str],
|
||
trace_payload: Optional[Dict[str, Any]],
|
||
retry_count: Optional[int] = None,
|
||
):
|
||
with self._write_lock, self._connect() as connection:
|
||
connection.execute(
|
||
"""
|
||
UPDATE task_execution
|
||
SET task_ended_at = ?,
|
||
status = ?,
|
||
failed_stage = ?,
|
||
failure_bucket = COALESCE(?, failure_bucket),
|
||
total_duration_seconds = COALESCE(?, total_duration_seconds),
|
||
failed_total_duration_seconds = COALESCE(?, failed_total_duration_seconds),
|
||
error_type = COALESCE(?, error_type),
|
||
error_message = COALESCE(?, error_message),
|
||
result_detail = COALESCE(?, result_detail),
|
||
droidbot_steps = COALESCE(?, droidbot_steps),
|
||
guiagent_steps = COALESCE(?, guiagent_steps),
|
||
total_steps = COALESCE(?, total_steps),
|
||
num_nodes = COALESCE(?, num_nodes),
|
||
num_reached_activities = COALESCE(?, num_reached_activities),
|
||
app_num_total_activities = COALESCE(?, app_num_total_activities),
|
||
failure_domain = COALESCE(?, failure_domain),
|
||
failure_subtype = COALESCE(?, failure_subtype),
|
||
trace_json = COALESCE(?, trace_json),
|
||
retry_count = COALESCE(?, retry_count),
|
||
last_updated_at = ?
|
||
WHERE execution_id = ?
|
||
""",
|
||
(
|
||
task_ended_at,
|
||
status,
|
||
failed_stage,
|
||
failure_bucket,
|
||
total_duration_seconds,
|
||
failed_total_duration_seconds,
|
||
error_type,
|
||
error_message,
|
||
result_detail,
|
||
droidbot_steps,
|
||
guiagent_steps,
|
||
total_steps,
|
||
num_nodes,
|
||
num_reached_activities,
|
||
app_num_total_activities,
|
||
failure_domain,
|
||
failure_subtype,
|
||
_safe_json_dumps(trace_payload) if trace_payload else None,
|
||
retry_count,
|
||
time.time(),
|
||
execution_id,
|
||
),
|
||
)
|
||
|
||
def list_task_executions(self, stat_date: str, worker_id: Optional[str] = None, limit: int = 200) -> List[sqlite3.Row]:
|
||
with self._connect() as connection:
|
||
if worker_id:
|
||
return connection.execute(
|
||
"""
|
||
SELECT *
|
||
FROM task_execution
|
||
WHERE stat_date = ? AND worker_id = ?
|
||
ORDER BY COALESCE(task_started_at, last_updated_at, 0) DESC
|
||
LIMIT ?
|
||
""",
|
||
(stat_date, worker_id, limit),
|
||
).fetchall()
|
||
return connection.execute(
|
||
"""
|
||
SELECT *
|
||
FROM task_execution
|
||
WHERE stat_date = ?
|
||
ORDER BY COALESCE(task_started_at, last_updated_at, 0) DESC
|
||
LIMIT ?
|
||
""",
|
||
(stat_date, limit),
|
||
).fetchall()
|
||
|
||
def list_app_collect_statuses(self, package_names: Iterable[str]) -> Dict[str, Dict[str, str]]:
|
||
"""
|
||
查询应用的采集状态分类(新架构)。
|
||
|
||
直接从 collection_task 查询最新执行记录,基于 execution_status,
|
||
error_category, error_code, num_nodes 和流量比例动态计算状态。
|
||
"""
|
||
normalized = [str(item or "").strip() for item in package_names if str(item or "").strip()]
|
||
if not normalized:
|
||
return {}
|
||
|
||
with self._connect() as connection:
|
||
rows = []
|
||
for package_name in normalized:
|
||
row = connection.execute(
|
||
"""
|
||
SELECT
|
||
package_name,
|
||
execution_status,
|
||
error_category,
|
||
error_code,
|
||
num_nodes,
|
||
CASE WHEN total_traffic_bytes > 0
|
||
THEN self_traffic_bytes * 100.0 / total_traffic_bytes
|
||
ELSE 0 END AS self_ratio
|
||
FROM collection_task
|
||
WHERE package_name = ?
|
||
ORDER BY
|
||
datetime(COALESCE(NULLIF(completed_at, ''), NULLIF(started_at, ''), created_at)) DESC,
|
||
attempt DESC,
|
||
batch_tag DESC,
|
||
run_kind DESC
|
||
LIMIT 1
|
||
""",
|
||
(package_name,),
|
||
).fetchone()
|
||
if row:
|
||
rows.append(row)
|
||
|
||
result = {}
|
||
# 不可重试的 DOWNLOAD_ERROR 错误码
|
||
NON_RETRYABLE_DOWNLOAD_CODES = {1, 5, 6, 10, 403, 404}
|
||
|
||
for row in rows:
|
||
pkg = row["package_name"]
|
||
if not pkg:
|
||
continue
|
||
|
||
exec_status = (row["execution_status"] or "").strip()
|
||
error_cat = (row["error_category"] or "").strip()
|
||
error_code = row["error_code"]
|
||
nodes = row["num_nodes"] or 0
|
||
self_ratio = row["self_ratio"] or 0
|
||
|
||
# 动态计算 restriction_status
|
||
if exec_status == "success":
|
||
# 成功任务:根据质量指标判断
|
||
if nodes >= 5 and self_ratio >= 30:
|
||
restriction_status = "success"
|
||
else:
|
||
restriction_status = "light_restricted"
|
||
else:
|
||
# 失败任务:严重受限
|
||
restriction_status = "severe_restricted"
|
||
|
||
# 动态计算 retryability
|
||
if exec_status == "success":
|
||
retryability = "retryable"
|
||
elif error_cat == "DOWNLOAD_ERROR" and error_code in NON_RETRYABLE_DOWNLOAD_CODES:
|
||
retryability = "non_retryable"
|
||
else:
|
||
retryability = "retryable"
|
||
|
||
# 动态计算 collection_status(业务层分类)
|
||
if restriction_status == "success":
|
||
collection_status = "qualified"
|
||
else:
|
||
collection_status = "restricted"
|
||
|
||
result[pkg] = {
|
||
"collection_status": collection_status,
|
||
"restriction_status": restriction_status,
|
||
"retryability": retryability,
|
||
}
|
||
|
||
return result
|
||
|
||
def get_task_execution_totals(self, worker_id: Optional[str] = None) -> Dict[str, int]:
|
||
with self._connect() as connection:
|
||
if worker_id:
|
||
row = connection.execute(
|
||
"""
|
||
SELECT
|
||
COUNT(*) AS task_total,
|
||
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS task_success,
|
||
COALESCE(SUM(CASE WHEN status != 'success' THEN 1 ELSE 0 END), 0) AS task_non_success,
|
||
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS task_failed
|
||
FROM task_execution
|
||
WHERE worker_id = ?
|
||
""",
|
||
(worker_id,),
|
||
).fetchone()
|
||
else:
|
||
row = connection.execute(
|
||
"""
|
||
SELECT
|
||
COUNT(*) AS task_total,
|
||
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS task_success,
|
||
COALESCE(SUM(CASE WHEN status != 'success' THEN 1 ELSE 0 END), 0) AS task_non_success,
|
||
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS task_failed
|
||
FROM task_execution
|
||
"""
|
||
).fetchone()
|
||
return {
|
||
"task_total": int(row["task_total"] or 0),
|
||
"task_success": int(row["task_success"] or 0),
|
||
"task_non_success": int(row["task_non_success"] or 0),
|
||
"task_failed": int(row["task_failed"] or 0),
|
||
}
|
||
|
||
def get_task_overview_metrics(self, stat_date: str, worker_id: Optional[str] = None) -> Dict[str, Any]:
|
||
with self._connect() as connection:
|
||
where_clause = "WHERE stat_date = ?"
|
||
params: List[Any] = [stat_date]
|
||
if worker_id:
|
||
where_clause += " AND worker_id = ?"
|
||
params.append(worker_id)
|
||
|
||
summary_row = connection.execute(
|
||
f"""
|
||
SELECT
|
||
COUNT(*) AS task_total,
|
||
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS task_success,
|
||
COALESCE(SUM(CASE WHEN status != 'success' THEN 1 ELSE 0 END), 0) AS task_non_success,
|
||
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS task_failed,
|
||
COALESCE(AVG(download_duration_seconds), 0) AS avg_download_duration_seconds,
|
||
COALESCE(AVG(collect_duration_seconds), 0) AS avg_collect_duration_seconds,
|
||
COALESCE(SUM(CASE WHEN download_duration_seconds IS NOT NULL THEN 1 ELSE 0 END), 0) AS download_value_count,
|
||
COALESCE(SUM(CASE WHEN collect_duration_seconds IS NOT NULL THEN 1 ELSE 0 END), 0) AS collect_value_count
|
||
FROM task_execution
|
||
{where_clause}
|
||
""",
|
||
params,
|
||
).fetchone()
|
||
|
||
def percentile_for(column: str, count: int, ratio: float) -> float:
|
||
if count <= 0:
|
||
return 0.0
|
||
offset = int(round((count - 1) * ratio))
|
||
row = connection.execute(
|
||
f"""
|
||
SELECT {column} AS value
|
||
FROM task_execution
|
||
{where_clause} AND {column} IS NOT NULL
|
||
ORDER BY {column}
|
||
LIMIT 1 OFFSET ?
|
||
""",
|
||
[*params, offset],
|
||
).fetchone()
|
||
if not row or row["value"] is None:
|
||
return 0.0
|
||
return round(float(row["value"]), 2)
|
||
|
||
download_count = int(summary_row["download_value_count"] or 0)
|
||
collect_count = int(summary_row["collect_value_count"] or 0)
|
||
return {
|
||
"task_total": int(summary_row["task_total"] or 0),
|
||
"task_success": int(summary_row["task_success"] or 0),
|
||
"task_non_success": int(summary_row["task_non_success"] or 0),
|
||
"task_failed": int(summary_row["task_failed"] or 0),
|
||
"avg_download_duration_seconds": round(float(summary_row["avg_download_duration_seconds"] or 0.0), 2),
|
||
"avg_collect_duration_seconds": round(float(summary_row["avg_collect_duration_seconds"] or 0.0), 2),
|
||
"p50_download_duration_seconds": percentile_for("download_duration_seconds", download_count, 0.5),
|
||
"p90_download_duration_seconds": percentile_for("download_duration_seconds", download_count, 0.9),
|
||
"p50_collect_duration_seconds": percentile_for("collect_duration_seconds", collect_count, 0.5),
|
||
"p90_collect_duration_seconds": percentile_for("collect_duration_seconds", collect_count, 0.9),
|
||
}
|
||
|
||
def list_state_events_before(self, day_end: float, worker_ids: Optional[Iterable[str]] = None) -> List[sqlite3.Row]:
|
||
with self._connect() as connection:
|
||
if worker_ids:
|
||
worker_ids = list(worker_ids)
|
||
placeholders = ",".join("?" for _ in worker_ids)
|
||
query = f"""
|
||
SELECT worker_id, event_time, state, reason, task_key
|
||
FROM worker_state_event
|
||
WHERE event_time < ? AND worker_id IN ({placeholders})
|
||
ORDER BY worker_id, event_time
|
||
"""
|
||
return connection.execute(query, [day_end, *worker_ids]).fetchall()
|
||
return connection.execute(
|
||
"""
|
||
SELECT worker_id, event_time, state, reason, task_key
|
||
FROM worker_state_event
|
||
WHERE event_time < ?
|
||
ORDER BY worker_id, event_time
|
||
""",
|
||
(day_end,),
|
||
).fetchall()
|
||
|
||
def list_worker_ids_up_to(self, day_end: float) -> List[str]:
|
||
with self._connect() as connection:
|
||
rows = connection.execute(
|
||
"""
|
||
SELECT DISTINCT worker_id
|
||
FROM worker_state_event
|
||
WHERE event_time < ?
|
||
""",
|
||
(day_end,),
|
||
).fetchall()
|
||
return [row["worker_id"] for row in rows]
|
||
|
||
def list_sessions_overlapping(self, day_start: float, day_end: float) -> List[sqlite3.Row]:
|
||
with self._connect() as connection:
|
||
return connection.execute(
|
||
"""
|
||
SELECT *
|
||
FROM controller_session
|
||
WHERE started_at < ? AND COALESCE(ended_at, last_heartbeat_at, ?) > ?
|
||
ORDER BY started_at
|
||
""",
|
||
(day_end, day_end, day_start),
|
||
).fetchall()
|
||
|
||
def update_failure_bucket(self, execution_id: str, failure_bucket: str):
|
||
with self._write_lock, self._connect() as connection:
|
||
connection.execute(
|
||
"""
|
||
UPDATE task_execution
|
||
SET failure_bucket = ?,
|
||
last_updated_at = ?
|
||
WHERE execution_id = ?
|
||
""",
|
||
(failure_bucket, time.time(), execution_id),
|
||
)
|
||
|
||
def update_failure_bucket_by_task_key(self, task_key: str, failure_bucket: str):
|
||
with self._write_lock, self._connect() as connection:
|
||
connection.execute(
|
||
"""
|
||
UPDATE task_execution
|
||
SET failure_bucket = ?,
|
||
last_updated_at = ?
|
||
WHERE execution_id = (
|
||
SELECT execution_id
|
||
FROM task_execution
|
||
WHERE task_key = ?
|
||
ORDER BY COALESCE(task_ended_at, last_updated_at, task_started_at) DESC
|
||
LIMIT 1
|
||
)
|
||
""",
|
||
(failure_bucket, time.time(), task_key),
|
||
)
|
||
|
||
def get_latest_execution_id(self, task_key: str) -> Optional[str]:
|
||
with self._connect() as connection:
|
||
row = connection.execute(
|
||
"""
|
||
SELECT execution_id
|
||
FROM task_execution
|
||
WHERE task_key = ?
|
||
ORDER BY COALESCE(task_ended_at, last_updated_at, task_started_at) DESC
|
||
LIMIT 1
|
||
""",
|
||
(task_key,),
|
||
).fetchone()
|
||
return row["execution_id"] if row else None
|
||
|
||
def list_stage_finish_events(self, stat_date: str) -> List[sqlite3.Row]:
|
||
with self._connect() as connection:
|
||
return connection.execute(
|
||
"""
|
||
SELECT
|
||
te.execution_id,
|
||
te.status,
|
||
te.failure_bucket,
|
||
te.total_duration_seconds,
|
||
te.failed_total_duration_seconds,
|
||
te.error_type,
|
||
te.error_message,
|
||
e.stage,
|
||
e.metrics_json
|
||
FROM task_stage_event e
|
||
JOIN task_execution te ON te.execution_id = e.execution_id
|
||
WHERE te.stat_date = ?
|
||
AND e.event_type = 'stage_finished'
|
||
ORDER BY e.event_time
|
||
""",
|
||
(stat_date,),
|
||
).fetchall()
|
||
|
||
|
||
class MonitoringService:
|
||
def __init__(self, redis_client, managed_worker_ids: Optional[List[str]] = None):
|
||
self.redis = redis_client
|
||
self.repo = MonitoringRepository()
|
||
self.managed_worker_ids = list(managed_worker_ids or MANAGED_WORKER_IDS or [])
|
||
self._lock = threading.RLock()
|
||
self._session_id: Optional[int] = None
|
||
self._version = "unknown"
|
||
self.repo.repair_open_sessions(time.time())
|
||
|
||
def start_controller_session(self, version: str = "unknown") -> int:
|
||
with self._lock:
|
||
if self._session_id is not None:
|
||
return self._session_id
|
||
started_at = time.time()
|
||
self._version = version
|
||
self._session_id = self.repo.create_controller_session(
|
||
host=socket.gethostname(),
|
||
version=version,
|
||
started_at=started_at,
|
||
)
|
||
self.redis.set("controller:current_session", str(self._session_id))
|
||
return self._session_id
|
||
|
||
def heartbeat_controller_session(self):
|
||
with self._lock:
|
||
session_id = self.current_session_id
|
||
if session_id is None:
|
||
return
|
||
self.repo.heartbeat_controller_session(session_id, time.time())
|
||
|
||
def close_controller_session(self, stop_reason: str = "stopped"):
|
||
with self._lock:
|
||
session_id = self._session_id
|
||
if session_id is None:
|
||
return
|
||
self.repo.close_controller_session(session_id, time.time(), stop_reason)
|
||
self.redis.delete("controller:current_session")
|
||
self._session_id = None
|
||
|
||
def _classify_rows_by_collection_quality(
|
||
self,
|
||
rows: List[sqlite3.Row],
|
||
) -> Tuple[List[sqlite3.Row], List[sqlite3.Row], Dict[str, int]]:
|
||
package_map = self.repo.list_app_collect_statuses(
|
||
row["package_name"] for row in rows if row["package_name"]
|
||
)
|
||
qualified_items: List[sqlite3.Row] = []
|
||
severe_items: List[sqlite3.Row] = []
|
||
counters = {
|
||
"qualified_task_count": 0,
|
||
"qualified_success_task_count": 0,
|
||
"qualified_light_task_count": 0,
|
||
"non_success_task_count": 0,
|
||
"non_retryable_task_count": 0,
|
||
}
|
||
for row in rows:
|
||
package_name = str(row["package_name"] or "").strip()
|
||
row_state = package_map.get(package_name, {})
|
||
restriction_status = row_state.get("restriction_status", "")
|
||
retryability = row_state.get("retryability", "")
|
||
if restriction_status == "severe_restricted":
|
||
severe_items.append(row)
|
||
counters["non_success_task_count"] += 1
|
||
if retryability == "non_retryable":
|
||
counters["non_retryable_task_count"] += 1
|
||
else:
|
||
qualified_items.append(row)
|
||
counters["qualified_task_count"] += 1
|
||
if restriction_status == "light_restricted":
|
||
counters["qualified_light_task_count"] += 1
|
||
else:
|
||
counters["qualified_success_task_count"] += 1
|
||
return qualified_items, severe_items, counters
|
||
|
||
def _split_rows_by_quality(self, rows: List[sqlite3.Row]) -> Tuple[List[sqlite3.Row], List[sqlite3.Row]]:
|
||
qualified_items, severe_items, _ = self._classify_rows_by_collection_quality(rows)
|
||
return qualified_items, severe_items
|
||
|
||
@property
|
||
def current_session_id(self) -> Optional[int]:
|
||
if self._session_id is not None:
|
||
return self._session_id
|
||
raw = self.redis.get("controller:current_session")
|
||
if raw:
|
||
try:
|
||
self._session_id = int(raw)
|
||
except (TypeError, ValueError):
|
||
self._session_id = None
|
||
return self._session_id
|
||
|
||
def _execution_key(self, task_key: str) -> str:
|
||
return f"monitor:execution:{task_key}"
|
||
|
||
def _current_state_key(self, worker_id: str) -> str:
|
||
return f"worker:current_state:{worker_id}"
|
||
|
||
def _get_current_state(self, worker_id: str) -> Dict[str, Any]:
|
||
return _safe_json_loads(self.redis.get(self._current_state_key(worker_id)))
|
||
|
||
def _set_current_state(self, worker_id: str, payload: Dict[str, Any]):
|
||
self.redis.set(self._current_state_key(worker_id), _safe_json_dumps(payload))
|
||
|
||
def _delete_monitoring_redis_state(self):
|
||
self.redis.delete("controller:current_session")
|
||
patterns = [
|
||
"worker:current_state:*",
|
||
"worker:last_event:*",
|
||
"monitor:execution:*",
|
||
]
|
||
keys_to_delete: List[str] = []
|
||
for pattern in patterns:
|
||
keys_to_delete.extend(list(self.redis.scan_iter(match=pattern)))
|
||
if keys_to_delete:
|
||
self.redis.delete(*keys_to_delete)
|
||
|
||
def _normalize_reseed_state(self, worker: Dict[str, Any]) -> Optional[str]:
|
||
if not worker.get("online", True):
|
||
return None
|
||
state = str(worker.get("state") or "").strip()
|
||
if state in {"idle_waiting_task", "running_download", "running_collect", "disabled"}:
|
||
return state
|
||
if worker.get("status") == "busy":
|
||
return "running_collect"
|
||
if worker.get("status") == "idle":
|
||
return "idle_waiting_task"
|
||
return "idle_waiting_task"
|
||
|
||
def reset_monitoring_data(self, worker_snapshots: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||
with self._lock:
|
||
reset_time = time.time()
|
||
self.close_controller_session(stop_reason="dashboard_reset")
|
||
self.repo.clear_monitoring_tables()
|
||
self._delete_monitoring_redis_state()
|
||
self._session_id = None
|
||
session_id = self.start_controller_session(version=self._version)
|
||
|
||
reseeded_workers = 0
|
||
for worker in worker_snapshots or []:
|
||
worker_id = worker.get("worker_id")
|
||
if not worker_id:
|
||
continue
|
||
state = self._normalize_reseed_state(worker)
|
||
if not state:
|
||
continue
|
||
self.set_worker_state(
|
||
worker_id,
|
||
state,
|
||
reason="dashboard_reset_reseed",
|
||
task_key=worker.get("current_task"),
|
||
event_time=reset_time,
|
||
)
|
||
current_task = worker.get("current_task")
|
||
if current_task:
|
||
task_metadata = self._resolve_task_metadata(current_task)
|
||
retry_count = 0
|
||
status_json = self.redis.hget("task:status", current_task)
|
||
if status_json:
|
||
status_payload = _safe_json_loads(status_json)
|
||
retry_count = int(status_payload.get("retry_count") or 0)
|
||
execution_id = f"{current_task}:{int(reset_time * 1000)}"
|
||
self.repo.upsert_task_execution(
|
||
execution_id=execution_id,
|
||
task_key=current_task,
|
||
worker_id=worker_id,
|
||
session_id=self.current_session_id,
|
||
stat_date=_date_str_from_ts(reset_time),
|
||
task_started_at=reset_time,
|
||
app_name=task_metadata.get("app_name"),
|
||
package_name=task_metadata.get("package_name"),
|
||
last_updated=task_metadata.get("last_updated"),
|
||
collection_task_type=task_metadata.get("collection_task_type"),
|
||
retry_count=retry_count,
|
||
)
|
||
self.redis.set(self._execution_key(current_task), execution_id)
|
||
reseeded_workers += 1
|
||
|
||
return {
|
||
"ok": True,
|
||
"reset_at": datetime.fromtimestamp(reset_time, SHANGHAI_TZ).strftime("%Y-%m-%d %H:%M:%S"),
|
||
"session_id": session_id,
|
||
"reseeded_workers": reseeded_workers,
|
||
}
|
||
|
||
def _update_worker_info_state(
|
||
self,
|
||
worker_id: str,
|
||
state: str,
|
||
task_key: Optional[str],
|
||
reason: Optional[str],
|
||
event_time: float,
|
||
):
|
||
info_json = self.redis.hget("workers:info", worker_id)
|
||
if not info_json:
|
||
return
|
||
info = _safe_json_loads(info_json)
|
||
info["state"] = state
|
||
info["state_since"] = event_time
|
||
info["pause_reason"] = reason
|
||
if task_key is not None:
|
||
info["current_task"] = task_key
|
||
self.redis.hset("workers:info", worker_id, _safe_json_dumps(info))
|
||
|
||
def set_worker_state(
|
||
self,
|
||
worker_id: str,
|
||
state: str,
|
||
reason: Optional[str] = None,
|
||
task_key: Optional[str] = None,
|
||
event_time: Optional[float] = None,
|
||
):
|
||
with self._lock:
|
||
event_time = event_time or time.time()
|
||
current = self._get_current_state(worker_id)
|
||
if (
|
||
current.get("state") == state
|
||
and current.get("reason") == reason
|
||
and current.get("task_key") == task_key
|
||
):
|
||
return
|
||
payload = {"state": state, "reason": reason, "task_key": task_key, "since": event_time}
|
||
self._set_current_state(worker_id, payload)
|
||
self._update_worker_info_state(worker_id, state, task_key, reason, event_time)
|
||
self.repo.insert_worker_state_event(
|
||
worker_id=worker_id,
|
||
session_id=self.current_session_id,
|
||
event_time=event_time,
|
||
state=state,
|
||
reason=reason,
|
||
task_key=task_key,
|
||
)
|
||
|
||
def register_worker(self, worker_id: str, task: Optional[Dict[str, Any]] = None, event_time: Optional[float] = None):
|
||
with self._lock:
|
||
event_time = event_time or time.time()
|
||
if task and task.get("task_key"):
|
||
self._update_worker_info_state(worker_id, "idle_waiting_task", task.get("task_key"), None, event_time)
|
||
else:
|
||
self.set_worker_state(worker_id, "idle_waiting_task", reason="waiting_task", event_time=event_time)
|
||
|
||
def handle_task_assigned(self, worker_id: str, task_key: str, event_time: Optional[float] = None):
|
||
with self._lock:
|
||
event_time = event_time or time.time()
|
||
info_json = self.redis.hget("workers:info", worker_id)
|
||
if not info_json:
|
||
return
|
||
info = _safe_json_loads(info_json)
|
||
info["current_task"] = task_key
|
||
info["last_update"] = event_time
|
||
self.redis.hset("workers:info", worker_id, _safe_json_dumps(info))
|
||
# 任务分发后将 worker state 标记为 running(等 stage_started 事件进一步细化为 download/collect)
|
||
self.set_worker_state(worker_id, "running_download", task_key=task_key, event_time=event_time)
|
||
if not self.redis.get(self._execution_key(task_key)):
|
||
status_payload = _safe_json_loads(self.redis.hget("task:status", task_key))
|
||
retry_count = int(status_payload.get("retry_count") or 0)
|
||
task_metadata = self._resolve_task_metadata(task_key)
|
||
execution_id = f"{task_key}:{int(event_time * 1000)}"
|
||
self.repo.upsert_task_execution(
|
||
execution_id=execution_id,
|
||
task_key=task_key,
|
||
worker_id=worker_id,
|
||
session_id=self.current_session_id,
|
||
stat_date=_date_str_from_ts(event_time),
|
||
task_started_at=event_time,
|
||
app_name=task_metadata.get("app_name"),
|
||
package_name=task_metadata.get("package_name"),
|
||
last_updated=task_metadata.get("last_updated"),
|
||
collection_task_type=task_metadata.get("collection_task_type"),
|
||
retry_count=retry_count,
|
||
)
|
||
self.redis.set(self._execution_key(task_key), execution_id)
|
||
|
||
def _resolve_task_details(self, task_key: str) -> Tuple[Optional[str], Optional[str]]:
|
||
details = self._resolve_task_metadata(task_key)
|
||
return details.get("app_name"), details.get("package_name")
|
||
|
||
def _resolve_task_metadata(self, task_key: str) -> Dict[str, Any]:
|
||
raw = self.redis.hget("task:details", task_key)
|
||
if not raw:
|
||
return {}
|
||
details = _safe_json_loads(raw)
|
||
return details if isinstance(details, dict) else {}
|
||
|
||
def _ensure_execution_id(
|
||
self,
|
||
worker_id: str,
|
||
task_key: str,
|
||
event_time: float,
|
||
retry_count: int = 0,
|
||
) -> str:
|
||
with self._lock:
|
||
execution_id = self.redis.get(self._execution_key(task_key))
|
||
if execution_id:
|
||
return execution_id
|
||
execution_id = f"{task_key}:{int(event_time * 1000)}"
|
||
task_metadata = self._resolve_task_metadata(task_key)
|
||
self.repo.upsert_task_execution(
|
||
execution_id=execution_id,
|
||
task_key=task_key,
|
||
worker_id=worker_id,
|
||
session_id=self.current_session_id,
|
||
stat_date=_date_str_from_ts(event_time),
|
||
task_started_at=event_time,
|
||
app_name=task_metadata.get("app_name"),
|
||
package_name=task_metadata.get("package_name"),
|
||
last_updated=task_metadata.get("last_updated"),
|
||
collection_task_type=task_metadata.get("collection_task_type"),
|
||
retry_count=retry_count,
|
||
)
|
||
self.redis.set(self._execution_key(task_key), execution_id)
|
||
return execution_id
|
||
|
||
def _resolve_report_execution_id(
|
||
self,
|
||
worker_id: str,
|
||
task_key: str,
|
||
event_time: float,
|
||
retry_count: int = 0,
|
||
) -> str:
|
||
execution_id = self.redis.get(self._execution_key(task_key))
|
||
if execution_id:
|
||
return execution_id
|
||
execution_id = self.repo.get_latest_execution_id(task_key)
|
||
if execution_id:
|
||
return execution_id
|
||
return self._ensure_execution_id(worker_id, task_key, event_time, retry_count=retry_count)
|
||
|
||
def _resolve_failure_fields(
|
||
self,
|
||
error_type: Optional[str],
|
||
error_message: Optional[str],
|
||
download_errors: Any = None,
|
||
trace_payload: Optional[Dict[str, Any]] = None,
|
||
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
|
||
trace_payload = dict(trace_payload) if isinstance(trace_payload, dict) else {}
|
||
normalized_download_errors = _normalize_download_errors(download_errors)
|
||
if normalized_download_errors:
|
||
trace_payload["download_errors"] = normalized_download_errors
|
||
failure_domain = trace_payload.get("failure_domain")
|
||
failure_subtype = trace_payload.get("failure_subtype")
|
||
has_failure = bool(error_type or error_message or normalized_download_errors)
|
||
if has_failure and (not failure_domain or not failure_subtype):
|
||
failure_domain, failure_subtype = _classify_failure(
|
||
error_type,
|
||
error_message,
|
||
download_errors=normalized_download_errors,
|
||
)
|
||
elif not has_failure:
|
||
failure_domain = None
|
||
failure_subtype = None
|
||
if not trace_payload:
|
||
trace_payload = None
|
||
elif has_failure and not trace_payload.get("failure_domain"):
|
||
trace_payload["failure_domain"] = failure_domain
|
||
trace_payload["failure_subtype"] = failure_subtype
|
||
return failure_domain, failure_subtype, trace_payload
|
||
|
||
def handle_worker_event(self, payload: Dict[str, Any]):
|
||
with self._lock:
|
||
worker_id = normalize_worker_id(payload.get("worker_id"))
|
||
task_key = payload.get("task_key")
|
||
event_type = payload.get("event_type")
|
||
if not worker_id or not event_type:
|
||
return
|
||
event_time = float(payload.get("event_time") or time.time())
|
||
stage = payload.get("stage")
|
||
state = payload.get("state")
|
||
status = payload.get("status")
|
||
reason = payload.get("reason")
|
||
metrics = payload.get("metrics") or {}
|
||
|
||
if event_type == "worker_state_changed" and state:
|
||
self.set_worker_state(worker_id, state, reason=reason, task_key=task_key, event_time=event_time)
|
||
return
|
||
|
||
if not task_key:
|
||
return
|
||
|
||
retry_count = int(metrics.get("retry_count") or 0)
|
||
execution_id = self._ensure_execution_id(worker_id, task_key, event_time, retry_count=retry_count)
|
||
self.repo.insert_task_stage_event(
|
||
execution_id=execution_id,
|
||
task_key=task_key,
|
||
worker_id=worker_id,
|
||
session_id=self.current_session_id,
|
||
event_time=event_time,
|
||
event_type=event_type,
|
||
stage=stage,
|
||
status=status,
|
||
reason=reason,
|
||
metrics=metrics,
|
||
)
|
||
|
||
if event_type == "task_started":
|
||
task_metadata = self._resolve_task_metadata(task_key)
|
||
self.repo.upsert_task_execution(
|
||
execution_id=execution_id,
|
||
task_key=task_key,
|
||
worker_id=worker_id,
|
||
session_id=self.current_session_id,
|
||
stat_date=_date_str_from_ts(event_time),
|
||
task_started_at=event_time,
|
||
app_name=task_metadata.get("app_name"),
|
||
package_name=task_metadata.get("package_name"),
|
||
last_updated=task_metadata.get("last_updated"),
|
||
collection_task_type=task_metadata.get("collection_task_type"),
|
||
retry_count=retry_count,
|
||
)
|
||
self.handle_task_assigned(worker_id, task_key, event_time)
|
||
return
|
||
|
||
if event_type == "stage_started":
|
||
if stage == "download":
|
||
self.set_worker_state(worker_id, "running_download", task_key=task_key, event_time=event_time)
|
||
elif stage == "collect":
|
||
self.set_worker_state(worker_id, "running_collect", task_key=task_key, event_time=event_time)
|
||
return
|
||
|
||
if event_type == "stage_finished":
|
||
self.repo.update_task_execution_stage(
|
||
execution_id=execution_id,
|
||
stage=stage or "",
|
||
duration_seconds=metrics.get("duration_seconds"),
|
||
download_source=metrics.get("download_source"),
|
||
)
|
||
return
|
||
|
||
if event_type == "task_finished":
|
||
error_type = metrics.get("error_type")
|
||
error_message = metrics.get("error_message") or reason
|
||
total_duration = metrics.get("total_duration_seconds")
|
||
failed_total_duration = metrics.get("failed_total_duration_seconds")
|
||
failed_stage = metrics.get("failed_stage")
|
||
failure_domain, failure_subtype, trace_payload = self._resolve_failure_fields(
|
||
error_type,
|
||
error_message,
|
||
download_errors=metrics.get("download_errors"),
|
||
trace_payload=metrics.get("trace"),
|
||
)
|
||
self.repo.finish_task_execution(
|
||
execution_id=execution_id,
|
||
task_ended_at=event_time,
|
||
status=status or "unknown",
|
||
failed_stage=failed_stage,
|
||
failure_bucket="failed" if (status or "unknown") == "failed" else status,
|
||
total_duration_seconds=total_duration,
|
||
failed_total_duration_seconds=failed_total_duration,
|
||
error_type=error_type,
|
||
error_message=error_message,
|
||
result_detail=error_message,
|
||
droidbot_steps=None,
|
||
guiagent_steps=None,
|
||
total_steps=None,
|
||
num_nodes=None,
|
||
num_reached_activities=None,
|
||
app_num_total_activities=None,
|
||
failure_domain=failure_domain,
|
||
failure_subtype=failure_subtype,
|
||
trace_payload=trace_payload,
|
||
retry_count=retry_count,
|
||
)
|
||
self.redis.delete(self._execution_key(task_key))
|
||
self.set_worker_state(worker_id, "idle_waiting_task", reason="waiting_task", event_time=event_time)
|
||
|
||
def handle_worker_report(
|
||
self,
|
||
worker_id: str,
|
||
previous_task_key: str,
|
||
report_data: Dict[str, Any],
|
||
current_time: Optional[float] = None,
|
||
retry_count: int = 0,
|
||
resolved_message: Optional[str] = None,
|
||
):
|
||
worker_id = normalize_worker_id(worker_id)
|
||
with self._lock:
|
||
if not previous_task_key:
|
||
return
|
||
current_time = current_time or time.time()
|
||
execution_id = self._resolve_report_execution_id(
|
||
worker_id,
|
||
previous_task_key,
|
||
current_time,
|
||
retry_count=retry_count,
|
||
)
|
||
statistics = report_data.get("statistics") or {}
|
||
error_data = report_data.get("error") or {}
|
||
trace_payload = report_data.get("trace")
|
||
status = report_data.get("status", "unknown")
|
||
current_state = self._get_current_state(worker_id).get("state")
|
||
failed_stage = "download" if not statistics.get("download_source") else "collect"
|
||
if status == "success":
|
||
failed_stage = None
|
||
elif current_state == "running_download":
|
||
failed_stage = "download"
|
||
elif current_state == "running_collect":
|
||
failed_stage = "collect"
|
||
error_type = (
|
||
f"{error_data.get('category')}/{error_data.get('code')}"
|
||
if error_data.get("category") is not None and error_data.get("code") is not None
|
||
else None
|
||
)
|
||
error_message = resolved_message or error_data.get("reason") or statistics.get("error_reason")
|
||
failure_domain, failure_subtype, trace_payload = self._resolve_failure_fields(
|
||
error_type,
|
||
error_message,
|
||
download_errors=report_data.get("download_errors"),
|
||
trace_payload=trace_payload,
|
||
)
|
||
self.repo.finish_task_execution(
|
||
execution_id=execution_id,
|
||
task_ended_at=current_time,
|
||
status=status,
|
||
failed_stage=failed_stage,
|
||
failure_bucket=status if status != "failed" else "failed",
|
||
total_duration_seconds=statistics.get("duration_seconds"),
|
||
failed_total_duration_seconds=statistics.get("duration_seconds") if status != "success" else None,
|
||
error_type=error_type,
|
||
error_message=error_message,
|
||
result_detail=resolved_message or error_message,
|
||
droidbot_steps=statistics.get("droidbot_steps"),
|
||
guiagent_steps=statistics.get("guiagent_steps"),
|
||
total_steps=statistics.get("total_steps"),
|
||
num_nodes=statistics.get("num_nodes"),
|
||
num_reached_activities=statistics.get("num_reached_activities"),
|
||
app_num_total_activities=statistics.get("app_num_total_activities"),
|
||
failure_domain=failure_domain,
|
||
failure_subtype=failure_subtype,
|
||
trace_payload=trace_payload,
|
||
retry_count=retry_count,
|
||
)
|
||
self.redis.delete(self._execution_key(previous_task_key))
|
||
|
||
def mark_worker_disabled(self, worker_id: str, reason: str):
|
||
self.set_worker_state(worker_id, "disabled", reason=reason, event_time=time.time())
|
||
|
||
def mark_worker_offline(self, worker_id: str, reason: str):
|
||
self.set_worker_state(worker_id, "offline", reason=reason, event_time=time.time())
|
||
|
||
def set_failure_bucket(self, task_key: str, failure_bucket: str):
|
||
with self._lock:
|
||
execution_id = self.redis.get(self._execution_key(task_key))
|
||
if execution_id:
|
||
self.repo.update_failure_bucket(execution_id, failure_bucket)
|
||
return
|
||
self.repo.update_failure_bucket_by_task_key(task_key, failure_bucket)
|
||
|
||
def _controller_up_seconds(self, date_str: str, now_ts: Optional[float] = None) -> float:
|
||
day_start, day_end, _ = _day_bounds(date_str, now_ts)
|
||
total = 0.0
|
||
for row in self.repo.list_sessions_overlapping(day_start, day_end):
|
||
start = max(float(row["started_at"]), day_start)
|
||
end = min(float(row["ended_at"] or row["last_heartbeat_at"] or day_end), day_end)
|
||
total += max(0.0, end - start)
|
||
return round(total, 2)
|
||
|
||
def _resolve_worker_ids(
|
||
self,
|
||
date_str: str,
|
||
dispatcher=None,
|
||
worker_id: Optional[str] = None,
|
||
) -> List[str]:
|
||
if worker_id:
|
||
return [worker_id]
|
||
_, day_end, is_today = _day_bounds(date_str)
|
||
# 以 managed_worker_ids(inventory.json)为准
|
||
worker_ids = set(self.managed_worker_ids)
|
||
if not is_today:
|
||
# 仅在查看历史日期时合并 SQLite 中的 worker(兼容查看旧数据)
|
||
worker_ids.update(self.repo.list_worker_ids_up_to(day_end))
|
||
if dispatcher is not None and is_today:
|
||
for worker in dispatcher.get_dashboard_workers():
|
||
worker_ids.add(worker.get("worker_id"))
|
||
worker_ids.discard(None)
|
||
worker_ids.discard("")
|
||
return sorted(worker_ids)
|
||
|
||
def _build_worker_options(self, date_str: str, dispatcher=None) -> List[Dict[str, Any]]:
|
||
worker_ids = self._resolve_worker_ids(date_str, dispatcher=dispatcher)
|
||
current_workers: Dict[str, Dict[str, Any]] = {}
|
||
if dispatcher is not None:
|
||
current_workers = {worker.get("worker_id"): worker for worker in dispatcher.get_dashboard_workers()}
|
||
|
||
options: List[Dict[str, Any]] = []
|
||
for worker_id in worker_ids:
|
||
current = current_workers.get(worker_id, {})
|
||
ip_address = current.get("ip_address") or worker_id.split("_", 1)[0]
|
||
hostname = current.get("hostname") or ""
|
||
label = f"{ip_address} · {hostname}" if hostname else ip_address
|
||
options.append(
|
||
{
|
||
"worker_id": worker_id,
|
||
"ip_address": ip_address,
|
||
"hostname": hostname,
|
||
"label": label,
|
||
}
|
||
)
|
||
return options
|
||
|
||
def _build_worker_intervals(
|
||
self,
|
||
date_str: str,
|
||
dispatcher=None,
|
||
worker_id: Optional[str] = None,
|
||
) -> Dict[str, List[Tuple[float, float, str]]]:
|
||
day_start, day_end, _ = _day_bounds(date_str)
|
||
worker_ids = self._resolve_worker_ids(date_str, dispatcher=dispatcher, worker_id=worker_id)
|
||
rows = self.repo.list_state_events_before(day_end, worker_ids=worker_ids)
|
||
grouped: Dict[str, List[sqlite3.Row]] = defaultdict(list)
|
||
for row in rows:
|
||
grouped[row["worker_id"]].append(row)
|
||
|
||
intervals: Dict[str, List[Tuple[float, float, str]]] = {}
|
||
for worker_id in worker_ids:
|
||
events = grouped.get(worker_id, [])
|
||
prev_state = "offline"
|
||
prev_time = day_start
|
||
worker_intervals: List[Tuple[float, float, str]] = []
|
||
for row in events:
|
||
event_time = float(row["event_time"])
|
||
if event_time < day_start:
|
||
prev_state = row["state"]
|
||
continue
|
||
if event_time > day_end:
|
||
break
|
||
if event_time > prev_time:
|
||
worker_intervals.append((prev_time, event_time, prev_state))
|
||
prev_state = row["state"]
|
||
prev_time = event_time
|
||
if day_end > prev_time:
|
||
worker_intervals.append((prev_time, day_end, prev_state))
|
||
intervals[worker_id] = worker_intervals
|
||
return intervals
|
||
|
||
def _summarize_worker_seconds(
|
||
self,
|
||
date_str: str,
|
||
dispatcher=None,
|
||
worker_id: Optional[str] = None,
|
||
) -> Dict[str, Counter]:
|
||
intervals = self._build_worker_intervals(date_str, dispatcher=dispatcher, worker_id=worker_id)
|
||
summary: Dict[str, Counter] = {}
|
||
for worker_id, worker_intervals in intervals.items():
|
||
counts = Counter()
|
||
for start, end, state in worker_intervals:
|
||
counts[state] += max(0.0, end - start)
|
||
summary[worker_id] = counts
|
||
return summary
|
||
|
||
def _runtime_state_breakdown(
|
||
self,
|
||
date_str: str,
|
||
dispatcher=None,
|
||
worker_id: Optional[str] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
totals = Counter()
|
||
for counts in self._summarize_worker_seconds(date_str, dispatcher=dispatcher, worker_id=worker_id).values():
|
||
for state in RUNTIME_STATE_ORDER:
|
||
totals[state] += float(counts.get(state, 0.0))
|
||
total_seconds = sum(totals.values())
|
||
return [
|
||
{
|
||
"state": state,
|
||
"label": RUNTIME_STATE_LABELS[state],
|
||
"duration_seconds": round(float(totals[state]), 2),
|
||
"share_percent": round((float(totals[state]) / total_seconds) * 100, 2) if total_seconds > 0 else 0.0,
|
||
}
|
||
for state in RUNTIME_STATE_ORDER
|
||
]
|
||
|
||
def get_overview(
|
||
self,
|
||
date_str: Optional[str] = None,
|
||
dispatcher=None,
|
||
worker_id: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
now_ts = time.time()
|
||
date_str = date_str or _date_str_from_ts(now_ts)
|
||
day_start, day_end, is_today = _day_bounds(date_str, now_ts=now_ts)
|
||
up_seconds = self._controller_up_seconds(date_str, now_ts=now_ts)
|
||
down_seconds = round(max(0.0, day_end - day_start - up_seconds), 2)
|
||
metrics = self.repo.get_task_overview_metrics(date_str, worker_id=worker_id)
|
||
overall_totals = self.repo.get_task_execution_totals(worker_id=worker_id)
|
||
overall_success_total = overall_totals["task_success"]
|
||
# The global dashboard should reflect the dispatcher's in-memory success set,
|
||
# including tasks preloaded from the current active catalog queue.
|
||
if dispatcher is not None and not worker_id:
|
||
overall_success_total = int(dispatcher.get_statistics().get("completed") or 0)
|
||
|
||
state_counts = {
|
||
"online_workers": 0,
|
||
"running_workers": 0,
|
||
"running_download_workers": 0,
|
||
"running_collect_workers": 0,
|
||
"idle_workers": 0,
|
||
"offline_workers": 0,
|
||
"disabled_workers": 0,
|
||
"pending_tasks": 0,
|
||
}
|
||
if is_today and dispatcher is not None:
|
||
dashboard_workers = dispatcher.get_dashboard_workers()
|
||
if worker_id:
|
||
dashboard_workers = [worker for worker in dashboard_workers if worker.get("worker_id") == worker_id]
|
||
state_counts["pending_tasks"] = 0 if worker_id else dispatcher.get_statistics().get("pending", 0)
|
||
for worker in dashboard_workers:
|
||
current_worker_id = worker.get("worker_id", "")
|
||
state = worker.get("state") or self._get_current_state(current_worker_id).get("state") or "offline"
|
||
if not worker.get("online"):
|
||
state = "offline"
|
||
if state in ONLINE_STATES:
|
||
state_counts["online_workers"] += 1
|
||
if state in RUNNING_STATES:
|
||
state_counts["running_workers"] += 1
|
||
if state == "running_download":
|
||
state_counts["running_download_workers"] += 1
|
||
elif state == "running_collect":
|
||
state_counts["running_collect_workers"] += 1
|
||
elif state == "idle_waiting_task":
|
||
state_counts["idle_workers"] += 1
|
||
elif state == "disabled":
|
||
state_counts["disabled_workers"] += 1
|
||
else:
|
||
state_counts["offline_workers"] += 1
|
||
else:
|
||
timeline = self.get_timeline(date_str=date_str, dispatcher=dispatcher, worker_id=worker_id)
|
||
last = timeline["points"][-1] if timeline["points"] else {}
|
||
state_counts = {
|
||
"online_workers": last.get("online_workers", 0),
|
||
"running_workers": last.get("running_workers", 0),
|
||
"running_download_workers": last.get("running_download_workers", 0),
|
||
"running_collect_workers": last.get("running_collect_workers", 0),
|
||
"idle_workers": last.get("idle_workers", 0),
|
||
"offline_workers": last.get("offline_workers", 0),
|
||
"disabled_workers": last.get("disabled_workers", 0),
|
||
}
|
||
|
||
return {
|
||
"date": date_str,
|
||
"is_today": is_today,
|
||
"controller_status": "running" if (is_today and self.current_session_id) else "stopped",
|
||
"controller_up_seconds": up_seconds,
|
||
"controller_down_seconds": down_seconds,
|
||
"task_total": metrics["task_total"],
|
||
"task_success": metrics["task_success"],
|
||
"task_non_success": metrics["task_non_success"],
|
||
"task_failed": metrics["task_failed"],
|
||
"task_total_all": overall_totals["task_total"],
|
||
"task_success_all": overall_success_total,
|
||
"task_non_success_all": overall_totals["task_non_success"],
|
||
"task_failed_all": overall_totals["task_failed"],
|
||
"avg_download_duration_seconds": metrics["avg_download_duration_seconds"],
|
||
"avg_collect_duration_seconds": metrics["avg_collect_duration_seconds"],
|
||
"p50_download_duration_seconds": metrics["p50_download_duration_seconds"],
|
||
"p90_download_duration_seconds": metrics["p90_download_duration_seconds"],
|
||
"p50_collect_duration_seconds": metrics["p50_collect_duration_seconds"],
|
||
"p90_collect_duration_seconds": metrics["p90_collect_duration_seconds"],
|
||
**state_counts,
|
||
"selected_worker_id": worker_id or "",
|
||
"worker_options": self._build_worker_options(date_str, dispatcher=dispatcher),
|
||
"generated_at": datetime.fromtimestamp(now_ts, SHANGHAI_TZ).strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
|
||
def get_distributions(
|
||
self,
|
||
date_str: Optional[str] = None,
|
||
worker_id: Optional[str] = None,
|
||
dispatcher=None,
|
||
) -> Dict[str, Any]:
|
||
date_str = date_str or _date_str_from_ts(time.time())
|
||
task_rows = self.repo.list_task_executions(date_str, worker_id=worker_id, limit=100000)
|
||
_, severe_items = self._split_rows_by_quality(task_rows)
|
||
download_values = [float(row["download_duration_seconds"]) for row in task_rows if row["download_duration_seconds"] is not None]
|
||
collect_values = [float(row["collect_duration_seconds"]) for row in task_rows if row["collect_duration_seconds"] is not None]
|
||
total_values = [float(row["total_duration_seconds"]) for row in task_rows if row["total_duration_seconds"] is not None]
|
||
severe_values = [
|
||
float(row["failed_total_duration_seconds"] or row["total_duration_seconds"] or 0.0)
|
||
for row in severe_items
|
||
if (row["failed_total_duration_seconds"] is not None or row["total_duration_seconds"] is not None)
|
||
]
|
||
bucket_edges = [60, 180, 300, 600, 1200, 1800, 3600]
|
||
return {
|
||
"date": date_str,
|
||
"download_duration": _build_histogram(download_values, bucket_edges),
|
||
"collect_duration": _build_histogram(collect_values, bucket_edges),
|
||
"total_duration": _build_histogram(total_values, bucket_edges),
|
||
"non_success_total_duration": _build_histogram(severe_values, bucket_edges),
|
||
**self.get_stage_analysis(date_str, worker_id=worker_id, dispatcher=dispatcher, rows=task_rows),
|
||
}
|
||
|
||
def get_stage_analysis(
|
||
self,
|
||
date_str: Optional[str] = None,
|
||
worker_id: Optional[str] = None,
|
||
dispatcher=None,
|
||
rows: Optional[List[sqlite3.Row]] = None,
|
||
) -> Dict[str, Any]:
|
||
date_str = date_str or _date_str_from_ts(time.time())
|
||
rows = rows if rows is not None else self.repo.list_task_executions(date_str, worker_id=worker_id, limit=100000)
|
||
worker_options = self._build_worker_options(date_str, dispatcher=dispatcher)
|
||
worker_label_map = {
|
||
item["worker_id"]: item.get("label") or item["worker_id"]
|
||
for item in worker_options
|
||
}
|
||
qualified_items, severe_items, collection_quality = self._classify_rows_by_collection_quality(rows)
|
||
|
||
def row_total(row: sqlite3.Row) -> float:
|
||
return _task_row_total_duration(row)
|
||
|
||
def summarize(items: List[sqlite3.Row]) -> List[Dict[str, Any]]:
|
||
if not items:
|
||
return []
|
||
total_sum = sum(max(row_total(item), 0.0) for item in items)
|
||
result = []
|
||
for stage in STAGE_ORDER:
|
||
key = f"{stage}_duration_seconds"
|
||
stage_sum = sum(float(item[key] or 0.0) for item in items)
|
||
result.append(
|
||
{
|
||
"stage": stage,
|
||
"label": STAGE_LABELS.get(stage, stage),
|
||
"avg_duration_seconds": round(stage_sum / len(items), 2),
|
||
"share_percent": round((stage_sum / total_sum) * 100, 2) if total_sum > 0 else 0.0,
|
||
"total_duration_seconds": round(stage_sum, 2),
|
||
}
|
||
)
|
||
return result
|
||
|
||
qualified_total = round(sum(row_total(item) for item in qualified_items), 2)
|
||
severe_total = round(sum(row_total(item) for item in severe_items), 2)
|
||
total_runtime = qualified_total + severe_total
|
||
time_composition = [
|
||
{
|
||
"bucket": "qualified",
|
||
"label": "合格",
|
||
"duration_seconds": qualified_total,
|
||
"share_percent": round((qualified_total / total_runtime) * 100, 2) if total_runtime > 0 else 0.0,
|
||
},
|
||
{
|
||
"bucket": "severe_restricted",
|
||
"label": "严重受限",
|
||
"duration_seconds": severe_total,
|
||
"share_percent": round((severe_total / total_runtime) * 100, 2) if total_runtime > 0 else 0.0,
|
||
},
|
||
]
|
||
|
||
domain_buckets: Dict[str, Dict[str, Any]] = {}
|
||
for row in severe_items:
|
||
failure_domain = row["failure_domain"]
|
||
failure_subtype = row["failure_subtype"]
|
||
if not failure_domain or not failure_subtype:
|
||
failure_domain, failure_subtype = _classify_failure(row["error_type"], row["error_message"])
|
||
duration_seconds = row_total(row)
|
||
reason_label = _humanize_failure_reason(row["error_type"], row["error_message"])
|
||
message_label = row["error_message"] or reason_label
|
||
sample = _build_failure_task_sample(row)
|
||
|
||
domain_entry = domain_buckets.setdefault(
|
||
failure_domain,
|
||
{
|
||
"domain": failure_domain,
|
||
"label": _humanize_failure_domain(failure_domain),
|
||
"duration_seconds": 0.0,
|
||
"task_count": 0,
|
||
"_subtypes": {},
|
||
"_reasons": {},
|
||
"_messages": {},
|
||
"_tasks": [],
|
||
},
|
||
)
|
||
domain_entry["duration_seconds"] += duration_seconds
|
||
domain_entry["task_count"] += 1
|
||
domain_entry["_tasks"].append(sample)
|
||
|
||
domain_reason = domain_entry["_reasons"].setdefault(reason_label, {"label": reason_label, "duration_seconds": 0.0, "task_count": 0})
|
||
domain_reason["duration_seconds"] += duration_seconds
|
||
domain_reason["task_count"] += 1
|
||
|
||
domain_message = domain_entry["_messages"].setdefault(message_label, {"label": message_label, "duration_seconds": 0.0, "task_count": 0})
|
||
domain_message["duration_seconds"] += duration_seconds
|
||
domain_message["task_count"] += 1
|
||
|
||
subtype_entry = domain_entry["_subtypes"].setdefault(
|
||
failure_subtype,
|
||
{
|
||
"subtype": failure_subtype,
|
||
"label": _humanize_failure_subtype(failure_domain, failure_subtype, row["error_type"], row["error_message"]),
|
||
"duration_seconds": 0.0,
|
||
"task_count": 0,
|
||
"_reasons": {},
|
||
"_messages": {},
|
||
"_tasks": [],
|
||
},
|
||
)
|
||
subtype_entry["duration_seconds"] += duration_seconds
|
||
subtype_entry["task_count"] += 1
|
||
subtype_entry["_tasks"].append(sample)
|
||
|
||
subtype_reason = subtype_entry["_reasons"].setdefault(reason_label, {"label": reason_label, "duration_seconds": 0.0, "task_count": 0})
|
||
subtype_reason["duration_seconds"] += duration_seconds
|
||
subtype_reason["task_count"] += 1
|
||
|
||
subtype_message = subtype_entry["_messages"].setdefault(message_label, {"label": message_label, "duration_seconds": 0.0, "task_count": 0})
|
||
subtype_message["duration_seconds"] += duration_seconds
|
||
subtype_message["task_count"] += 1
|
||
|
||
failure_domain_breakdown = []
|
||
failure_subtype_breakdown = []
|
||
for domain_entry in sorted(domain_buckets.values(), key=lambda item: item["duration_seconds"], reverse=True):
|
||
domain_duration = float(domain_entry["duration_seconds"])
|
||
subtypes = []
|
||
for subtype_entry in sorted(domain_entry["_subtypes"].values(), key=lambda item: item["duration_seconds"], reverse=True):
|
||
subtype_duration = float(subtype_entry["duration_seconds"])
|
||
subtype_reasons = sorted(subtype_entry["_reasons"].values(), key=lambda item: item["duration_seconds"], reverse=True)
|
||
subtype_messages = sorted(subtype_entry["_messages"].values(), key=lambda item: item["duration_seconds"], reverse=True)
|
||
subtype_tasks = sorted(
|
||
subtype_entry["_tasks"],
|
||
key=lambda item: (
|
||
float(item.get("failed_total_duration_seconds") or item.get("total_duration_seconds") or 0.0),
|
||
item.get("task_ended_at", ""),
|
||
),
|
||
reverse=True,
|
||
)
|
||
reasons = [
|
||
{
|
||
"label": item["label"],
|
||
"duration_seconds": round(float(item["duration_seconds"]), 2),
|
||
"task_count": int(item["task_count"]),
|
||
"overall_share_percent": round((float(item["duration_seconds"]) / total_runtime) * 100, 2) if total_runtime > 0 else 0.0,
|
||
"error_share_percent": round((float(item["duration_seconds"]) / severe_total) * 100, 2) if severe_total > 0 else 0.0,
|
||
"domain_share_percent": round((float(item["duration_seconds"]) / domain_duration) * 100, 2) if domain_duration > 0 else 0.0,
|
||
"subtype_share_percent": round((float(item["duration_seconds"]) / subtype_duration) * 100, 2) if subtype_duration > 0 else 0.0,
|
||
}
|
||
for item in subtype_reasons
|
||
]
|
||
messages = [
|
||
{
|
||
"label": item["label"],
|
||
"message": item["label"],
|
||
"duration_seconds": round(float(item["duration_seconds"]), 2),
|
||
"task_count": int(item["task_count"]),
|
||
"overall_share_percent": round((float(item["duration_seconds"]) / total_runtime) * 100, 2) if total_runtime > 0 else 0.0,
|
||
"error_share_percent": round((float(item["duration_seconds"]) / severe_total) * 100, 2) if severe_total > 0 else 0.0,
|
||
"domain_share_percent": round((float(item["duration_seconds"]) / domain_duration) * 100, 2) if domain_duration > 0 else 0.0,
|
||
"subtype_share_percent": round((float(item["duration_seconds"]) / subtype_duration) * 100, 2) if subtype_duration > 0 else 0.0,
|
||
}
|
||
for item in subtype_messages
|
||
]
|
||
subtype_item = {
|
||
"domain": domain_entry["domain"],
|
||
"subtype": subtype_entry["subtype"],
|
||
"label": subtype_entry["label"],
|
||
"duration_seconds": round(subtype_duration, 2),
|
||
"task_count": int(subtype_entry["task_count"]),
|
||
"avg_duration_seconds": round(subtype_duration / subtype_entry["task_count"], 2) if subtype_entry["task_count"] else 0.0,
|
||
"overall_share_percent": round((subtype_duration / total_runtime) * 100, 2) if total_runtime > 0 else 0.0,
|
||
"error_share_percent": round((subtype_duration / severe_total) * 100, 2) if severe_total > 0 else 0.0,
|
||
"domain_share_percent": round((subtype_duration / domain_duration) * 100, 2) if domain_duration > 0 else 0.0,
|
||
"reasons": reasons,
|
||
"messages": messages,
|
||
"tasks": subtype_tasks,
|
||
}
|
||
subtypes.append(subtype_item)
|
||
failure_subtype_breakdown.append(subtype_item)
|
||
|
||
domain_reasons = sorted(domain_entry["_reasons"].values(), key=lambda item: item["duration_seconds"], reverse=True)
|
||
domain_messages = sorted(domain_entry["_messages"].values(), key=lambda item: item["duration_seconds"], reverse=True)
|
||
domain_tasks = sorted(
|
||
domain_entry["_tasks"],
|
||
key=lambda item: (
|
||
float(item.get("failed_total_duration_seconds") or item.get("total_duration_seconds") or 0.0),
|
||
item.get("task_ended_at", ""),
|
||
),
|
||
reverse=True,
|
||
)
|
||
failure_domain_breakdown.append(
|
||
{
|
||
"domain": domain_entry["domain"],
|
||
"label": domain_entry["label"],
|
||
"duration_seconds": round(domain_duration, 2),
|
||
"task_count": int(domain_entry["task_count"]),
|
||
"overall_share_percent": round((domain_duration / total_runtime) * 100, 2) if total_runtime > 0 else 0.0,
|
||
"error_share_percent": round((domain_duration / severe_total) * 100, 2) if severe_total > 0 else 0.0,
|
||
"share_percent": round((domain_duration / severe_total) * 100, 2) if severe_total > 0 else 0.0,
|
||
"subtypes": subtypes,
|
||
"reasons": [
|
||
{
|
||
"label": item["label"],
|
||
"duration_seconds": round(float(item["duration_seconds"]), 2),
|
||
"task_count": int(item["task_count"]),
|
||
"overall_share_percent": round((float(item["duration_seconds"]) / total_runtime) * 100, 2) if total_runtime > 0 else 0.0,
|
||
"error_share_percent": round((float(item["duration_seconds"]) / severe_total) * 100, 2) if severe_total > 0 else 0.0,
|
||
"domain_share_percent": round((float(item["duration_seconds"]) / domain_duration) * 100, 2) if domain_duration > 0 else 0.0,
|
||
}
|
||
for item in domain_reasons
|
||
],
|
||
"messages": [
|
||
{
|
||
"label": item["label"],
|
||
"message": item["label"],
|
||
"duration_seconds": round(float(item["duration_seconds"]), 2),
|
||
"task_count": int(item["task_count"]),
|
||
"overall_share_percent": round((float(item["duration_seconds"]) / total_runtime) * 100, 2) if total_runtime > 0 else 0.0,
|
||
"error_share_percent": round((float(item["duration_seconds"]) / severe_total) * 100, 2) if severe_total > 0 else 0.0,
|
||
"domain_share_percent": round((float(item["duration_seconds"]) / domain_duration) * 100, 2) if domain_duration > 0 else 0.0,
|
||
}
|
||
for item in domain_messages
|
||
],
|
||
"tasks": domain_tasks,
|
||
}
|
||
)
|
||
|
||
failure_reasons = Counter()
|
||
for row in severe_items:
|
||
label = _humanize_failure_reason(row["error_type"], row["error_message"])
|
||
failure_reasons[label] += 1
|
||
|
||
if len(time_composition) > 1:
|
||
time_composition[1]["details"] = [
|
||
{
|
||
"domain": item["domain"],
|
||
"label": item["label"],
|
||
"duration_seconds": item["duration_seconds"],
|
||
"overall_share_percent": item["overall_share_percent"],
|
||
"error_share_percent": item["error_share_percent"],
|
||
"task_count": item["task_count"],
|
||
}
|
||
for item in failure_domain_breakdown
|
||
]
|
||
|
||
worker_stage_buckets: Dict[str, Dict[str, Any]] = {}
|
||
total_collect_seconds = 0.0
|
||
for row in rows:
|
||
current_worker_id = row["worker_id"]
|
||
if not current_worker_id:
|
||
continue
|
||
download_seconds = float(row["download_duration_seconds"] or 0.0)
|
||
collect_seconds = float(row["collect_duration_seconds"] or 0.0)
|
||
if download_seconds <= 0 and collect_seconds <= 0:
|
||
continue
|
||
stage_total_seconds = download_seconds + collect_seconds
|
||
bucket = worker_stage_buckets.setdefault(
|
||
current_worker_id,
|
||
{
|
||
"worker_id": current_worker_id,
|
||
"label": worker_label_map.get(current_worker_id, current_worker_id),
|
||
"download_duration_seconds": 0.0,
|
||
"collect_duration_seconds": 0.0,
|
||
"stage_total_seconds": 0.0,
|
||
"task_count": 0,
|
||
"success_count": 0,
|
||
"non_success_count": 0,
|
||
},
|
||
)
|
||
bucket["download_duration_seconds"] += download_seconds
|
||
bucket["collect_duration_seconds"] += collect_seconds
|
||
bucket["stage_total_seconds"] += stage_total_seconds
|
||
bucket["task_count"] += 1
|
||
if row["status"] == "success":
|
||
bucket["success_count"] += 1
|
||
else:
|
||
bucket["non_success_count"] += 1
|
||
total_collect_seconds += collect_seconds
|
||
|
||
worker_collect_share_breakdown = [
|
||
{
|
||
"worker_id": item["worker_id"],
|
||
"label": item["label"],
|
||
"download_duration_seconds": round(float(item["download_duration_seconds"]), 2),
|
||
"collect_duration_seconds": round(float(item["collect_duration_seconds"]), 2),
|
||
"stage_total_seconds": round(float(item["stage_total_seconds"]), 2),
|
||
"collect_share_percent": round((float(item["collect_duration_seconds"]) / float(item["stage_total_seconds"])) * 100, 2)
|
||
if item["stage_total_seconds"] > 0
|
||
else 0.0,
|
||
"overall_collect_share_percent": round((float(item["collect_duration_seconds"]) / total_collect_seconds) * 100, 2)
|
||
if total_collect_seconds > 0
|
||
else 0.0,
|
||
"task_count": int(item["task_count"]),
|
||
"success_count": int(item["success_count"]),
|
||
"non_success_count": int(item["non_success_count"]),
|
||
}
|
||
for item in sorted(
|
||
worker_stage_buckets.values(),
|
||
key=lambda current: (
|
||
round((float(current["collect_duration_seconds"]) / float(current["stage_total_seconds"])) * 100, 6)
|
||
if current["stage_total_seconds"] > 0
|
||
else 0.0,
|
||
float(current["collect_duration_seconds"]),
|
||
),
|
||
reverse=True,
|
||
)
|
||
]
|
||
|
||
return {
|
||
"success_stage_breakdown": summarize(qualified_items),
|
||
"non_success_stage_breakdown": summarize(severe_items),
|
||
"time_composition": time_composition,
|
||
"daily_collection_overview": {
|
||
"qualified_task_count": int(collection_quality["qualified_task_count"]),
|
||
"qualified_success_task_count": int(collection_quality["qualified_success_task_count"]),
|
||
"qualified_light_task_count": int(collection_quality["qualified_light_task_count"]),
|
||
"non_success_task_count": int(collection_quality["non_success_task_count"]),
|
||
"non_retryable_task_count": int(collection_quality["non_retryable_task_count"]),
|
||
},
|
||
"runtime_state_breakdown": self._runtime_state_breakdown(
|
||
date_str,
|
||
dispatcher=dispatcher,
|
||
worker_id=worker_id,
|
||
),
|
||
"failure_domain_breakdown": failure_domain_breakdown,
|
||
"failure_subtype_breakdown": failure_subtype_breakdown,
|
||
"failure_reasons": [
|
||
{"label": label, "count": count}
|
||
for label, count in failure_reasons.most_common()
|
||
],
|
||
"success_task_count": len(qualified_items),
|
||
"non_success_task_count": len(severe_items),
|
||
"worker_collect_share_breakdown": worker_collect_share_breakdown,
|
||
"total_collect_duration_seconds": round(total_collect_seconds, 2),
|
||
"selected_worker_id": worker_id or "",
|
||
"worker_options": worker_options,
|
||
}
|
||
|
||
def get_timeline(
|
||
self,
|
||
date_str: Optional[str] = None,
|
||
dispatcher=None,
|
||
bucket_minutes: int = MONITORING_TIMELINE_BUCKET_MINUTES,
|
||
worker_id: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
date_str = date_str or _date_str_from_ts(time.time())
|
||
day_start, day_end, _ = _day_bounds(date_str)
|
||
intervals = self._build_worker_intervals(date_str, dispatcher=dispatcher, worker_id=worker_id)
|
||
bucket_seconds = max(1, bucket_minutes) * 60
|
||
buckets: List[Dict[str, Any]] = []
|
||
bucket_start = day_start
|
||
while bucket_start < day_end:
|
||
buckets.append(
|
||
{
|
||
"timestamp": bucket_start,
|
||
"label": datetime.fromtimestamp(bucket_start, SHANGHAI_TZ).strftime("%H:%M"),
|
||
"online_workers": 0,
|
||
"running_workers": 0,
|
||
"running_download_workers": 0,
|
||
"running_collect_workers": 0,
|
||
"idle_workers": 0,
|
||
"offline_workers": 0,
|
||
"disabled_workers": 0,
|
||
}
|
||
)
|
||
bucket_start += bucket_seconds
|
||
|
||
for worker_intervals in intervals.values():
|
||
for interval_start, interval_end, state in worker_intervals:
|
||
for bucket in buckets:
|
||
bucket_start = bucket["timestamp"]
|
||
bucket_end = bucket_start + bucket_seconds
|
||
overlap_start = max(interval_start, bucket_start)
|
||
overlap_end = min(interval_end, bucket_end)
|
||
if overlap_start >= overlap_end:
|
||
continue
|
||
weight = (overlap_end - overlap_start) / bucket_seconds
|
||
if state in ONLINE_STATES:
|
||
bucket["online_workers"] += weight
|
||
if state in RUNNING_STATES:
|
||
bucket["running_workers"] += weight
|
||
if state == "running_download":
|
||
bucket["running_download_workers"] += weight
|
||
elif state == "running_collect":
|
||
bucket["running_collect_workers"] += weight
|
||
elif state == "idle_waiting_task":
|
||
bucket["idle_workers"] += weight
|
||
elif state == "disabled":
|
||
bucket["disabled_workers"] += weight
|
||
else:
|
||
bucket["offline_workers"] += weight
|
||
for bucket in buckets:
|
||
for key in (
|
||
"online_workers",
|
||
"running_workers",
|
||
"running_download_workers",
|
||
"running_collect_workers",
|
||
"idle_workers",
|
||
"offline_workers",
|
||
"disabled_workers",
|
||
):
|
||
bucket[key] = round(float(bucket[key]), 2)
|
||
return {"date": date_str, "bucket_minutes": bucket_minutes, "points": buckets}
|
||
|
||
def get_worker_stats(
|
||
self,
|
||
date_str: Optional[str] = None,
|
||
dispatcher=None,
|
||
) -> List[Dict[str, Any]]:
|
||
now_ts = time.time()
|
||
date_str = date_str or _date_str_from_ts(now_ts)
|
||
is_today = _date_str_from_ts(now_ts) == date_str
|
||
seconds_by_worker = self._summarize_worker_seconds(date_str, dispatcher=dispatcher)
|
||
task_rows = self.repo.list_task_executions(date_str, limit=100000)
|
||
task_stats: Dict[str, Dict[str, Any]] = defaultdict(
|
||
lambda: {
|
||
"task_count": 0,
|
||
"success_count": 0,
|
||
"failed_count": 0,
|
||
"download_values": [],
|
||
"collect_values": [],
|
||
"last_task_at": None,
|
||
}
|
||
)
|
||
for row in task_rows:
|
||
entry = task_stats[row["worker_id"]]
|
||
entry["task_count"] += 1
|
||
if row["status"] == "success":
|
||
entry["success_count"] += 1
|
||
elif row["status"] == "failed":
|
||
entry["failed_count"] += 1
|
||
if row["download_duration_seconds"] is not None:
|
||
entry["download_values"].append(float(row["download_duration_seconds"]))
|
||
if row["collect_duration_seconds"] is not None:
|
||
entry["collect_values"].append(float(row["collect_duration_seconds"]))
|
||
last_task_at = row["task_ended_at"] or row["task_started_at"]
|
||
if last_task_at is not None:
|
||
if entry["last_task_at"] is None or float(last_task_at) > entry["last_task_at"]:
|
||
entry["last_task_at"] = float(last_task_at)
|
||
|
||
current_workers = {}
|
||
if dispatcher is not None and is_today:
|
||
current_workers = {worker.get("worker_id"): worker for worker in dispatcher.get_dashboard_workers()}
|
||
|
||
results: List[Dict[str, Any]] = []
|
||
for worker_id in sorted(set(self._resolve_worker_ids(date_str, dispatcher=dispatcher))):
|
||
seconds = seconds_by_worker.get(worker_id, Counter())
|
||
metrics = task_stats.get(worker_id, {})
|
||
current_worker = current_workers.get(worker_id, {})
|
||
current_state = (
|
||
current_worker.get("state")
|
||
or self._get_current_state(worker_id).get("state")
|
||
or ("offline" if not current_worker.get("online") else "idle_waiting_task")
|
||
)
|
||
if is_today and current_worker and not current_worker.get("online"):
|
||
current_state = "offline"
|
||
last_task_at = metrics.get("last_task_at")
|
||
results.append(
|
||
{
|
||
"worker_id": worker_id,
|
||
"ip_address": current_worker.get("ip_address") or worker_id.split("_", 1)[0],
|
||
"status": DISPLAY_STATE_LABELS.get(current_state, current_state),
|
||
"running_seconds": round(seconds.get("running_download", 0.0) + seconds.get("running_collect", 0.0), 2),
|
||
"idle_waiting_seconds": round(seconds.get("idle_waiting_task", 0.0), 2),
|
||
"offline_seconds": round(seconds.get("offline", 0.0), 2),
|
||
"disabled_seconds": round(seconds.get("disabled", 0.0), 2),
|
||
"task_count": metrics.get("task_count", 0),
|
||
"success_count": metrics.get("success_count", 0),
|
||
"failed_count": metrics.get("failed_count", 0),
|
||
"avg_download_duration_seconds": round(sum(metrics.get("download_values", [])) / len(metrics.get("download_values", [])), 2)
|
||
if metrics.get("download_values")
|
||
else 0.0,
|
||
"avg_collect_duration_seconds": round(sum(metrics.get("collect_values", [])) / len(metrics.get("collect_values", [])), 2)
|
||
if metrics.get("collect_values")
|
||
else 0.0,
|
||
"last_task_at": datetime.fromtimestamp(last_task_at, SHANGHAI_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||
if last_task_at
|
||
else "--",
|
||
}
|
||
)
|
||
return results
|
||
|
||
def get_task_rows(
|
||
self,
|
||
date_str: Optional[str] = None,
|
||
worker_id: Optional[str] = None,
|
||
limit: int = 200,
|
||
) -> List[Dict[str, Any]]:
|
||
date_str = date_str or _date_str_from_ts(time.time())
|
||
rows = self.repo.list_task_executions(date_str, worker_id=worker_id, limit=limit)
|
||
return [_build_failure_task_sample(row) for row in rows]
|