4932 lines
182 KiB
Python
4932 lines
182 KiB
Python
import json
|
||
import logging
|
||
import traceback
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from config import (
|
||
DASHBOARD_HOST,
|
||
DASHBOARD_PORT,
|
||
INSTANCE_NAME,
|
||
MONITORING_TIMEZONE,
|
||
REDIS_DB,
|
||
RUN_PIPELINE_DEFAULT_OPTIONS,
|
||
WORKER_ACTION_MAX_PARALLEL,
|
||
)
|
||
from redis_task_distribute import RedisTaskDispatcher
|
||
|
||
try:
|
||
from flask import Flask, jsonify, render_template_string, request
|
||
except ImportError: # pragma: no cover - runtime dependency check
|
||
Flask = None
|
||
jsonify = None
|
||
render_template_string = None
|
||
request = None
|
||
|
||
|
||
def silence_flask_runtime_logs():
|
||
try:
|
||
import flask.cli as flask_cli
|
||
|
||
flask_cli.show_server_banner = lambda *args, **kwargs: None
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
import werkzeug.serving as werkzeug_serving
|
||
|
||
werkzeug_serving._log = lambda *args, **kwargs: None
|
||
except Exception:
|
||
pass
|
||
|
||
logging.getLogger("werkzeug").disabled = True
|
||
|
||
|
||
class WorkerActionJobManager:
|
||
def __init__(self, dispatcher):
|
||
self.dispatcher = dispatcher
|
||
self._lock = threading.Lock()
|
||
self._jobs: Dict[str, Dict[str, Any]] = {}
|
||
|
||
def submit(self, worker_ids: List[str], action: str, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
job_id = uuid.uuid4().hex
|
||
now_ts = time.time()
|
||
job = {
|
||
"job_id": job_id,
|
||
"action": action,
|
||
"worker_ids": list(worker_ids),
|
||
"options": dict(options or {}),
|
||
"status": "running",
|
||
"started_at": now_ts,
|
||
"finished_at": 0.0,
|
||
"error": "",
|
||
"results": [
|
||
{
|
||
"worker_id": worker_id,
|
||
"ok": None,
|
||
"message": "等待执行",
|
||
"steps": [],
|
||
}
|
||
for worker_id in worker_ids
|
||
],
|
||
}
|
||
with self._lock:
|
||
self._jobs[job_id] = job
|
||
thread = threading.Thread(
|
||
target=self._run_job,
|
||
args=(job_id, list(worker_ids), action, dict(options or {})),
|
||
daemon=True,
|
||
)
|
||
thread.start()
|
||
return self.get(job_id)
|
||
|
||
def get(self, job_id: str) -> Optional[Dict[str, Any]]:
|
||
with self._lock:
|
||
job = self._jobs.get(job_id)
|
||
if not job:
|
||
return None
|
||
return json.loads(json.dumps(job))
|
||
|
||
def _run_job(self, job_id: str, worker_ids: List[str], action: str, options: Dict[str, Any]) -> None:
|
||
results: List[Dict[str, Any]] = []
|
||
try:
|
||
max_workers = min(max(len(worker_ids), 1), WORKER_ACTION_MAX_PARALLEL)
|
||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||
futures = {
|
||
executor.submit(
|
||
self.dispatcher.run_dashboard_action,
|
||
[worker_id],
|
||
action,
|
||
options=options,
|
||
progress_callback=lambda current_worker_id, payload: self._record_progress(job_id, current_worker_id, payload),
|
||
): worker_id
|
||
for worker_id in worker_ids
|
||
}
|
||
for future in as_completed(futures):
|
||
worker_id = futures[future]
|
||
try:
|
||
worker_results = future.result()
|
||
except Exception as exc:
|
||
detail = traceback.format_exc().strip()
|
||
worker_results = [{
|
||
"worker_id": worker_id,
|
||
"action": action,
|
||
"ok": False,
|
||
"message": detail or str(exc),
|
||
"stderr": detail,
|
||
}]
|
||
results.extend(worker_results)
|
||
except Exception as exc:
|
||
with self._lock:
|
||
job = self._jobs.get(job_id)
|
||
if not job:
|
||
return
|
||
job["status"] = "failed"
|
||
job["finished_at"] = time.time()
|
||
job["error"] = traceback.format_exc().strip() or str(exc)
|
||
return
|
||
|
||
with self._lock:
|
||
job = self._jobs.get(job_id)
|
||
if not job:
|
||
return
|
||
by_worker = {item["worker_id"]: item for item in job["results"]}
|
||
for result in results:
|
||
item = by_worker.setdefault(
|
||
result.get("worker_id", ""),
|
||
{"worker_id": result.get("worker_id", ""), "ok": None, "message": "", "steps": []},
|
||
)
|
||
item["ok"] = result.get("ok")
|
||
item["message"] = self._result_message(result)
|
||
if isinstance(result.get("steps"), list):
|
||
item["steps"] = [
|
||
{
|
||
"step": str(step.get("step", "")),
|
||
"ok": step.get("ok"),
|
||
"message": str(step.get("message", "")),
|
||
}
|
||
for step in result.get("steps", [])
|
||
if isinstance(step, dict)
|
||
]
|
||
item["stdout"] = result.get("stdout", "")
|
||
item["stderr"] = result.get("stderr", "")
|
||
item["command"] = result.get("command", "")
|
||
job["results"] = list(by_worker.values())
|
||
job["finished_at"] = time.time()
|
||
job["status"] = "failed" if any(item.get("ok") is False for item in job["results"]) else "completed"
|
||
|
||
@staticmethod
|
||
def _result_message(result: Dict[str, Any]) -> str:
|
||
base = str(result.get("message", "") or "").strip()
|
||
prefixes = []
|
||
failed_step = str(result.get("failed_step", "") or "").strip()
|
||
stage = str(result.get("stage", "") or "").strip()
|
||
if failed_step:
|
||
prefixes.append(f"step={failed_step}")
|
||
if stage:
|
||
prefixes.append(f"stage={stage}")
|
||
if prefixes:
|
||
prefix = "[" + " ".join(prefixes) + "]"
|
||
base = f"{prefix} {base}".strip()
|
||
if result.get("ok") is not False:
|
||
return base
|
||
parts = [base] if base else []
|
||
for label, key in (("stdout", "stdout"), ("stderr", "stderr")):
|
||
value = str(result.get(key, "") or "").strip()
|
||
if not value or value == base:
|
||
continue
|
||
parts.append(f"{label}:\n{value}")
|
||
return "\n\n".join(parts)
|
||
|
||
def _record_progress(self, job_id: str, worker_id: str, payload: Dict[str, Any]) -> None:
|
||
with self._lock:
|
||
job = self._jobs.get(job_id)
|
||
if not job:
|
||
return
|
||
result = None
|
||
for item in job["results"]:
|
||
if item["worker_id"] == worker_id:
|
||
result = item
|
||
break
|
||
if result is None:
|
||
result = {"worker_id": worker_id, "ok": None, "message": "", "steps": []}
|
||
job["results"].append(result)
|
||
result["message"] = str(payload.get("message", result.get("message", "")))
|
||
step_name = str(payload.get("step", "")).strip()
|
||
if not step_name:
|
||
return
|
||
status = payload.get("status")
|
||
step_ok = None
|
||
if status in {"success", "ok"}:
|
||
step_ok = True
|
||
elif status in {"failed", "error"}:
|
||
step_ok = False
|
||
steps = result.setdefault("steps", [])
|
||
current_step = None
|
||
for step in steps:
|
||
if step.get("step") == step_name:
|
||
current_step = step
|
||
break
|
||
if current_step is None:
|
||
current_step = {"step": step_name, "ok": step_ok, "message": result["message"]}
|
||
steps.append(current_step)
|
||
else:
|
||
current_step["message"] = result["message"]
|
||
current_step["ok"] = step_ok
|
||
|
||
|
||
TEMPLATE = """
|
||
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>{{ instance_name }} Monitoring Dashboard</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||
<link rel="stylesheet" href="/static/vendor/bootstrap/bootstrap.min.css">
|
||
<style>
|
||
:root {
|
||
--bg: #f2eee8;
|
||
--panel: #e7e1d8;
|
||
--line: #d8d1c7;
|
||
--text: #1f2a33;
|
||
--muted: #4f5b69;
|
||
--accent: #76869a;
|
||
--ok: #7c9488;
|
||
--warn: #b39c76;
|
||
--err: #5f6875;
|
||
--panel-strong: #fbfaf7;
|
||
--font: "Outfit", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
--mono: "Consolas", "SFMono-Regular", monospace;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font-family: var(--font);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
background:
|
||
linear-gradient(0deg, rgba(242, 238, 232, 0.96), rgba(242, 238, 232, 0.96)),
|
||
linear-gradient(90deg, transparent 0, transparent 27px, rgba(36, 48, 59, 0.025) 27px, rgba(36, 48, 59, 0.025) 28px),
|
||
#f7f3ee;
|
||
}
|
||
.shell { width: min(1520px, calc(100vw - 36px)); margin: 20px auto 36px; }
|
||
.hero, .panel {
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: var(--panel-strong);
|
||
box-shadow: none;
|
||
}
|
||
.hero {
|
||
position: relative;
|
||
overflow: hidden;
|
||
padding: 24px 26px 22px;
|
||
margin-bottom: 18px;
|
||
background: #5f6875;
|
||
color: #fbfaf7;
|
||
}
|
||
.hero::before,
|
||
.hero::after {
|
||
content: "";
|
||
position: absolute;
|
||
pointer-events: none;
|
||
}
|
||
.hero::before {
|
||
width: 220px;
|
||
height: 220px;
|
||
right: -72px;
|
||
top: -58px;
|
||
background: rgba(196, 206, 219, 0.28);
|
||
border-radius: 24px;
|
||
transform: rotate(16deg);
|
||
}
|
||
.hero::after {
|
||
width: 140px;
|
||
height: 140px;
|
||
left: -36px;
|
||
bottom: -58px;
|
||
background: rgba(203, 194, 170, 0.24);
|
||
border-radius: 999px;
|
||
}
|
||
.hero > * { position: relative; z-index: 1; }
|
||
.eyebrow { font-size: 12px; letter-spacing: 0.16em; text-transform: uppercase; color: rgba(255, 255, 255, 0.82); }
|
||
.title-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 18px;
|
||
align-items: start;
|
||
margin-top: 8px;
|
||
}
|
||
h1 { margin: 0; font-size: clamp(28px, 4vw, 46px); line-height: 0.98; }
|
||
.subtitle { color: rgba(255, 255, 255, 0.8); font-size: 13px; line-height: 1.5; max-width: 560px; }
|
||
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
|
||
.toolbar-wrap {
|
||
display: grid;
|
||
gap: 10px;
|
||
justify-items: end;
|
||
align-content: start;
|
||
}
|
||
.hero-summary {
|
||
margin-top: 18px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
.context-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
border: 0;
|
||
background: rgba(255, 255, 255, 0.16);
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.94);
|
||
}
|
||
.snapshot-panel {
|
||
margin-bottom: 18px;
|
||
background: var(--panel);
|
||
}
|
||
.snapshot-body {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
.primary-metrics {
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
}
|
||
.view-switcher {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin: 0 0 18px;
|
||
}
|
||
.view-tab {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
border: 0;
|
||
background: #ffffff;
|
||
color: var(--text);
|
||
box-shadow: none;
|
||
transition: transform 180ms ease, background-color 180ms ease, color 180ms ease;
|
||
}
|
||
.view-tab:hover {
|
||
transform: scale(1.03);
|
||
background: #eef1f3;
|
||
}
|
||
.view-tab.active {
|
||
background: #76869a;
|
||
color: #fbfaf7;
|
||
}
|
||
.view-tab-label {
|
||
text-align: left;
|
||
}
|
||
.view-tab-label strong {
|
||
font-size: 14px;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.view-tab-badge {
|
||
min-width: 34px;
|
||
padding: 4px 8px;
|
||
text-align: center;
|
||
border-radius: 999px;
|
||
background: rgba(17, 24, 39, 0.08);
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
font-weight: 700;
|
||
}
|
||
.view-tab.active .view-tab-badge {
|
||
background: rgba(255, 255, 255, 0.16);
|
||
color: #ffffff;
|
||
}
|
||
.view-panel {
|
||
display: grid;
|
||
gap: 18px;
|
||
}
|
||
.view-panel.is-hidden {
|
||
display: none;
|
||
}
|
||
.pill, input[type="date"], select, button {
|
||
border-radius: 8px;
|
||
border: 0;
|
||
background: #ffffff;
|
||
color: var(--text);
|
||
font: inherit;
|
||
padding: 10px 14px;
|
||
}
|
||
input[type="date"], select {
|
||
background: #f3f4f6;
|
||
}
|
||
button {
|
||
cursor: pointer;
|
||
min-height: 48px;
|
||
font-weight: 600;
|
||
transition: transform 180ms ease, background-color 180ms ease, color 180ms ease;
|
||
}
|
||
button:hover {
|
||
transform: scale(1.03);
|
||
}
|
||
button:focus-visible,
|
||
input[type="date"]:focus-visible,
|
||
select:focus-visible {
|
||
outline: 0;
|
||
box-shadow: 0 0 0 2px #fbfaf7, 0 0 0 4px #76869a;
|
||
}
|
||
.danger-btn {
|
||
background: #5f6875;
|
||
color: #fbfaf7;
|
||
}
|
||
.danger-btn:disabled {
|
||
opacity: 0.55;
|
||
cursor: wait;
|
||
}
|
||
.section-title {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin: 30px 4px 12px;
|
||
}
|
||
.section-title h2 {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
letter-spacing: -0.02em;
|
||
font-weight: 800;
|
||
font-family: "Outfit", var(--font);
|
||
}
|
||
.section-title .muted { font-size: 13px; }
|
||
.grid { display: grid; gap: 20px; }
|
||
.metrics { grid-template-columns: repeat(auto-fit, minmax(175px, 1fr)); }
|
||
.main { grid-template-columns: 1fr; align-items: start; }
|
||
.overview-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
align-items: start;
|
||
}
|
||
.daily-collection-panel {
|
||
background: #efebe4;
|
||
}
|
||
.daily-collection-grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
.daily-collection-card {
|
||
position: relative;
|
||
overflow: hidden;
|
||
min-height: 156px;
|
||
padding: 18px;
|
||
border-radius: 8px;
|
||
display: grid;
|
||
gap: 10px;
|
||
align-content: start;
|
||
}
|
||
.daily-collection-card::after {
|
||
content: "";
|
||
position: absolute;
|
||
right: -20px;
|
||
bottom: -24px;
|
||
width: 74px;
|
||
height: 74px;
|
||
border-radius: 14px;
|
||
background: rgba(251, 250, 247, 0.2);
|
||
transform: rotate(16deg);
|
||
}
|
||
.daily-collection-card.qualified {
|
||
background: #869c92;
|
||
color: #fbfaf7;
|
||
}
|
||
.daily-collection-card.non-success {
|
||
background: #dde1e6;
|
||
color: #24303b;
|
||
}
|
||
.daily-collection-card.non-retryable {
|
||
background: #697280;
|
||
color: #fbfaf7;
|
||
}
|
||
.daily-collection-card .eyebrow {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.12em;
|
||
opacity: 0.96;
|
||
}
|
||
.daily-collection-card .value {
|
||
font-size: 34px;
|
||
font-weight: 800;
|
||
line-height: 0.95;
|
||
letter-spacing: -0.04em;
|
||
}
|
||
.daily-collection-card .hint {
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
opacity: 0.96;
|
||
}
|
||
.daily-collection-breakdown {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 8px;
|
||
margin-top: auto;
|
||
}
|
||
.daily-collection-breakdown-item {
|
||
border-radius: 8px;
|
||
background: rgba(251, 250, 247, 0.22);
|
||
padding: 8px 10px;
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
.daily-collection-breakdown-item .label {
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
opacity: 0.94;
|
||
}
|
||
.daily-collection-breakdown-item strong {
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.span-two {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.panel-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 20px 20px 4px;
|
||
}
|
||
.panel h2 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
font-family: "Outfit", var(--font);
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.panel-body { padding: 16px 20px 20px; }
|
||
.card.shadow-sm.rounded-4 {
|
||
border: 0;
|
||
border-radius: 8px !important;
|
||
background: #fbfaf7;
|
||
box-shadow: none !important;
|
||
}
|
||
.card.shadow-sm.rounded-4 .card-header {
|
||
border-bottom: 2px solid #d8d1c7;
|
||
background: transparent;
|
||
padding: 18px 20px 12px;
|
||
}
|
||
.card.shadow-sm.rounded-4 .card-body {
|
||
padding: 16px 20px 20px;
|
||
background: transparent;
|
||
}
|
||
.metric-card {
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
padding: 18px 18px 16px;
|
||
min-height: 132px;
|
||
display: grid;
|
||
align-content: start;
|
||
}
|
||
.metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||
.metric-value { margin-top: 8px; font-size: 30px; font-weight: 800; letter-spacing: -0.03em; }
|
||
.metric-note { margin-top: 8px; font-size: 12px; color: var(--muted); }
|
||
.chart-grid { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
.chart-card {
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
padding: 16px 16px 14px;
|
||
min-height: 252px;
|
||
display: grid;
|
||
align-content: start;
|
||
gap: 14px;
|
||
}
|
||
.stats-grid {
|
||
display: grid;
|
||
gap: 16px;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
.stat-card {
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
padding: 18px;
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
.stat-card strong {
|
||
font-size: 16px;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.stat-metrics {
|
||
display: grid;
|
||
gap: 10px;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
.stat-metric {
|
||
padding: 12px 12px 10px;
|
||
border-radius: 8px;
|
||
background: #f3f4f6;
|
||
}
|
||
.stat-metric .label {
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
.stat-metric .value {
|
||
margin-top: 6px;
|
||
font-size: 24px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.chart-title {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.chart-title strong {
|
||
font-size: 16px;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.chart-title span {
|
||
color: #42505c;
|
||
font-size: 12px;
|
||
}
|
||
.histogram {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.hist-bars {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(46px, 1fr));
|
||
gap: 10px;
|
||
align-items: end;
|
||
min-height: 180px;
|
||
padding-top: 10px;
|
||
}
|
||
.hist-col {
|
||
display: grid;
|
||
gap: 8px;
|
||
align-items: end;
|
||
justify-items: center;
|
||
}
|
||
.hist-count {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
min-height: 16px;
|
||
}
|
||
.hist-track {
|
||
width: 100%;
|
||
min-height: 136px;
|
||
display: flex;
|
||
align-items: end;
|
||
justify-content: center;
|
||
}
|
||
.hist-bar {
|
||
width: min(42px, 100%);
|
||
border-radius: 8px 8px 0 0;
|
||
background: #8ea3ba;
|
||
min-height: 14px;
|
||
}
|
||
.hist-bar.subtle {
|
||
background: #9ca3af;
|
||
}
|
||
.hist-label {
|
||
text-align: center;
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
line-height: 1.3;
|
||
}
|
||
.bar-list { display: grid; gap: 12px; }
|
||
.bar-row {
|
||
display: grid;
|
||
gap: 10px;
|
||
grid-template-columns: 108px minmax(0, 1fr) 88px;
|
||
align-items: center;
|
||
}
|
||
.bar-name {
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.bar-track {
|
||
position: relative;
|
||
height: 16px;
|
||
border-radius: 999px;
|
||
background: rgba(17, 24, 39, 0.08);
|
||
overflow: hidden;
|
||
}
|
||
.bar-fill {
|
||
position: absolute;
|
||
inset: 0 auto 0 0;
|
||
border-radius: 999px;
|
||
background: #8ea3ba;
|
||
}
|
||
.bar-fill.ok {
|
||
background: #7c9488;
|
||
}
|
||
.bar-fill.subtle {
|
||
background: #9ca3af;
|
||
}
|
||
.bar-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
font-size: 12px;
|
||
color: #42505c;
|
||
margin-top: 6px;
|
||
}
|
||
.bar-value {
|
||
text-align: right;
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
font-weight: 700;
|
||
}
|
||
.runtime-state-grid {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
.timeline-chart {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.timeline-series { display: grid; gap: 8px; }
|
||
.series-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
font-size: 13px;
|
||
color: #42505c;
|
||
}
|
||
.timeline-track {
|
||
display: flex;
|
||
align-items: end;
|
||
gap: 2px;
|
||
min-height: 132px;
|
||
padding-top: 8px;
|
||
}
|
||
.timeline-bar {
|
||
flex: 1 1 0;
|
||
min-width: 4px;
|
||
border-radius: 6px 6px 0 0;
|
||
background: rgba(142, 163, 186, 0.28);
|
||
}
|
||
.timeline-bar.accent { background: rgba(142, 163, 186, 0.92); }
|
||
.timeline-bar.ok { background: rgba(124, 148, 136, 0.88); }
|
||
.timeline-bar.idle { background: rgba(179, 156, 118, 0.84); }
|
||
.timeline-bar.subtle { background: rgba(156, 163, 175, 0.82); }
|
||
.timeline-bar.err { background: rgba(17, 24, 39, 0.82); }
|
||
.timeline-labels {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 12px;
|
||
color: #42505c;
|
||
gap: 12px;
|
||
}
|
||
.legend { display: flex; gap: 14px; flex-wrap: wrap; color: #42505c; font-size: 13px; }
|
||
.legend span::before {
|
||
content: "";
|
||
display: inline-block;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 999px;
|
||
margin-right: 6px;
|
||
vertical-align: middle;
|
||
}
|
||
.legend .accent::before { background: rgba(142, 163, 186, 0.92); }
|
||
.legend .ok::before { background: rgba(124, 148, 136, 0.88); }
|
||
.legend .idle::before { background: rgba(179, 156, 118, 0.84); }
|
||
.legend .subtle::before { background: rgba(156, 163, 175, 0.82); }
|
||
.legend .err::before { background: rgba(17, 24, 39, 0.82); }
|
||
.table-wrap {
|
||
overflow: auto;
|
||
border: 2px solid #d8d1c7;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
}
|
||
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||
.table th, .table td {
|
||
padding: 13px 10px;
|
||
border-bottom: 1px solid var(--line);
|
||
text-align: left;
|
||
vertical-align: top;
|
||
}
|
||
.table th {
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
font-size: 12px;
|
||
}
|
||
.mono { font-family: var(--mono); font-size: 12px; }
|
||
.status {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
.status.ok { background: #cdd9d1; color: #304239; }
|
||
.status.warn { background: #e4d6c1; color: #5f4d31; }
|
||
.status.err { background: #d3d8de; color: #31404f; }
|
||
.muted { color: var(--muted); }
|
||
.failure-layout { display: grid; gap: 16px; }
|
||
.failure-bottom { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
.empty { color: var(--muted); padding: 20px 0; text-align: center; }
|
||
.control-bar {
|
||
display: grid;
|
||
gap: 14px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.action-feedback {
|
||
display: grid;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
padding: 14px 16px;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
}
|
||
.action-feedback.is-idle {
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
.action-feedback-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.action-feedback-title {
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
.action-feedback-title strong {
|
||
font-size: 14px;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.action-feedback-meta {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.action-feedback-grid {
|
||
display: grid;
|
||
gap: 10px;
|
||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||
}
|
||
.action-feedback-card {
|
||
display: grid;
|
||
gap: 8px;
|
||
padding: 12px 14px;
|
||
border-radius: 8px;
|
||
border: 0;
|
||
background: #f3f4f6;
|
||
}
|
||
.action-feedback-card.fail {
|
||
background: #dde1e6;
|
||
}
|
||
.action-feedback-card.ok {
|
||
background: #dbe5de;
|
||
}
|
||
.action-feedback-card.pending {
|
||
background: #ece2d2;
|
||
}
|
||
.action-feedback-worker {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.action-feedback-message {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
.action-feedback-command {
|
||
padding: 8px 10px;
|
||
border-radius: 8px;
|
||
border: 2px solid #d8d1c7;
|
||
background: #ffffff;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
}
|
||
.step-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.step-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 8px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
border: 0;
|
||
background: #ffffff;
|
||
}
|
||
.step-pill.ok {
|
||
color: #556c61;
|
||
background: #dbe5de;
|
||
}
|
||
.step-pill.fail {
|
||
color: #4f5b69;
|
||
background: #dde1e6;
|
||
}
|
||
.step-pill.pending {
|
||
color: #7d6d50;
|
||
background: #ece2d2;
|
||
}
|
||
.control-hint {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
padding: 14px 16px;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
}
|
||
.control-hint strong {
|
||
display: block;
|
||
font-size: 14px;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.control-hint .muted {
|
||
margin-top: 4px;
|
||
}
|
||
.selection-chip {
|
||
min-width: 120px;
|
||
padding: 10px 14px;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
background: #d3d8de;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
}
|
||
.action-grid {
|
||
display: grid;
|
||
gap: 10px;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
}
|
||
.action-card {
|
||
min-height: 58px;
|
||
padding: 14px 16px;
|
||
text-align: left;
|
||
border-radius: 8px;
|
||
}
|
||
.action-card .action-title {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.control-btn {
|
||
background: #76869a;
|
||
color: #fbfaf7;
|
||
}
|
||
.control-btn.subtle {
|
||
background: #efebe4;
|
||
color: #24303b;
|
||
}
|
||
.advanced-panel {
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
overflow: hidden;
|
||
}
|
||
.advanced-panel summary {
|
||
cursor: pointer;
|
||
list-style: none;
|
||
padding: 14px 16px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.advanced-panel summary::-webkit-details-marker {
|
||
display: none;
|
||
}
|
||
.advanced-panel[open] summary {
|
||
border-bottom: 2px solid #d8d1c7;
|
||
}
|
||
.advanced-panel-body {
|
||
display: grid;
|
||
gap: 12px;
|
||
padding: 14px 16px 16px;
|
||
}
|
||
.advanced-note {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.pipeline-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px 14px;
|
||
align-items: center;
|
||
}
|
||
.pipeline-option {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
}
|
||
.pipeline-option input {
|
||
margin: 0;
|
||
}
|
||
.pipeline-note {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.checkbox-cell {
|
||
width: 32px;
|
||
text-align: center;
|
||
}
|
||
.status-note {
|
||
display: grid;
|
||
gap: 4px;
|
||
min-width: 180px;
|
||
}
|
||
.analysis-panel {
|
||
display: grid;
|
||
gap: 20px;
|
||
}
|
||
.analysis-filter {
|
||
min-width: 220px;
|
||
}
|
||
.domain-header {
|
||
display: grid;
|
||
gap: 10px;
|
||
width: 100%;
|
||
}
|
||
.domain-title {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
}
|
||
.domain-metrics {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.metric-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
background: #f3f4f6;
|
||
font-size: 12px;
|
||
}
|
||
.subtype-table-wrap,
|
||
.detail-scroll {
|
||
max-height: 520px;
|
||
overflow: auto;
|
||
border: 2px solid #d8d1c7;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
}
|
||
.subtype-row {
|
||
cursor: pointer;
|
||
}
|
||
.subtype-row.active {
|
||
background: #e3e7eb;
|
||
}
|
||
.detail-summary {
|
||
display: grid;
|
||
gap: 10px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.detail-summary .summary-title {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.detail-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
.message-cell {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
min-width: 280px;
|
||
}
|
||
.tooltip-card {
|
||
position: fixed;
|
||
z-index: 9999;
|
||
max-width: 420px;
|
||
display: none;
|
||
padding: 14px 16px;
|
||
border-radius: 8px;
|
||
border: 2px solid #d8d1c7;
|
||
background: #ffffff;
|
||
box-shadow: none;
|
||
pointer-events: none;
|
||
}
|
||
.tooltip-card.visible {
|
||
display: block;
|
||
}
|
||
.tooltip-card strong {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
font-size: 14px;
|
||
}
|
||
.tooltip-list {
|
||
display: grid;
|
||
gap: 6px;
|
||
margin-top: 10px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.empty-card {
|
||
min-height: 280px;
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--muted);
|
||
}
|
||
.analytics-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
align-items: stretch;
|
||
}
|
||
.analytics-grid .panel,
|
||
.analytics-detail-card,
|
||
.analytics-section-card,
|
||
.analytics-job-card,
|
||
.analytics-breakdown-item,
|
||
.analytics-stat-card {
|
||
box-shadow: none;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
font-family: "Outfit", var(--font);
|
||
}
|
||
.analytics-grid .panel {
|
||
background: #ffffff;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.analytics-showcase {
|
||
background: #f3f4f6 !important;
|
||
}
|
||
.analytics-showcase::before,
|
||
.analytics-showcase::after {
|
||
content: "";
|
||
position: absolute;
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
.analytics-showcase::before {
|
||
width: 180px;
|
||
height: 180px;
|
||
right: -52px;
|
||
top: -48px;
|
||
border-radius: 24px;
|
||
background: rgba(59, 130, 246, 0.12);
|
||
transform: rotate(18deg);
|
||
}
|
||
.analytics-showcase::after {
|
||
width: 120px;
|
||
height: 120px;
|
||
left: -24px;
|
||
bottom: -32px;
|
||
background: rgba(245, 158, 11, 0.16);
|
||
border-radius: 999px;
|
||
}
|
||
.analytics-showcase .panel-head,
|
||
.analytics-showcase .panel-body {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.analytics-showcase .panel-head h2,
|
||
.analytics-detail-title h3,
|
||
.analytics-section-card h4 {
|
||
font-family: "Outfit", var(--font);
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.analytics-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
.analytics-actions button {
|
||
min-height: 48px;
|
||
border-radius: 8px;
|
||
border: 0;
|
||
padding: 11px 16px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.01em;
|
||
transition: transform 180ms ease, background-color 180ms ease, color 180ms ease;
|
||
box-shadow: none;
|
||
}
|
||
.analytics-actions button:hover {
|
||
transform: scale(1.03);
|
||
}
|
||
.analytics-actions button:focus-visible,
|
||
.analytics-field input:focus-visible,
|
||
.analytics-field select:focus-visible {
|
||
outline: 0;
|
||
box-shadow: 0 0 0 2px #fbfaf7, 0 0 0 4px #76869a;
|
||
}
|
||
.analytics-backfill-btn,
|
||
#analytics-backfill-btn {
|
||
background: #76869a;
|
||
color: #fbfaf7;
|
||
}
|
||
#analytics-refresh-btn,
|
||
#analytics-rebuild-btn,
|
||
#analytics-rebuild-inline-btn,
|
||
#analytics-clear-btn,
|
||
#analytics-prev-btn,
|
||
#analytics-next-btn {
|
||
background: #5f6875;
|
||
color: #fbfaf7;
|
||
}
|
||
.analytics-showcase-main {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
.analytics-overview-cards {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
}
|
||
.analytics-overview-card {
|
||
position: relative;
|
||
overflow: hidden;
|
||
min-height: 172px;
|
||
padding: 18px;
|
||
display: grid;
|
||
align-content: start;
|
||
gap: 10px;
|
||
transition: transform 180ms ease, background-color 180ms ease;
|
||
}
|
||
.analytics-overview-card:hover {
|
||
transform: scale(1.02);
|
||
}
|
||
.analytics-overview-card::after {
|
||
content: "";
|
||
position: absolute;
|
||
right: -24px;
|
||
bottom: -30px;
|
||
width: 88px;
|
||
height: 88px;
|
||
border-radius: 16px;
|
||
background: rgba(251, 250, 247, 0.18);
|
||
transform: rotate(18deg);
|
||
}
|
||
.analytics-overview-card.data {
|
||
background: #7d8fa4;
|
||
color: #fbfaf7;
|
||
}
|
||
.analytics-overview-card.pending {
|
||
background: #b9a788;
|
||
color: #24303b;
|
||
}
|
||
.analytics-overview-card.success {
|
||
background: #869c92;
|
||
color: #fbfaf7;
|
||
}
|
||
.analytics-overview-card.light {
|
||
background: #dde3e8;
|
||
color: #24303b;
|
||
}
|
||
.analytics-overview-card.severe {
|
||
background: #697280;
|
||
color: #fbfaf7;
|
||
}
|
||
.summary-card {
|
||
min-height: 164px;
|
||
}
|
||
.analytics-overview-card .eyebrow {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.12em;
|
||
opacity: 0.96;
|
||
}
|
||
.analytics-overview-card .value {
|
||
font-size: 34px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.04em;
|
||
line-height: 0.95;
|
||
}
|
||
.analytics-overview-card .hint {
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
opacity: 0.96;
|
||
}
|
||
.analytics-overview-breakdown {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 8px;
|
||
margin-top: auto;
|
||
}
|
||
.analytics-overview-breakdown-item {
|
||
background: rgba(251, 250, 247, 0.22);
|
||
border-radius: 8px;
|
||
padding: 8px 10px;
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
.analytics-overview-breakdown-item .label {
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
opacity: 0.94;
|
||
}
|
||
.analytics-overview-breakdown-item strong {
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.analytics-status-lanes {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
.analytics-status-lane {
|
||
padding: 16px;
|
||
background: #ffffff;
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.analytics-status-lane.pending {
|
||
background: #e7dccb;
|
||
}
|
||
.analytics-status-lane.success {
|
||
background: #d4ddd7;
|
||
}
|
||
.analytics-status-lane.light {
|
||
background: #d8dee5;
|
||
}
|
||
.analytics-status-lane.severe {
|
||
background: #d4d9df;
|
||
}
|
||
.analytics-status-lane strong {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
color: #24303b;
|
||
}
|
||
.analytics-lane-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
font-size: 12px;
|
||
color: #33404d;
|
||
}
|
||
.analytics-lane-track {
|
||
height: 12px;
|
||
border-radius: 999px;
|
||
overflow: hidden;
|
||
background: rgba(17, 24, 39, 0.1);
|
||
}
|
||
.analytics-lane-fill {
|
||
height: 100%;
|
||
border-radius: 999px;
|
||
background: #7c9488;
|
||
}
|
||
.analytics-status-lane.pending .analytics-lane-fill {
|
||
background: #b39c76;
|
||
}
|
||
.analytics-status-lane.light .analytics-lane-fill {
|
||
background: #8ea3ba;
|
||
}
|
||
.analytics-status-lane.severe .analytics-lane-fill {
|
||
background: #697280;
|
||
}
|
||
.analytics-jobs {
|
||
display: grid;
|
||
gap: 10px;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
padding-right: 4px;
|
||
scrollbar-gutter: stable;
|
||
}
|
||
.analytics-job-card {
|
||
display: grid;
|
||
gap: 8px;
|
||
padding: 14px;
|
||
background: #eef0f2;
|
||
}
|
||
.analytics-job-card.running {
|
||
background: #e7dccb;
|
||
}
|
||
.analytics-job-card.failed {
|
||
background: #d4d9df;
|
||
}
|
||
.analytics-job-card.completed {
|
||
background: #d4ddd7;
|
||
}
|
||
.analytics-job-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
}
|
||
.analytics-job-head strong {
|
||
font-size: 14px;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.analytics-job-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
color: #41505e;
|
||
}
|
||
.analytics-job-message {
|
||
font-size: 12px;
|
||
color: #41505e;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
.analytics-breakdown-list {
|
||
display: grid;
|
||
gap: 12px;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
padding-right: 4px;
|
||
scrollbar-gutter: stable;
|
||
}
|
||
.analytics-breakdown-item {
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 14px;
|
||
background: #d4d9df;
|
||
}
|
||
.analytics-breakdown-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
}
|
||
.analytics-breakdown-title {
|
||
display: grid;
|
||
gap: 4px;
|
||
min-width: 0;
|
||
}
|
||
.analytics-breakdown-title strong {
|
||
font-size: 14px;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.analytics-breakdown-code {
|
||
font-size: 12px;
|
||
color: #43505c;
|
||
word-break: break-word;
|
||
}
|
||
.analytics-breakdown-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
font-size: 12px;
|
||
color: #43505c;
|
||
}
|
||
.analytics-chart-panel .panel-body {
|
||
display: block;
|
||
}
|
||
.analytics-side-panel {
|
||
display: grid;
|
||
grid-template-rows: auto minmax(0, 1fr);
|
||
height: 460px;
|
||
min-height: 460px;
|
||
align-self: stretch;
|
||
}
|
||
.analytics-side-panel .panel-body {
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
display: grid;
|
||
}
|
||
.analytics-toolbar {
|
||
display: grid;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.analytics-toolbar-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: flex-end;
|
||
flex-wrap: wrap;
|
||
}
|
||
.analytics-filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: flex-end;
|
||
}
|
||
.analytics-field {
|
||
display: grid;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: #43505c;
|
||
}
|
||
.analytics-field input,
|
||
.analytics-field select {
|
||
min-width: 132px;
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
background: #f3f4f6;
|
||
border: 2px solid transparent;
|
||
box-shadow: none;
|
||
}
|
||
.analytics-field input:focus,
|
||
.analytics-field select:focus {
|
||
background: #ffffff;
|
||
border-color: #76869a;
|
||
outline: 0;
|
||
}
|
||
.analytics-field input[type="search"] {
|
||
min-width: 240px;
|
||
}
|
||
.analytics-layout {
|
||
display: grid;
|
||
gap: 18px;
|
||
grid-template-columns: minmax(0, 1.08fr) minmax(360px, 0.92fr);
|
||
align-items: start;
|
||
}
|
||
.analytics-list-wrap {
|
||
max-height: 860px;
|
||
}
|
||
.analytics-table tbody tr {
|
||
cursor: pointer;
|
||
transition: background-color 140ms ease, transform 140ms ease;
|
||
}
|
||
.analytics-table tbody tr:hover {
|
||
background: #dbe1e7;
|
||
}
|
||
.analytics-table tbody tr.active {
|
||
background: #cfd8e1;
|
||
}
|
||
.analytics-cell-title {
|
||
display: grid;
|
||
gap: 4px;
|
||
min-width: 180px;
|
||
}
|
||
.analytics-cell-title strong {
|
||
font-size: 14px;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.analytics-pager {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-top: 12px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.analytics-detail {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
.analytics-detail-card {
|
||
background: #f3f4f6;
|
||
}
|
||
.analytics-detail-card .panel-head {
|
||
padding-bottom: 12px;
|
||
}
|
||
.analytics-detail-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
flex-wrap: wrap;
|
||
}
|
||
.analytics-detail-title {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
.analytics-detail-title h3 {
|
||
margin: 0;
|
||
font-size: 22px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.analytics-detail-badges {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.analytics-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 7px 12px;
|
||
border-radius: 999px;
|
||
border: 0;
|
||
background: #ffffff;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
.analytics-tag.success {
|
||
color: #304239;
|
||
background: #cdd9d1;
|
||
}
|
||
.analytics-tag.light {
|
||
color: #3f556f;
|
||
background: #d6dde5;
|
||
}
|
||
.analytics-tag.severe {
|
||
color: #31404f;
|
||
background: #d3d8de;
|
||
}
|
||
.analytics-tag.retryable {
|
||
color: #5f4d31;
|
||
background: #e4d6c1;
|
||
}
|
||
.analytics-tag.non-retryable {
|
||
color: #31404f;
|
||
background: #d3d8de;
|
||
}
|
||
.analytics-tag.neutral {
|
||
color: #3f4c59;
|
||
background: #dfe3e7;
|
||
}
|
||
.analytics-stat-grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||
}
|
||
.analytics-stat-card {
|
||
padding: 14px 14px 12px;
|
||
background: #ffffff;
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
.analytics-stat-card .label {
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--muted);
|
||
}
|
||
.analytics-stat-card .value {
|
||
font-size: 24px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.analytics-stack {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
.analytics-section-card {
|
||
background: #ffffff;
|
||
padding: 16px;
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.analytics-section-card h4 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.analytics-component-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.analytics-component-row {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.analytics-component-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.analytics-component-meta {
|
||
font-size: 12px;
|
||
color: #43505c;
|
||
}
|
||
.analytics-chip-list {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.analytics-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 7px 11px;
|
||
border-radius: 999px;
|
||
border: 0;
|
||
background: #f3f4f6;
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
}
|
||
.analytics-chip.alert {
|
||
color: #31404f;
|
||
background: #d3d8de;
|
||
}
|
||
.analytics-domain-table {
|
||
max-height: 380px;
|
||
overflow: auto;
|
||
border-radius: 8px;
|
||
border: 2px solid #d8d1c7;
|
||
background: #ffffff;
|
||
}
|
||
.analytics-empty {
|
||
min-height: 420px;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 18px;
|
||
border-radius: 8px;
|
||
border: 2px dashed #d1d5db;
|
||
background: #f9fafb;
|
||
color: #6b7280;
|
||
text-align: center;
|
||
}
|
||
@media (max-width: 1080px) {
|
||
.main, .chart-grid { grid-template-columns: 1fr; }
|
||
.overview-grid { grid-template-columns: 1fr; }
|
||
.daily-collection-grid { grid-template-columns: 1fr; }
|
||
.title-row { grid-template-columns: 1fr; }
|
||
.toolbar { justify-content: flex-start; }
|
||
.toolbar-wrap { justify-items: start; }
|
||
.metric-card { min-height: 0; }
|
||
.failure-bottom, .stats-grid, .stat-metrics { grid-template-columns: 1fr; }
|
||
.analytics-grid,
|
||
.analytics-layout,
|
||
.analytics-status-lanes { grid-template-columns: 1fr; }
|
||
.analytics-side-panel {
|
||
height: auto;
|
||
min-height: 0;
|
||
}
|
||
.span-two { grid-column: auto; }
|
||
}
|
||
@media (max-width: 760px) {
|
||
.shell { width: min(100vw - 20px, 100%); }
|
||
.hero { padding: 22px 18px; }
|
||
.panel-head, .panel-body { padding-left: 16px; padding-right: 16px; }
|
||
.bar-row { grid-template-columns: 96px minmax(0, 1fr) 44px; }
|
||
.analytics-field input[type="search"],
|
||
.analytics-field input,
|
||
.analytics-field select {
|
||
min-width: 100%;
|
||
}
|
||
.analytics-toolbar-row,
|
||
.analytics-actions,
|
||
.analytics-filters,
|
||
.analytics-pager,
|
||
.analytics-detail-header {
|
||
align-items: stretch;
|
||
}
|
||
.analytics-overview-cards,
|
||
.analytics-overview-breakdown,
|
||
.analytics-status-lanes {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="shell">
|
||
<section class="hero">
|
||
<div class="eyebrow">Control Center / {{ instance_name }}</div>
|
||
<div class="title-row">
|
||
<div>
|
||
<h1>Worker Monitoring</h1>
|
||
</div>
|
||
<div class="toolbar-wrap">
|
||
<div class="toolbar">
|
||
<select id="analysis-worker-select" class="analysis-filter">
|
||
<option value="">全部 Worker</option>
|
||
</select>
|
||
<input id="date-picker" type="date" value="{{ default_date }}">
|
||
<button id="today-btn" type="button">回到今天</button>
|
||
<button id="reset-btn" class="danger-btn" type="button">重置看板</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="hero-summary">
|
||
<span class="context-chip" id="dashboard-context-instance">实例 {{ instance_name }} / Redis DB {{ redis_db }}</span>
|
||
<span class="context-chip" id="dashboard-context-date">日期 {{ default_date }}</span>
|
||
<span class="context-chip" id="dashboard-context-worker">范围 全部 Worker</span>
|
||
<span class="context-chip" id="dashboard-context-refresh">今日视图自动刷新</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel snapshot-panel">
|
||
<div class="panel-head">
|
||
<h2>运行快照</h2>
|
||
</div>
|
||
<div class="panel-body snapshot-body">
|
||
<section class="grid primary-metrics" id="summary-grid"></section>
|
||
</div>
|
||
</section>
|
||
|
||
<nav class="view-switcher" aria-label="Dashboard Views">
|
||
<button class="view-tab active" type="button" data-view="overview">
|
||
<span class="view-tab-label">
|
||
<strong>总览</strong>
|
||
</span>
|
||
</button>
|
||
<button class="view-tab" type="button" data-view="analytics">
|
||
<span class="view-tab-label">
|
||
<strong>应用分析</strong>
|
||
</span>
|
||
<span class="view-tab-badge" id="tab-badge-analytics">0</span>
|
||
</button>
|
||
<button class="view-tab" type="button" data-view="failures">
|
||
<span class="view-tab-label">
|
||
<strong>故障分析</strong>
|
||
</span>
|
||
<span class="view-tab-badge" id="tab-badge-failures">0</span>
|
||
</button>
|
||
<button class="view-tab" type="button" data-view="workers">
|
||
<span class="view-tab-label">
|
||
<strong>Worker 操作</strong>
|
||
</span>
|
||
<span class="view-tab-badge" id="tab-badge-workers">0</span>
|
||
</button>
|
||
<button class="view-tab" type="button" data-view="tasks">
|
||
<span class="view-tab-label">
|
||
<strong>任务记录</strong>
|
||
</span>
|
||
<span class="view-tab-badge" id="tab-badge-tasks">0</span>
|
||
</button>
|
||
</nav>
|
||
|
||
<section class="view-panel" data-view-panel="overview">
|
||
<section class="section-title">
|
||
<h2>整体效率</h2>
|
||
</section>
|
||
<section class="panel daily-collection-panel">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>今日采集概况</h2>
|
||
<div class="muted" id="daily-collection-note">--</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="daily-collection-grid" id="daily-collection-grid"></div>
|
||
</div>
|
||
</section>
|
||
<section class="grid overview-grid">
|
||
<section class="panel">
|
||
<div class="panel-head">
|
||
<h2>整体运行时间构成</h2>
|
||
<div class="muted" id="time-composition-note">--</div>
|
||
</div>
|
||
<div class="panel-body" id="time-composition"></div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-head">
|
||
<h2>合格任务阶段耗时</h2>
|
||
<div class="muted" id="success-stage-note">--</div>
|
||
</div>
|
||
<div class="panel-body" id="success-stage-breakdown"></div>
|
||
</section>
|
||
|
||
<section class="panel span-two">
|
||
<div class="panel-head">
|
||
<h2>运行状态时间分布</h2>
|
||
<div class="muted" id="runtime-state-note">--</div>
|
||
</div>
|
||
<div class="panel-body" id="runtime-state-breakdown"></div>
|
||
</section>
|
||
|
||
<section class="panel span-two">
|
||
<div class="panel-head">
|
||
<h2>各 Worker 采集占比</h2>
|
||
<div class="muted" id="worker-collect-share-note">--</div>
|
||
</div>
|
||
<div class="panel-body" id="worker-collect-share"></div>
|
||
</section>
|
||
</section>
|
||
</section>
|
||
|
||
<section class="view-panel is-hidden" data-view-panel="analytics">
|
||
<section class="section-title">
|
||
<h2>应用分析</h2>
|
||
</section>
|
||
<section class="grid analytics-grid">
|
||
<section class="panel span-two analytics-showcase">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>应用画像总览</h2>
|
||
<div class="muted" id="analytics-overview-note">--</div>
|
||
</div>
|
||
<div class="analytics-actions">
|
||
<button id="analytics-backfill-btn" type="button">全量回灌</button>
|
||
<button id="analytics-refresh-btn" type="button">刷新分析</button>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="analytics-toolbar">
|
||
<div class="analytics-toolbar-row">
|
||
<div class="analytics-filters">
|
||
<label class="analytics-field">
|
||
<span>增量批次</span>
|
||
<select id="analytics-batch-filter">
|
||
<option value="">全部 active 应用</option>
|
||
</select>
|
||
</label>
|
||
<label class="analytics-field">
|
||
<span>榜单 TopN</span>
|
||
<input id="analytics-top-n" type="number" min="1" step="1" placeholder="3000">
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="analytics-showcase-main">
|
||
<div class="analytics-overview-breakdown" id="analytics-topn-summary"></div>
|
||
<div class="analytics-status-lanes" id="analytics-status-lanes"></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel analytics-side-panel">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>分析任务</h2>
|
||
<div class="muted" id="analytics-job-note">--</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="analytics-jobs" id="analytics-jobs"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel analytics-side-panel">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>不可重试错误</h2>
|
||
<div class="muted" id="analytics-non-retryable-note">--</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="analytics-breakdown-list" id="analytics-non-retryable-breakdown"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel span-two analytics-chart-panel">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>不可重试应用下载量分布</h2>
|
||
<div class="muted" id="analytics-non-retryable-download-note">--</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body" id="analytics-non-retryable-download-distribution"></div>
|
||
</section>
|
||
|
||
<section class="panel span-two" id="analytics-list-panel">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>应用列表与详情</h2>
|
||
<div class="muted" id="analytics-list-note">--</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="analytics-toolbar">
|
||
<div class="analytics-toolbar-row">
|
||
<div class="analytics-filters">
|
||
<label class="analytics-field">
|
||
<span>搜索应用</span>
|
||
<input id="analytics-search" type="search" placeholder="应用名 / 包名">
|
||
</label>
|
||
<label class="analytics-field">
|
||
<span>当前状态</span>
|
||
<select id="analytics-collection-filter">
|
||
<option value="">全部</option>
|
||
<option value="pending">待分发</option>
|
||
<option value="qualified">合格</option>
|
||
<option value="failed_terminal">严重受限不可重试</option>
|
||
</select>
|
||
</label>
|
||
<label class="analytics-field">
|
||
<span>排序</span>
|
||
<select id="analytics-sort">
|
||
<option value="source_order">榜单排名</option>
|
||
<option value="updated_at">最近更新</option>
|
||
<option value="self_ratio">独占流量占比</option>
|
||
<option value="recognition_ratio">识别流量占比</option>
|
||
<option value="unique_domain_count">域名数</option>
|
||
<option value="latest_status">任务状态</option>
|
||
</select>
|
||
</label>
|
||
<label class="analytics-field">
|
||
<span>顺序</span>
|
||
<select id="analytics-order">
|
||
<option value="desc">降序</option>
|
||
<option value="asc">升序</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="analytics-actions">
|
||
<button id="analytics-clear-btn" type="button">清空筛选</button>
|
||
<button id="analytics-rebuild-btn" type="button">重算当前应用</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="action-feedback is-idle" id="analytics-action-feedback"></div>
|
||
<div class="analytics-layout">
|
||
<div>
|
||
<div class="table-wrap analytics-list-wrap">
|
||
<table class="table analytics-table">
|
||
<thead>
|
||
<tr>
|
||
<th>应用</th>
|
||
<th>执行设备</th>
|
||
<th>当前状态</th>
|
||
<th>分析结论</th>
|
||
<th>任务状态</th>
|
||
<th>独占流量占比</th>
|
||
<th>二级域名</th>
|
||
<th>UTG 节点</th>
|
||
<th>更新时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="analytics-apps-body"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="analytics-pager">
|
||
<div id="analytics-page-note">--</div>
|
||
<div class="analytics-actions">
|
||
<button id="analytics-prev-btn" type="button">上一页</button>
|
||
<button id="analytics-next-btn" type="button">下一页</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="analytics-detail"></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
</section>
|
||
|
||
<section class="view-panel is-hidden" data-view-panel="failures">
|
||
<section class="section-title">
|
||
<h2>故障分析</h2>
|
||
</section>
|
||
<section class="panel">
|
||
<div class="panel-head">
|
||
<h2>失败时间去向</h2>
|
||
<div class="muted" id="failure-domain-note">--</div>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="container-fluid p-0">
|
||
<div class="row g-4">
|
||
<div class="col-12 col-lg-5">
|
||
<div class="analysis-panel">
|
||
<div id="failure-domain-breakdown"></div>
|
||
<div id="non-success-distribution"></div>
|
||
</div>
|
||
</div>
|
||
<div class="col-12 col-lg-7">
|
||
<div class="card shadow-sm rounded-4">
|
||
<div class="card-header">
|
||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<div>
|
||
<h3 class="mb-0">失败详情</h3>
|
||
</div>
|
||
<div class="text-muted small" id="failure-detail-note">--</div>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="failure-detail-container"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
|
||
<section class="view-panel is-hidden" data-view-panel="workers">
|
||
<section class="section-title">
|
||
<h2>Worker 操作台</h2>
|
||
</section>
|
||
<section class="panel">
|
||
<div class="panel-head">
|
||
<h2>Worker 明细</h2>
|
||
<div class="muted" id="worker-count">--</div>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="control-bar">
|
||
<div class="control-hint">
|
||
<div>
|
||
<strong>先选 Worker,再点动作</strong>
|
||
</div>
|
||
<div class="selection-chip muted" id="worker-selection-note">未选择 Worker</div>
|
||
</div>
|
||
<div class="action-grid">
|
||
<button id="worker-run-btn" class="control-btn action-card" type="button" data-worker-action="run_pipeline">
|
||
<span class="action-title">执行默认链路</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="start">
|
||
<span class="action-title">直接启动</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="drain">
|
||
<span class="action-title">完成当前任务后停机</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="enable">
|
||
<span class="action-title">恢复接单</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="disable">
|
||
<span class="action-title">禁止接单</span>
|
||
</button>
|
||
</div>
|
||
<details class="advanced-panel">
|
||
<summary>高级维护</summary>
|
||
<div class="advanced-panel-body">
|
||
<div class="action-grid">
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="recover_and_start">
|
||
<span class="action-title">完整恢复后启动</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="recover_mumu_full">
|
||
<span class="action-title">完整恢复MuMu镜像</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="restart_mumu">
|
||
<span class="action-title">仅重启 MuMu</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="pull">
|
||
<span class="action-title">仅拉取代码</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="pull_pcap_files">
|
||
<span class="action-title">拉取 PCAP 文件</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="stop_worker">
|
||
<span class="action-title">强制停止 Worker 进程</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="status">
|
||
<span class="action-title">采集当前状态</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="setup">
|
||
<span class="action-title">执行 Setup</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="clone">
|
||
<span class="action-title">克隆仓库</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="reboot">
|
||
<span class="action-title">重启主机</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="fix_adb_connection">
|
||
<span class="action-title">修复 ADB 连接</span>
|
||
</button>
|
||
<button class="control-btn subtle action-card" type="button" data-worker-action="configure_mumu_network">
|
||
<span class="action-title">配置 MuMu 网络</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<details class="advanced-panel">
|
||
<summary>修改默认链路</summary>
|
||
<div class="advanced-panel-body">
|
||
<div class="pipeline-bar">
|
||
<label class="pipeline-option"><input type="checkbox" data-run-pipeline="stop_worker" checked>停止 Worker 进程</label>
|
||
<label class="pipeline-option"><input type="checkbox" data-run-pipeline="clone_if_missing">缺仓时克隆</label>
|
||
<label class="pipeline-option"><input type="checkbox" data-run-pipeline="git_pull" checked>拉取代码</label>
|
||
<label class="pipeline-option"><input type="checkbox" data-run-pipeline="setup">执行 Setup</label>
|
||
<label class="pipeline-option"><input type="checkbox" data-run-pipeline="pull_pcap_files" checked>预拉取 PCAP 文件</label>
|
||
<label class="pipeline-option"><input type="checkbox" data-run-pipeline="recover_mumu">恢复 MuMu 镜像</label>
|
||
<label class="pipeline-option"><input type="checkbox" data-run-pipeline="restart_mumu" checked>重启 MuMu</label>
|
||
<label class="pipeline-option"><input type="checkbox" data-run-pipeline="start_worker" checked>启动 batch_run</label>
|
||
</div>
|
||
<div class="pipeline-note">默认只做常用起机链路,不做镜像恢复。</div>
|
||
</div>
|
||
</details>
|
||
<details class="advanced-panel" open>
|
||
<summary>批量命令执行</summary>
|
||
<div class="advanced-panel-body">
|
||
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
|
||
<input id="execute-command-input" type="text" placeholder="输入要在选中 Worker 上执行的 CMD 或 PowerShell 命令" style="flex: 1; border: 1px solid #d8d1c7; padding: 10px 14px; border-radius: 8px; font-family: var(--mono); font-size: 14px;">
|
||
<button class="control-btn action-card" type="button" data-worker-action="execute_command" style="min-width: 120px;">
|
||
<span class="action-title">执行命令</span>
|
||
</button>
|
||
</div>
|
||
<div class="pipeline-note">注意:该命令将通过 SSH 直接下发到选中的 Worker 执行。默认使用 cmd.exe。若要使用 powershell,请显式调用。</div>
|
||
</div>
|
||
</details>
|
||
<details class="advanced-panel" open>
|
||
<summary>强制重新下载 APK</summary>
|
||
<div class="advanced-panel-body">
|
||
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
|
||
<input id="force-reapk-input" type="text" placeholder="输入包名,清除所有缓存后从US重新下载" style="flex: 1; border: 1px solid #d8d1c7; padding: 10px 14px; border-radius: 8px; font-family: var(--mono); font-size: 14px;">
|
||
<button class="control-btn action-card" type="button" data-worker-action="force_reapk" style="min-width: 140px;">
|
||
<span class="action-title">强制重下APK</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="action-feedback is-idle" id="worker-action-feedback">最近一次操作反馈会显示在这里。</div>
|
||
<div class="table-wrap">
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th class="checkbox-cell"><input id="workers-select-all" type="checkbox"></th>
|
||
<th>Worker</th>
|
||
<th>设备类型</th>
|
||
<th>状态</th>
|
||
<th>控制</th>
|
||
<th>分发</th>
|
||
<th>任务数</th>
|
||
<th>平均下载</th>
|
||
<th>平均采集</th>
|
||
<th>运行时长</th>
|
||
<th>空闲时长</th>
|
||
<th>失败数</th>
|
||
<th>最后任务</th>
|
||
<th>最近动作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="workers-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
|
||
<section class="view-panel is-hidden" data-view-panel="tasks">
|
||
<section class="section-title">
|
||
<h2>任务记录</h2>
|
||
<div class="muted">只保留近期任务,查任务状态和失败归因时不会被其他图表打断。</div>
|
||
</section>
|
||
<section class="panel">
|
||
<div class="panel-head">
|
||
<h2>近期任务</h2>
|
||
<div class="muted" id="task-count">--</div>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="table-wrap">
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th>任务</th>
|
||
<th>Worker</th>
|
||
<th>状态</th>
|
||
<th>下载耗时</th>
|
||
<th>采集耗时</th>
|
||
<th>总耗时</th>
|
||
<th>失败归因</th>
|
||
<th>失败原因</th>
|
||
<th>过程标记</th>
|
||
<th>开始时间</th>
|
||
<th>结束时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tasks-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
</main>
|
||
|
||
<div id="analysis-tooltip-anchor"></div>
|
||
<script src="/static/vendor/bootstrap/bootstrap.bundle.min.js"></script>
|
||
<script>
|
||
const workerActionLabels = {
|
||
run_pipeline: "执行默认链路",
|
||
start: "直接启动",
|
||
drain: "完成当前任务后停机",
|
||
enable: "恢复接单",
|
||
disable: "禁止接单",
|
||
recover_and_start: "完整恢复后启动",
|
||
restart_mumu: "仅重启 MuMu",
|
||
pull: "仅拉取代码",
|
||
pull_pcap_files: "拉取 PCAP 文件",
|
||
stop_worker: "强制停止 Worker 进程",
|
||
status: "采集当前状态",
|
||
setup: "执行 Setup",
|
||
clone: "克隆仓库",
|
||
reboot: "重启主机",
|
||
execute_command: "执行自定义命令",
|
||
fix_adb_connection: "修复 ADB 连接",
|
||
configure_mumu_network: "配置 MuMu 网络",
|
||
recover_mumu_full: "完整恢复MuMu镜像",
|
||
force_reapk: "强制重下APK",
|
||
};
|
||
const DASHBOARD_UI_STATE_KEY = "autool-dashboard-ui-state-v1";
|
||
|
||
let currentAnalysisWorkerId = "";
|
||
let selectedFailureDomain = "";
|
||
let selectedFailureSubtype = "";
|
||
let activeFailureTab = "subtypes";
|
||
let activeView = "overview";
|
||
let selectedWorkerSet = new Set();
|
||
let refreshRequestSeq = 0;
|
||
let activeRefreshController = null;
|
||
let activeWorkerActionJobId = "";
|
||
let selectedAnalyticsPackage = "";
|
||
let analyticsPage = 1;
|
||
let analyticsSearch = "";
|
||
let analyticsCollectionStatus = "";
|
||
let analyticsIncrementalBatchTag = "";
|
||
let analyticsTopN = 3000;
|
||
let analyticsSort = "updated_at";
|
||
let analyticsOrder = "desc";
|
||
const analyticsDetailCache = new Map();
|
||
let analyticsDetailRequestSeq = 0;
|
||
let activeAnalyticsDetailController = null;
|
||
|
||
const datePicker = document.getElementById("date-picker");
|
||
const todayBtn = document.getElementById("today-btn");
|
||
const resetBtn = document.getElementById("reset-btn");
|
||
const analysisWorkerSelect = document.getElementById("analysis-worker-select");
|
||
const selectAllWorkers = document.getElementById("workers-select-all");
|
||
const workerSelectionNote = document.getElementById("worker-selection-note");
|
||
const workerActionButtons = Array.from(document.querySelectorAll("button[data-worker-action]"));
|
||
const workerActionFeedback = document.getElementById("worker-action-feedback");
|
||
const viewButtons = Array.from(document.querySelectorAll("button[data-view]"));
|
||
const viewPanels = Array.from(document.querySelectorAll("[data-view-panel]"));
|
||
const contextDateChip = document.getElementById("dashboard-context-date");
|
||
const contextWorkerChip = document.getElementById("dashboard-context-worker");
|
||
const contextRefreshChip = document.getElementById("dashboard-context-refresh");
|
||
const defaultRunPipelineOptions = {{ run_pipeline_defaults | safe }};
|
||
const runPipelineOptionNodes = Array.from(document.querySelectorAll("input[data-run-pipeline]"));
|
||
const analyticsBackfillBtn = document.getElementById("analytics-backfill-btn");
|
||
const analyticsRefreshBtn = document.getElementById("analytics-refresh-btn");
|
||
const analyticsClearBtn = document.getElementById("analytics-clear-btn");
|
||
const analyticsRebuildBtn = document.getElementById("analytics-rebuild-btn");
|
||
const analyticsPrevBtn = document.getElementById("analytics-prev-btn");
|
||
const analyticsNextBtn = document.getElementById("analytics-next-btn");
|
||
const analyticsSearchInput = document.getElementById("analytics-search");
|
||
const analyticsCollectionSelect = document.getElementById("analytics-collection-filter");
|
||
const analyticsBatchSelect = document.getElementById("analytics-batch-filter");
|
||
const analyticsTopNInput = document.getElementById("analytics-top-n");
|
||
const analyticsSortSelect = document.getElementById("analytics-sort");
|
||
const analyticsOrderSelect = document.getElementById("analytics-order");
|
||
const analyticsActionFeedback = document.getElementById("analytics-action-feedback");
|
||
const analyticsSortOptions = new Set(["source_order", "updated_at", "self_ratio", "recognition_ratio", "unique_domain_count", "latest_status", "collection_status"]);
|
||
|
||
const todayStr = () => {
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||
const day = String(now.getDate()).padStart(2, "0");
|
||
return `${year}-${month}-${day}`;
|
||
};
|
||
|
||
const formatSeconds = (value) => {
|
||
const total = Number(value || 0);
|
||
if (!Number.isFinite(total) || total <= 0) return "0m";
|
||
const totalMinutes = total / 60;
|
||
if (totalMinutes >= 60) {
|
||
const hours = Math.floor(totalMinutes / 60);
|
||
const minutes = Math.round(totalMinutes % 60);
|
||
return `${hours}h ${minutes}m`;
|
||
}
|
||
if (totalMinutes >= 10) return `${Math.round(totalMinutes)}m`;
|
||
return `${totalMinutes.toFixed(1)}m`;
|
||
};
|
||
|
||
const statusBadge = (value) => {
|
||
const normalized = (value || "").toLowerCase();
|
||
const cls = normalized === "running" ? "warn"
|
||
: normalized === "drain" ? "warn"
|
||
: normalized === "failed" ? "err"
|
||
: normalized === "stopped" || normalized === "offline" ? "err"
|
||
: normalized === "download" || normalized === "collect" ? "warn"
|
||
: normalized === "disabled" ? "err"
|
||
: "ok";
|
||
return `<span class="status ${cls}">${value || "--"}</span>`;
|
||
};
|
||
|
||
const formatDateTime = (value) => {
|
||
const ts = Number(value || 0);
|
||
if (!Number.isFinite(ts) || ts <= 0) return "--";
|
||
return new Date(ts * 1000).toLocaleString("zh-CN", { hour12: false });
|
||
};
|
||
|
||
const normalizeAnalyticsTopN = (value) => {
|
||
const parsed = Number.parseInt(String(value == null ? "" : value).trim(), 10);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 3000;
|
||
};
|
||
|
||
const formatAnalyticsRank = (sourceOrder) => {
|
||
const parsed = Number(sourceOrder);
|
||
if (!Number.isFinite(parsed) || parsed < 0) return "未入榜";
|
||
return `榜单 #${parsed + 1}`;
|
||
};
|
||
|
||
const buildAnalyticsScopeLabel = (batchTag, topNLabel) => {
|
||
const parts = [];
|
||
parts.push(batchTag ? `批次 ${batchTag}` : "全部 active 应用");
|
||
if (topNLabel) {
|
||
parts.push(topNLabel);
|
||
}
|
||
return parts.join(" · ");
|
||
};
|
||
|
||
const formatBytes = (value) => {
|
||
const bytes = Number(value || 0);
|
||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
let current = bytes;
|
||
let unitIndex = 0;
|
||
while (current >= 1024 && unitIndex < units.length - 1) {
|
||
current /= 1024;
|
||
unitIndex += 1;
|
||
}
|
||
if (unitIndex === 0) return `${Math.round(current)} ${units[unitIndex]}`;
|
||
if (current >= 10) return `${current.toFixed(1)} ${units[unitIndex]}`;
|
||
return `${current.toFixed(2)} ${units[unitIndex]}`;
|
||
};
|
||
|
||
const collectionStatusLabels = {
|
||
pending: "待分发",
|
||
qualified: "合格",
|
||
failed_terminal: "严重受限不可重试",
|
||
};
|
||
|
||
const restrictionStatusLabels = {
|
||
success: "成功",
|
||
light_restricted: "轻度受限",
|
||
severe_restricted: "严重受限",
|
||
};
|
||
|
||
const retryabilityLabels = {
|
||
retryable: "可重试",
|
||
non_retryable: "不可重试",
|
||
not_applicable: "不适用",
|
||
};
|
||
|
||
const latestStatusLabels = {
|
||
success: "SUCCESS",
|
||
failed: "FAILED",
|
||
stop: "STOP",
|
||
unknown: "UNKNOWN",
|
||
};
|
||
|
||
const deviceTypeLabels = {
|
||
emulator: "模拟器",
|
||
physical: "真机",
|
||
any: "真机/模拟器",
|
||
};
|
||
|
||
function analyticsTag(label, tone = "neutral") {
|
||
return `<span class="analytics-tag ${tone}">${escapeHtml(label || "--")}</span>`;
|
||
}
|
||
|
||
function analyticsRestrictionTag(value) {
|
||
const normalized = String(value || "").trim();
|
||
if (!normalized) return analyticsTag("未分类");
|
||
if (normalized === "success") return analyticsTag(restrictionStatusLabels[normalized], "success");
|
||
if (normalized === "light_restricted") return analyticsTag(restrictionStatusLabels[normalized], "light");
|
||
if (normalized === "severe_restricted") return analyticsTag(restrictionStatusLabels[normalized], "severe");
|
||
return analyticsTag(normalized);
|
||
}
|
||
|
||
function analyticsCollectionTag(value) {
|
||
const normalized = String(value || "").trim();
|
||
if (!normalized || normalized === "pending") return analyticsTag(collectionStatusLabels.pending, "light");
|
||
if (normalized === "qualified") return analyticsTag(collectionStatusLabels.qualified, "success");
|
||
if (normalized === "failed_terminal") return analyticsTag(collectionStatusLabels.failed_terminal, "severe");
|
||
return analyticsTag(normalized);
|
||
}
|
||
|
||
function analyticsRetryabilityTag(value) {
|
||
const normalized = String(value || "").trim();
|
||
if (!normalized || normalized === "not_applicable") return analyticsTag(retryabilityLabels.not_applicable, "neutral");
|
||
if (normalized === "retryable") return analyticsTag(retryabilityLabels[normalized], "retryable");
|
||
if (normalized === "non_retryable") return analyticsTag(retryabilityLabels[normalized], "non-retryable");
|
||
return analyticsTag(normalized);
|
||
}
|
||
|
||
function analyticsLatestStatusTag(value) {
|
||
const normalized = String(value || "unknown").toLowerCase();
|
||
const label = latestStatusLabels[normalized] || normalized.toUpperCase();
|
||
return statusBadge(label);
|
||
}
|
||
|
||
function analyticsArtifactTag(value) {
|
||
const normalized = String(value || "").trim();
|
||
if (normalized === "complete") return analyticsTag("产物完整", "success");
|
||
if (normalized === "partial") return analyticsTag("产物部分缺失", "light");
|
||
if (normalized === "missing") return analyticsTag("产物缺失", "severe");
|
||
return analyticsTag(normalized || "未知", "neutral");
|
||
}
|
||
|
||
function deviceTypeTag(value) {
|
||
const normalized = String(value || "").trim().toLowerCase();
|
||
if (!normalized) return analyticsTag("未标记", "neutral");
|
||
if (normalized === "physical") return analyticsTag(deviceTypeLabels[normalized], "success");
|
||
if (normalized === "emulator") return analyticsTag(deviceTypeLabels[normalized], "light");
|
||
if (normalized === "any") return analyticsTag(deviceTypeLabels[normalized], "neutral");
|
||
return analyticsTag(normalized, "neutral");
|
||
}
|
||
|
||
function toSecondLevelDomain(domain) {
|
||
const normalized = String(domain || "").trim().toLowerCase();
|
||
if (!normalized || normalized.startsWith("model_data:")) return "";
|
||
let cleaned = normalized;
|
||
if (cleaned.startsWith("https://")) {
|
||
cleaned = cleaned.slice(8);
|
||
} else if (cleaned.startsWith("http://")) {
|
||
cleaned = cleaned.slice(7);
|
||
}
|
||
cleaned = cleaned.trim().replace(/^["']|["']$/g, "");
|
||
const parts = cleaned.split(".").filter(Boolean);
|
||
if (!parts.length) return "";
|
||
if (parts.length <= 2) return cleaned;
|
||
return `*.${parts.slice(-2).join(".")}`;
|
||
}
|
||
|
||
function renderAnalyticsDomainTag(matchState) {
|
||
if (matchState === "self") return analyticsTag("自有域名", "success");
|
||
if (matchState === "server") return analyticsTag("第三方域名", "light");
|
||
if (matchState === "unmatched") return analyticsTag("未匹配", "severe");
|
||
if (matchState === "ip") return analyticsTag("IP 流量", "neutral");
|
||
return analyticsTag("待识别");
|
||
}
|
||
|
||
const selectedWorkerIds = () => Array.from(selectedWorkerSet);
|
||
|
||
function loadDashboardUiState() {
|
||
try {
|
||
const raw = window.localStorage.getItem(DASHBOARD_UI_STATE_KEY);
|
||
if (!raw) return;
|
||
const payload = JSON.parse(raw);
|
||
if (!payload || typeof payload !== "object") return;
|
||
if (typeof payload.currentAnalysisWorkerId === "string") {
|
||
currentAnalysisWorkerId = payload.currentAnalysisWorkerId;
|
||
}
|
||
if (typeof payload.activeView === "string" && payload.activeView) {
|
||
activeView = payload.activeView;
|
||
}
|
||
if (Array.isArray(payload.selectedWorkerIds)) {
|
||
selectedWorkerSet = new Set(
|
||
payload.selectedWorkerIds.filter((item) => typeof item === "string" && item)
|
||
);
|
||
}
|
||
if (typeof payload.selectedAnalyticsPackage === "string") {
|
||
selectedAnalyticsPackage = payload.selectedAnalyticsPackage;
|
||
}
|
||
if (typeof payload.analyticsPage === "number" && payload.analyticsPage > 0) {
|
||
analyticsPage = payload.analyticsPage;
|
||
}
|
||
if (typeof payload.analyticsSearch === "string") {
|
||
analyticsSearch = payload.analyticsSearch;
|
||
}
|
||
if (typeof payload.analyticsCollectionStatus === "string") {
|
||
analyticsCollectionStatus = payload.analyticsCollectionStatus;
|
||
}
|
||
if (typeof payload.analyticsIncrementalBatchTag === "string") {
|
||
analyticsIncrementalBatchTag = payload.analyticsIncrementalBatchTag;
|
||
}
|
||
if (payload.analyticsTopN != null) {
|
||
analyticsTopN = normalizeAnalyticsTopN(payload.analyticsTopN);
|
||
}
|
||
if (typeof payload.analyticsSort === "string" && analyticsSortOptions.has(payload.analyticsSort)) {
|
||
analyticsSort = payload.analyticsSort;
|
||
}
|
||
if (typeof payload.analyticsOrder === "string" && payload.analyticsOrder) {
|
||
analyticsOrder = payload.analyticsOrder;
|
||
}
|
||
} catch (error) {
|
||
console.warn("failed to load dashboard ui state", error);
|
||
}
|
||
}
|
||
|
||
function saveDashboardUiState() {
|
||
try {
|
||
window.localStorage.setItem(
|
||
DASHBOARD_UI_STATE_KEY,
|
||
JSON.stringify({
|
||
currentAnalysisWorkerId,
|
||
activeView,
|
||
selectedWorkerIds: Array.from(selectedWorkerSet),
|
||
selectedAnalyticsPackage,
|
||
analyticsPage,
|
||
analyticsSearch,
|
||
analyticsCollectionStatus,
|
||
analyticsIncrementalBatchTag,
|
||
analyticsTopN,
|
||
analyticsSort,
|
||
analyticsOrder,
|
||
})
|
||
);
|
||
} catch (error) {
|
||
console.warn("failed to save dashboard ui state", error);
|
||
}
|
||
}
|
||
|
||
function setCurrentAnalysisWorker(workerId) {
|
||
currentAnalysisWorkerId = workerId || "";
|
||
analysisWorkerSelect.value = currentAnalysisWorkerId;
|
||
if (!analysisWorkerSelect.value) {
|
||
analysisWorkerSelect.selectedIndex = 0;
|
||
}
|
||
saveDashboardUiState();
|
||
}
|
||
|
||
function applyRunPipelineDefaults() {
|
||
runPipelineOptionNodes.forEach((node) => {
|
||
node.checked = !!defaultRunPipelineOptions[node.dataset.runPipeline];
|
||
});
|
||
}
|
||
|
||
function collectRunPipelineOptions() {
|
||
const options = {};
|
||
runPipelineOptionNodes.forEach((node) => {
|
||
options[node.dataset.runPipeline] = node.checked;
|
||
});
|
||
return options;
|
||
}
|
||
|
||
function applyAnalyticsStateToInputs() {
|
||
analyticsSearchInput.value = analyticsSearch;
|
||
analyticsCollectionSelect.value = analyticsCollectionStatus;
|
||
analyticsBatchSelect.value = analyticsIncrementalBatchTag;
|
||
analyticsTopNInput.value = String(normalizeAnalyticsTopN(analyticsTopN));
|
||
if (!analyticsSortOptions.has(analyticsSort)) {
|
||
analyticsSort = "updated_at";
|
||
}
|
||
analyticsSortSelect.value = analyticsSort;
|
||
analyticsOrderSelect.value = analyticsOrder;
|
||
}
|
||
|
||
function refreshWorkerSelectionNote() {
|
||
const workerNodes = Array.from(document.querySelectorAll('input[data-worker-select="1"]'));
|
||
const count = selectedWorkerSet.size;
|
||
workerSelectionNote.textContent = count ? `已选择 ${count} 台 Worker` : "未选择 Worker";
|
||
selectAllWorkers.checked = !!count && workerNodes.length > 0 && workerNodes.every((node) => node.checked);
|
||
}
|
||
|
||
const escapeHtml = (value) => String(value == null ? "" : value)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
|
||
const tooltipEl = document.createElement("div");
|
||
tooltipEl.className = "tooltip-card";
|
||
document.body.appendChild(tooltipEl);
|
||
|
||
function showTooltip(html, event) {
|
||
tooltipEl.innerHTML = html;
|
||
tooltipEl.classList.add("visible");
|
||
moveTooltip(event);
|
||
}
|
||
|
||
function moveTooltip(event) {
|
||
if (!tooltipEl.classList.contains("visible")) return;
|
||
const offset = 18;
|
||
const maxLeft = window.innerWidth - tooltipEl.offsetWidth - 16;
|
||
const maxTop = window.innerHeight - tooltipEl.offsetHeight - 16;
|
||
const left = Math.min(maxLeft, Math.max(16, event.clientX + offset));
|
||
const top = Math.min(maxTop, Math.max(16, event.clientY + offset));
|
||
tooltipEl.style.left = `${left}px`;
|
||
tooltipEl.style.top = `${top}px`;
|
||
}
|
||
|
||
function hideTooltip() {
|
||
tooltipEl.classList.remove("visible");
|
||
}
|
||
|
||
function bindTooltipTargets() {
|
||
document.querySelectorAll("[data-tooltip-html]").forEach((node) => {
|
||
node.addEventListener("mouseenter", (event) => showTooltip(node.dataset.tooltipHtml, event));
|
||
node.addEventListener("mousemove", moveTooltip);
|
||
node.addEventListener("mouseleave", hideTooltip);
|
||
});
|
||
}
|
||
|
||
function updateWorkerFilterOptions(workerOptions = []) {
|
||
const options = ['<option value="">全部 Worker</option>'];
|
||
for (const item of workerOptions) {
|
||
const selected = item.worker_id === currentAnalysisWorkerId ? "selected" : "";
|
||
options.push(`<option value="${escapeHtml(item.worker_id)}" ${selected}>${escapeHtml(item.label || item.worker_id)}</option>`);
|
||
}
|
||
analysisWorkerSelect.innerHTML = options.join("");
|
||
if (currentAnalysisWorkerId && !workerOptions.some((item) => item.worker_id === currentAnalysisWorkerId)) {
|
||
setCurrentAnalysisWorker("");
|
||
return;
|
||
}
|
||
setCurrentAnalysisWorker(currentAnalysisWorkerId);
|
||
}
|
||
|
||
function formatShare(value) {
|
||
return `${Number(value || 0).toFixed(1)}%`;
|
||
}
|
||
|
||
function formatWorkerCount(value) {
|
||
const count = Number(value || 0);
|
||
if (!Number.isFinite(count) || count <= 0) return "0";
|
||
if (Math.abs(count - Math.round(count)) < 0.01) return `${Math.round(count)}`;
|
||
return count.toFixed(1);
|
||
}
|
||
|
||
function formatOverviewMetric(overview, key) {
|
||
let value = overview[key];
|
||
if (key.includes("_seconds")) value = formatSeconds(value);
|
||
if (key.includes("_workers")) value = formatWorkerCount(value);
|
||
if (key === "controller_status") {
|
||
value = statusBadge(value === "running" ? "运行中" : "已停止");
|
||
}
|
||
return value == null ? "--" : value;
|
||
}
|
||
|
||
function setActiveView(view) {
|
||
activeView = view;
|
||
viewButtons.forEach((button) => {
|
||
const selected = button.dataset.view === view;
|
||
button.classList.toggle("active", selected);
|
||
button.setAttribute("aria-pressed", selected ? "true" : "false");
|
||
});
|
||
viewPanels.forEach((panel) => {
|
||
panel.classList.toggle("is-hidden", panel.dataset.viewPanel !== view);
|
||
});
|
||
saveDashboardUiState();
|
||
}
|
||
|
||
function shouldFreezeWorkerPanel(mode) {
|
||
if (mode !== "poll") {
|
||
return false;
|
||
}
|
||
return activeView === "workers";
|
||
}
|
||
|
||
function shouldAutoRefresh() {
|
||
if (activeView === "analytics") {
|
||
return true;
|
||
}
|
||
if ((datePicker.value || todayStr()) !== todayStr()) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function updateDashboardContext() {
|
||
const selectedDate = datePicker.value || todayStr();
|
||
const selectedOption = analysisWorkerSelect.options[analysisWorkerSelect.selectedIndex];
|
||
const workerLabel = currentAnalysisWorkerId
|
||
? ((selectedOption && selectedOption.text) || currentAnalysisWorkerId)
|
||
: "全部 Worker";
|
||
if (activeView === "analytics") {
|
||
contextDateChip.textContent = "维度 累计画像";
|
||
contextWorkerChip.textContent = "范围 全部应用";
|
||
contextRefreshChip.textContent = "应用画像按累计结果自动刷新";
|
||
return;
|
||
}
|
||
contextDateChip.textContent = `日期 ${selectedDate}`;
|
||
contextWorkerChip.textContent = `范围 ${workerLabel}`;
|
||
if (selectedDate !== todayStr()) {
|
||
contextRefreshChip.textContent = "历史视图停止自动刷新";
|
||
return;
|
||
}
|
||
if (activeView === "workers") {
|
||
contextRefreshChip.textContent = "今日视图自动刷新,Worker 操作区保持当前状态";
|
||
return;
|
||
}
|
||
contextRefreshChip.textContent = "今日视图自动刷新";
|
||
}
|
||
|
||
function setLoadingState(isLoading) {
|
||
if (!isLoading) {
|
||
updateDashboardContext();
|
||
return;
|
||
}
|
||
if (activeView === "analytics") {
|
||
contextRefreshChip.textContent = "正在刷新应用画像";
|
||
return;
|
||
}
|
||
const workerLabel = currentAnalysisWorkerId || "全部 Worker";
|
||
contextRefreshChip.textContent = `正在刷新 ${workerLabel}`;
|
||
}
|
||
|
||
function renderSummary(overview, analyticsOverview = {}) {
|
||
const primaryGrid = document.getElementById("summary-grid");
|
||
const workerTotal = Array.isArray(overview.worker_options) ? overview.worker_options.length : 0;
|
||
const onlineWorkers = Number(overview.online_workers || 0);
|
||
const appCount = Number(analyticsOverview.app_count || 0);
|
||
const pendingCount = Number(analyticsOverview.pending_count || 0);
|
||
const qualifiedCount = Number(analyticsOverview.qualified_count || 0);
|
||
const successCount = Number(analyticsOverview.restriction_success_count || 0);
|
||
const lightRestrictedCount = Number(analyticsOverview.light_restricted_count || 0);
|
||
const failedTerminalCount = Number(analyticsOverview.failed_terminal_count || 0);
|
||
const severeNonRetryableCount = Number(analyticsOverview.severe_non_retryable_count || 0);
|
||
const pendingNonRetryableCount = Number(analyticsOverview.pending_non_retryable_count || 0);
|
||
const analyticsScopeHint = buildAnalyticsScopeLabel(
|
||
analyticsOverview.incremental_batch_tag || analyticsIncrementalBatchTag,
|
||
analyticsOverview.top_n_label || `榜单 Top${normalizeAnalyticsTopN(analyticsTopN)}`
|
||
);
|
||
const cards = [
|
||
{ tone: "data", label: "应用总数", value: appCount, hint: analyticsScopeHint },
|
||
{ tone: "pending", label: "待分发", value: pendingCount, hint: "等待重新分发或补采" },
|
||
{
|
||
tone: "success",
|
||
label: "合格",
|
||
value: qualifiedCount,
|
||
hint: "成功 + 轻度受限",
|
||
breakdown: [
|
||
["成功", successCount],
|
||
["轻度受限", lightRestrictedCount],
|
||
],
|
||
},
|
||
{
|
||
tone: "severe",
|
||
label: "不可重试判定",
|
||
value: severeNonRetryableCount,
|
||
hint: "最近一次结果仍判为不可重试",
|
||
breakdown: [
|
||
["终态", failedTerminalCount],
|
||
["待复采", pendingNonRetryableCount],
|
||
],
|
||
},
|
||
{ tone: "data", label: "Worker 总数", value: workerTotal, hint: "当前实例纳管范围" },
|
||
{ tone: "pending", label: "在线数量", value: onlineWorkers, hint: "当前在线 Worker" },
|
||
];
|
||
primaryGrid.innerHTML = cards.map((item) => `
|
||
<article class="analytics-overview-card ${escapeHtml(item.tone)} summary-card">
|
||
<div class="eyebrow">${escapeHtml(item.label)}</div>
|
||
<div class="value">${escapeHtml(item.value)}</div>
|
||
<div class="hint">${escapeHtml(item.hint)}</div>
|
||
${Array.isArray(item.breakdown) && item.breakdown.length ? `
|
||
<div class="analytics-overview-breakdown">
|
||
${item.breakdown.map(([subLabel, subValue]) => `
|
||
<div class="analytics-overview-breakdown-item">
|
||
<span class="label">${escapeHtml(subLabel)}</span>
|
||
<strong>${escapeHtml(subValue)}</strong>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
</article>
|
||
`).join("");
|
||
document.getElementById("tab-badge-failures").textContent = `${overview.task_failed || 0}`;
|
||
}
|
||
|
||
function setAnalyticsActionFeedback(state = null) {
|
||
if (!state) {
|
||
analyticsActionFeedback.hidden = true;
|
||
analyticsActionFeedback.className = "action-feedback is-idle";
|
||
analyticsActionFeedback.textContent = "";
|
||
return;
|
||
}
|
||
analyticsActionFeedback.hidden = false;
|
||
const phaseLabel = state.phase === "running" ? "执行中" : state.phase === "failed" ? "执行失败" : "执行完成";
|
||
analyticsActionFeedback.className = "action-feedback";
|
||
analyticsActionFeedback.innerHTML = `
|
||
<div class="action-feedback-head">
|
||
<div class="action-feedback-title">
|
||
<strong>${escapeHtml(state.title || "分析任务")} · ${phaseLabel}</strong>
|
||
<div class="action-feedback-meta">${escapeHtml(state.subtitle || "")}</div>
|
||
</div>
|
||
${statusBadge(state.phase === "running" ? "running" : state.phase === "failed" ? "failed" : "success")}
|
||
</div>
|
||
<div class="action-feedback-message">${escapeHtml(state.message || "--")}</div>
|
||
`;
|
||
}
|
||
|
||
function renderAnalyticsOverview(overview) {
|
||
const note = document.getElementById("analytics-overview-note");
|
||
const lanes = document.getElementById("analytics-status-lanes");
|
||
const summary = document.getElementById("analytics-topn-summary");
|
||
const batches = Array.isArray(overview.available_incremental_batches) ? overview.available_incremental_batches : [];
|
||
const seenBatchTags = new Set();
|
||
const currentBatchTag = analyticsIncrementalBatchTag || "";
|
||
const batchOptions = ['<option value="">全部 active 应用</option>'];
|
||
batches.forEach((item) => {
|
||
const tag = String((item && (item.tag || item.batch_tag)) || "").trim();
|
||
if (!tag || seenBatchTags.has(tag)) {
|
||
return;
|
||
}
|
||
seenBatchTags.add(tag);
|
||
const count = Number((item && item.app_count) || 0);
|
||
batchOptions.push(
|
||
`<option value="${escapeHtml(tag)}">${escapeHtml(`${tag} (${count})`)}</option>`
|
||
);
|
||
});
|
||
if (currentBatchTag && !seenBatchTags.has(currentBatchTag)) {
|
||
batchOptions.push(
|
||
`<option value="${escapeHtml(currentBatchTag)}">${escapeHtml(`${currentBatchTag} (当前筛选)`)}</option>`
|
||
);
|
||
}
|
||
analyticsBatchSelect.innerHTML = batchOptions.join("");
|
||
analyticsBatchSelect.value = currentBatchTag;
|
||
const appCount = Number(overview.app_count || 0);
|
||
const baseScopeAppCount = Number(overview.base_scope_app_count || 0);
|
||
const missingRankCount = Number(overview.base_scope_missing_source_order_count || 0);
|
||
const successQualifiedCount = Number(overview.restriction_success_count || 0);
|
||
const lightRestrictedCount = Number(overview.light_restricted_count || 0);
|
||
const scopeLabel = buildAnalyticsScopeLabel(
|
||
overview.incremental_batch_tag || currentBatchTag,
|
||
overview.top_n_label || `榜单 Top${normalizeAnalyticsTopN(analyticsTopN)}`
|
||
);
|
||
document.getElementById("tab-badge-analytics").textContent = `${appCount}`;
|
||
note.textContent = appCount
|
||
? `${scopeLabel} · 最近更新 ${overview.last_updated_at || "--"}`
|
||
: `${scopeLabel} · 当前没有命中应用`;
|
||
summary.innerHTML = [
|
||
["基础范围", baseScopeAppCount, currentBatchTag ? "当前批次新增应用" : "当前 active 应用"],
|
||
["命中 TopN", appCount, "基础范围内落在全榜单 TopN 的应用"],
|
||
["缺少榜单排名", missingRankCount, "基础范围内 source_order 为空"],
|
||
].map(([label, value, hint]) => `
|
||
<div class="analytics-overview-breakdown-item">
|
||
<span class="label">${escapeHtml(label)}</span>
|
||
<strong>${escapeHtml(value)}</strong>
|
||
<span class="muted">${escapeHtml(hint)}</span>
|
||
</div>
|
||
`).join("");
|
||
const laneItems = [
|
||
["pending", "待分发", overview.pending_count || 0, "等待重新进入队列"],
|
||
["success", "成功", successQualifiedCount, "restriction_status = success"],
|
||
["light", "轻度受限", lightRestrictedCount, "仍算合格,但存在限制"],
|
||
["severe", "不可重试", overview.failed_terminal_count || 0, "严重受限且不会自动重试"],
|
||
];
|
||
lanes.innerHTML = laneItems.map(([tone, label, count, extra]) => {
|
||
const share = appCount > 0 ? (Number(count || 0) / appCount) * 100 : 0;
|
||
return `
|
||
<article class="analytics-status-lane ${tone}">
|
||
<div class="analytics-lane-meta">
|
||
<strong>${escapeHtml(label)}</strong>
|
||
<span>${count || 0} 个应用</span>
|
||
</div>
|
||
<div class="analytics-lane-track">
|
||
<div class="analytics-lane-fill" style="width:${Math.max(8, share)}%"></div>
|
||
</div>
|
||
<div class="analytics-lane-meta">
|
||
<span>占比 ${formatShare(share)}</span>
|
||
<span>${escapeHtml(extra)}</span>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}).join("");
|
||
renderAnalyticsNonRetryableBreakdown(overview);
|
||
renderAnalyticsNonRetryableDownloadDistribution(overview);
|
||
}
|
||
|
||
function renderDailyCollectionOverview(distributions = {}) {
|
||
const note = document.getElementById("daily-collection-note");
|
||
const container = document.getElementById("daily-collection-grid");
|
||
const overview = distributions.daily_collection_overview || {};
|
||
const qualifiedCount = Number(overview.qualified_task_count || 0);
|
||
const successCount = Number(overview.qualified_success_task_count || 0);
|
||
const lightCount = Number(overview.qualified_light_task_count || 0);
|
||
const nonSuccessCount = Number(overview.non_success_task_count || 0);
|
||
const nonRetryableCount = Number(overview.non_retryable_task_count || 0);
|
||
const totalCount = qualifiedCount + nonSuccessCount;
|
||
|
||
note.textContent = totalCount ? `${totalCount} 个任务样本` : "--";
|
||
container.innerHTML = `
|
||
<article class="daily-collection-card qualified">
|
||
<div class="eyebrow">今日合格</div>
|
||
<div class="value">${qualifiedCount}</div>
|
||
<div class="hint">成功 + 轻度受限</div>
|
||
<div class="daily-collection-breakdown">
|
||
<div class="daily-collection-breakdown-item">
|
||
<span class="label">成功</span>
|
||
<strong>${successCount}</strong>
|
||
</div>
|
||
<div class="daily-collection-breakdown-item">
|
||
<span class="label">轻度受限</span>
|
||
<strong>${lightCount}</strong>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
<article class="daily-collection-card non-success">
|
||
<div class="eyebrow">今日不合格</div>
|
||
<div class="value">${nonSuccessCount}</div>
|
||
<div class="hint">严重受限任务</div>
|
||
</article>
|
||
<article class="daily-collection-card non-retryable">
|
||
<div class="eyebrow">今日不可重试</div>
|
||
<div class="value">${nonRetryableCount}</div>
|
||
<div class="hint">严重受限且不会自动重试</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function renderAnalyticsJobs(jobs = []) {
|
||
const container = document.getElementById("analytics-jobs");
|
||
const note = document.getElementById("analytics-job-note");
|
||
if (!jobs.length) {
|
||
note.textContent = "暂无任务";
|
||
container.innerHTML = '<div class="empty">当前没有分析任务记录</div>';
|
||
return;
|
||
}
|
||
const runningCount = jobs.filter((job) => ["queued", "waiting_artifacts", "running"].includes(job.status)).length;
|
||
note.textContent = `最近 ${jobs.length} 条任务 · 进行中 ${runningCount} 条`;
|
||
const jobTypeLabels = {
|
||
backfill: "全量回灌",
|
||
incremental: "增量更新",
|
||
manual_rebuild: "单应用重算",
|
||
};
|
||
const jobStatusLabels = {
|
||
queued: "排队中",
|
||
waiting_artifacts: "等待产物",
|
||
running: "执行中",
|
||
succeeded: "成功",
|
||
partial: "部分完成",
|
||
failed: "失败",
|
||
};
|
||
container.innerHTML = jobs.map((job) => {
|
||
const phase = ["queued", "waiting_artifacts", "running"].includes(job.status)
|
||
? "running"
|
||
: job.status === "failed"
|
||
? "failed"
|
||
: "completed";
|
||
const scopeLabel = job.package_name || (job.payload && job.payload.scope === "all" ? "全部应用" : "指定应用");
|
||
return `
|
||
<article class="analytics-job-card ${phase}">
|
||
<div class="analytics-job-head">
|
||
<div>
|
||
<strong>${escapeHtml(jobTypeLabels[job.job_type] || job.job_type || "分析任务")}</strong>
|
||
<div class="analytics-job-meta">
|
||
<span>${escapeHtml(scopeLabel)}</span>
|
||
<span>${escapeHtml(job.created_at || "--")}</span>
|
||
</div>
|
||
</div>
|
||
${analyticsTag(jobStatusLabels[job.status] || job.status || "--", phase === "completed" ? "success" : phase === "failed" ? "severe" : "light")}
|
||
</div>
|
||
<div class="analytics-job-meta">
|
||
<span>尝试 ${job.attempt_count || 0} 次</span>
|
||
<span>${job.worker_id ? `Worker ${job.worker_id}` : "系统任务"}</span>
|
||
</div>
|
||
<div class="analytics-job-message">${escapeHtml(job.error_message || "等待更多反馈...")}</div>
|
||
</article>
|
||
`;
|
||
}).join("");
|
||
}
|
||
|
||
function renderAnalyticsNonRetryableBreakdown(overview = {}) {
|
||
const note = document.getElementById("analytics-non-retryable-note");
|
||
const container = document.getElementById("analytics-non-retryable-breakdown");
|
||
const items = Array.isArray(overview.non_retryable_breakdown) ? overview.non_retryable_breakdown : [];
|
||
const total = Number(overview.failed_terminal_count || overview.severe_non_retryable_count || 0);
|
||
const pendingNonRetryableCount = Number(overview.pending_non_retryable_count || 0);
|
||
const splitThreshold = Number(overview.non_retryable_download_split_threshold || 10000);
|
||
const splitLabel = splitThreshold >= 1000 ? `${Math.round(splitThreshold / 1000)}K` : `${splitThreshold}`;
|
||
if (!items.length) {
|
||
note.textContent = total
|
||
? `共 ${total} 个不可重试应用${pendingNonRetryableCount ? ` · 待复采 ${pendingNonRetryableCount} 个` : ""}`
|
||
: "当前没有不可重试应用";
|
||
container.innerHTML = '<div class="empty">当前没有不可重试错误明细</div>';
|
||
return;
|
||
}
|
||
note.textContent = `共 ${total} 个应用 · ${items.length} 类错误${pendingNonRetryableCount ? ` · 待复采 ${pendingNonRetryableCount} 个` : ""}`;
|
||
const maxShare = Math.max(...items.map((item) => Number(item.share_percent || 0)), 1);
|
||
container.innerHTML = items.map((item) => {
|
||
const share = Number(item.share_percent || 0);
|
||
const count = Number(item.app_count || 0);
|
||
const lowCount = Number(item.downloads_below_10k_count || 0);
|
||
const highCount = Number(item.downloads_10k_or_above_count || 0);
|
||
const unknownCount = Number(item.downloads_unknown_count || 0);
|
||
return `
|
||
<article class="analytics-breakdown-item">
|
||
<div class="analytics-breakdown-head">
|
||
<div class="analytics-breakdown-title">
|
||
<strong>${escapeHtml(item.label || item.error_type || "未标记错误")}</strong>
|
||
<div class="analytics-breakdown-code">${escapeHtml(item.error_type || "未标记错误码")}</div>
|
||
</div>
|
||
${analyticsTag(`${count} 个应用`, "severe")}
|
||
</div>
|
||
<div class="bar-track">
|
||
<div class="bar-fill subtle" style="width:${Math.max(8, (share / maxShare) * 100)}%"></div>
|
||
</div>
|
||
<div class="analytics-breakdown-meta">
|
||
<span>占不可重试 ${formatShare(share)}</span>
|
||
<span>个数 ${count}</span>
|
||
</div>
|
||
<div class="analytics-breakdown-meta">
|
||
<span>${splitLabel} 以下 ${lowCount}</span>
|
||
<span>${splitLabel} 及以上 ${highCount}${unknownCount ? ` · 未知 ${unknownCount}` : ""}</span>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}).join("");
|
||
}
|
||
|
||
function renderAnalyticsNonRetryableDownloadDistribution(overview = {}) {
|
||
const note = document.getElementById("analytics-non-retryable-download-note");
|
||
const total = Number(overview.failed_terminal_count || overview.severe_non_retryable_count || 0);
|
||
const matched = Number(overview.non_retryable_downloads_matched_count || 0);
|
||
const missing = Number(overview.non_retryable_downloads_missing_count || 0);
|
||
const items = Array.isArray(overview.non_retryable_download_distribution)
|
||
? overview.non_retryable_download_distribution.map((item) => ({
|
||
label: item.label || item.bucket_key || "--",
|
||
count: Number(item.app_count || 0),
|
||
}))
|
||
: [];
|
||
note.textContent = total
|
||
? `共 ${total} 个应用 · 已匹配下载量 ${matched} 个${missing ? ` · 缺失 ${missing} 个` : ""}`
|
||
: "当前没有不可重试应用";
|
||
renderHistogram(
|
||
"analytics-non-retryable-download-distribution",
|
||
"下载量区间应用数",
|
||
items,
|
||
"subtle",
|
||
"当前没有不可重试应用的下载量分布"
|
||
);
|
||
}
|
||
|
||
function updateAnalyticsSelectedRows() {
|
||
document.querySelectorAll("[data-analytics-package]").forEach((row) => {
|
||
row.classList.toggle("active", row.dataset.analyticsPackage === selectedAnalyticsPackage);
|
||
});
|
||
}
|
||
|
||
function renderAnalyticsDetailLoading(packageName = "") {
|
||
const container = document.getElementById("analytics-detail");
|
||
container.innerHTML = `
|
||
<div class="analytics-empty">
|
||
正在加载 ${escapeHtml(packageName || "应用详情")} ...
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderAnalyticsDetailError(packageName = "", message = "") {
|
||
const container = document.getElementById("analytics-detail");
|
||
container.innerHTML = `
|
||
<div class="analytics-empty">
|
||
无法加载 ${escapeHtml(packageName || "应用详情")}。
|
||
<div class="muted">${escapeHtml(message || "请稍后重试。")}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function loadAnalyticsDetail(packageName, { preferCache = true, force = false } = {}) {
|
||
if (!packageName) {
|
||
renderAnalyticsDetail(null);
|
||
return null;
|
||
}
|
||
const cachedDetail = analyticsDetailCache.get(packageName) || null;
|
||
if (cachedDetail && preferCache) {
|
||
renderAnalyticsDetail(cachedDetail);
|
||
} else {
|
||
renderAnalyticsDetailLoading(packageName);
|
||
}
|
||
if (cachedDetail && !force) {
|
||
return cachedDetail;
|
||
}
|
||
|
||
const requestSeq = ++analyticsDetailRequestSeq;
|
||
if (activeAnalyticsDetailController) {
|
||
activeAnalyticsDetailController.abort();
|
||
}
|
||
const controller = new AbortController();
|
||
activeAnalyticsDetailController = controller;
|
||
try {
|
||
const response = await fetch(
|
||
`/api/analytics/apps/${encodeURIComponent(packageName)}`,
|
||
{ cache: "no-store", signal: controller.signal }
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error(`analytics detail request failed (${response.status})`);
|
||
}
|
||
const detail = await response.json();
|
||
analyticsDetailCache.set(packageName, detail);
|
||
if (requestSeq === analyticsDetailRequestSeq && selectedAnalyticsPackage === packageName) {
|
||
renderAnalyticsDetail(detail);
|
||
}
|
||
return detail;
|
||
} catch (error) {
|
||
if (error && error.name === "AbortError") {
|
||
return cachedDetail;
|
||
}
|
||
console.error(error);
|
||
if (!cachedDetail && requestSeq === analyticsDetailRequestSeq && selectedAnalyticsPackage === packageName) {
|
||
renderAnalyticsDetailError(packageName, error.message || String(error));
|
||
}
|
||
return cachedDetail;
|
||
} finally {
|
||
if (activeAnalyticsDetailController === controller) {
|
||
activeAnalyticsDetailController = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderAnalyticsApps(payload = { items: [], total: 0, page: 1, page_size: 14 }) {
|
||
const body = document.getElementById("analytics-apps-body");
|
||
const pageNote = document.getElementById("analytics-page-note");
|
||
const listNote = document.getElementById("analytics-list-note");
|
||
const items = payload.items || [];
|
||
const total = Number(payload.total || 0);
|
||
const page = Number(payload.page || 1);
|
||
const pageSize = Number(payload.page_size || 14);
|
||
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||
const scopeLabel = buildAnalyticsScopeLabel(
|
||
payload.incremental_batch_tag || analyticsIncrementalBatchTag,
|
||
payload.top_n_label || `榜单 Top${normalizeAnalyticsTopN(analyticsTopN)}`
|
||
);
|
||
listNote.textContent = total
|
||
? `${scopeLabel} · 共 ${total} 个应用 · 当前第 ${page}/${totalPages} 页`
|
||
: `${scopeLabel} · 当前筛选下没有应用`;
|
||
pageNote.textContent = total
|
||
? `显示 ${Math.min((page - 1) * pageSize + 1, total)}-${Math.min(page * pageSize, total)} / ${total}`
|
||
: "0 / 0";
|
||
analyticsPrevBtn.disabled = page <= 1;
|
||
analyticsNextBtn.disabled = page >= totalPages || total === 0;
|
||
if (!items.length) {
|
||
body.innerHTML = '<tr><td colspan="9" class="empty">当前筛选下没有应用画像</td></tr>';
|
||
return;
|
||
}
|
||
body.innerHTML = items.map((item) => {
|
||
const rankLabel = formatAnalyticsRank(item.source_order);
|
||
const batchLabel = item.incremental_batch_tag ? `批次 ${item.incremental_batch_tag}` : "未标记批次";
|
||
return `
|
||
<tr class="${selectedAnalyticsPackage === item.package_name ? "active" : ""}" data-analytics-package="${escapeHtml(item.package_name)}">
|
||
<td>
|
||
<div class="analytics-cell-title">
|
||
<strong>${escapeHtml(item.app_name || item.package_name)}</strong>
|
||
<div class="mono">${escapeHtml(item.package_name)}</div>
|
||
<div class="muted">${escapeHtml(rankLabel)}</div>
|
||
<div class="muted">${escapeHtml(batchLabel)}</div>
|
||
<div class="muted">${escapeHtml(item.latest_worker_id || "--")}</div>
|
||
</div>
|
||
</td>
|
||
<td>${deviceTypeTag(item.device_type)}</td>
|
||
<td>${analyticsCollectionTag(item.collection_status)}</td>
|
||
<td>
|
||
<div>${analyticsRestrictionTag(item.restriction_status)}</div>
|
||
<div class="muted">${escapeHtml(item.retryability === "not_applicable" ? "不适用" : retryabilityLabels[item.retryability] || item.retryability || "--")}</div>
|
||
</td>
|
||
<td>${analyticsLatestStatusTag(item.latest_status)}</td>
|
||
<td>
|
||
<div>${formatShare(item.self_ratio || 0)}</div>
|
||
<div class="muted">${formatBytes(item.self_traffic_bytes || 0)}</div>
|
||
</td>
|
||
<td>
|
||
<div>${item.unique_second_level_domain_count || 0}</div>
|
||
<div class="muted">原始 ${item.unique_domain_count || 0}</div>
|
||
</td>
|
||
<td>
|
||
<div>${item.num_nodes || 0}</div>
|
||
<div class="muted">nodes</div>
|
||
</td>
|
||
<td>
|
||
<div>${escapeHtml(item.updated_at || "--")}</div>
|
||
<div class="muted">${formatSeconds(item.duration_seconds || 0)}</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join("");
|
||
body.querySelectorAll("[data-analytics-package]").forEach((row) => {
|
||
row.addEventListener("click", () => {
|
||
selectedAnalyticsPackage = row.dataset.analyticsPackage;
|
||
saveDashboardUiState();
|
||
updateAnalyticsSelectedRows();
|
||
void loadAnalyticsDetail(selectedAnalyticsPackage, { preferCache: true, force: true });
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderAnalyticsDetail(detail) {
|
||
const container = document.getElementById("analytics-detail");
|
||
if (!detail || !detail.summary) {
|
||
container.innerHTML = '<div class="analytics-empty">先在左侧选择一个应用,再查看二级域名、组件归因和独占流量拆解。</div>';
|
||
return;
|
||
}
|
||
const summary = detail.summary || {};
|
||
const components = detail.components || [];
|
||
const topSecondLevelDomains = detail.top_second_level_domains || [];
|
||
const topRawDomains = detail.top_raw_domains || [];
|
||
const secondLevelDomains = summary.unique_second_level_domains || [];
|
||
const unmatchedDomains = Array.from(new Set(
|
||
(detail.top_unmatched_domains || []).map((item) => item.domain).filter(Boolean)
|
||
));
|
||
const maxComponentShare = Math.max(...components.map((item) => Number(item.share_percent || 0)), 1);
|
||
const latestDetail = summary.latest_task_detail || summary.latest_failure_type || "暂无任务详情";
|
||
const rankLabel = formatAnalyticsRank(summary.source_order);
|
||
const batchLabel = summary.incremental_batch_tag ? `批次 ${summary.incremental_batch_tag}` : "未标记增量批次";
|
||
const batchMarkedAt = summary.incremental_batch_marked_at || "--";
|
||
container.innerHTML = `
|
||
<section class="analytics-detail">
|
||
<article class="analytics-detail-card">
|
||
<div class="panel-head">
|
||
<div class="analytics-detail-header">
|
||
<div class="analytics-detail-title">
|
||
<h3>${escapeHtml(summary.app_name || summary.package_name)}</h3>
|
||
<div class="mono">${escapeHtml(summary.package_name)}</div>
|
||
<div class="muted">${escapeHtml(`${rankLabel} · ${batchLabel}`)}</div>
|
||
<div class="muted">${escapeHtml(latestDetail)}</div>
|
||
</div>
|
||
<div class="analytics-detail-badges">
|
||
${deviceTypeTag(summary.device_type)}
|
||
${analyticsCollectionTag(summary.collection_status)}
|
||
${analyticsRestrictionTag(summary.restriction_status)}
|
||
${analyticsRetryabilityTag(summary.retryability)}
|
||
${analyticsLatestStatusTag(summary.latest_status)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body analytics-stack">
|
||
<div class="analytics-stat-grid">
|
||
<article class="analytics-stat-card">
|
||
<div class="label">独占流量占比</div>
|
||
<div class="value">${formatShare(summary.self_ratio || 0)}</div>
|
||
<div class="muted">${formatBytes(summary.self_traffic_bytes || 0)}</div>
|
||
</article>
|
||
<article class="analytics-stat-card">
|
||
<div class="label">识别流量</div>
|
||
<div class="value">${formatShare(summary.recognition_ratio || 0)}</div>
|
||
<div class="muted">${formatBytes((summary.self_traffic_bytes || 0) + (summary.server_traffic_bytes || 0))}</div>
|
||
</article>
|
||
<article class="analytics-stat-card">
|
||
<div class="label">未识别流量</div>
|
||
<div class="value">${formatBytes(summary.unrecognized_traffic_bytes || 0)}</div>
|
||
<div class="muted">第三方 ${formatBytes(summary.server_traffic_bytes || 0)}</div>
|
||
</article>
|
||
<article class="analytics-stat-card">
|
||
<div class="label">二级域名</div>
|
||
<div class="value">${summary.unique_second_level_domain_count || 0}</div>
|
||
<div class="muted">原始域名 ${summary.unique_domain_count || 0}</div>
|
||
</article>
|
||
<article class="analytics-stat-card">
|
||
<div class="label">UTG 节点</div>
|
||
<div class="value">${summary.num_nodes || 0}</div>
|
||
<div class="muted">节点总数</div>
|
||
</article>
|
||
<article class="analytics-stat-card">
|
||
<div class="label">榜单排名</div>
|
||
<div class="value">${escapeHtml(rankLabel)}</div>
|
||
<div class="muted">${escapeHtml(summary.downloads ? `下载量 ${summary.downloads}` : "无下载量展示")}</div>
|
||
</article>
|
||
<article class="analytics-stat-card">
|
||
<div class="label">增量批次</div>
|
||
<div class="value">${escapeHtml(summary.incremental_batch_tag || "--")}</div>
|
||
<div class="muted">标记时间 ${escapeHtml(batchMarkedAt)}</div>
|
||
</article>
|
||
<article class="analytics-stat-card">
|
||
<div class="label">任务耗时</div>
|
||
<div class="value">${formatSeconds(summary.duration_seconds || 0)}</div>
|
||
<div class="muted">更新时间 ${escapeHtml(summary.updated_at || "--")}</div>
|
||
</article>
|
||
</div>
|
||
|
||
<section class="analytics-section-card">
|
||
<div class="analytics-detail-header">
|
||
<div>
|
||
<h4>组件归因</h4>
|
||
<div class="muted">从 DPI 规则归因出来的自有 / 第三方流量组件。</div>
|
||
</div>
|
||
<div class="analytics-actions">
|
||
<button id="analytics-rebuild-inline-btn" type="button">重算该应用</button>
|
||
</div>
|
||
</div>
|
||
<div class="analytics-component-list">
|
||
${components.length ? components.map((item) => `
|
||
<div class="analytics-component-row">
|
||
<div class="analytics-component-head">
|
||
<div>
|
||
<strong>${escapeHtml(item.component_name)}</strong>
|
||
<div class="analytics-component-meta">${escapeHtml(item.component_package_names || "--")}</div>
|
||
</div>
|
||
<div class="analytics-detail-badges">
|
||
${item.is_self ? analyticsTag("自有组件", "success") : analyticsTag("第三方组件", "light")}
|
||
${analyticsTag(`${formatShare(item.share_percent || 0)}`, "neutral")}
|
||
</div>
|
||
</div>
|
||
<div class="bar-track">
|
||
<div class="bar-fill ${item.is_self ? "ok" : ""}" style="width:${Math.max(8, ((item.share_percent || 0) / maxComponentShare) * 100)}%"></div>
|
||
</div>
|
||
<div class="analytics-component-meta">${formatBytes(item.traffic_bytes || 0)}</div>
|
||
</div>
|
||
`).join("") : '<div class="empty">当前没有可归因的组件流量</div>'}
|
||
</div>
|
||
</section>
|
||
|
||
<section class="analytics-section-card">
|
||
<h4>二级合并域名</h4>
|
||
<div class="analytics-domain-table">
|
||
<table class="table mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>域名</th>
|
||
<th>归因</th>
|
||
<th>流量</th>
|
||
<th>命中流</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${topSecondLevelDomains.length ? topSecondLevelDomains.map((item) => `
|
||
<tr>
|
||
<td>
|
||
<div>${escapeHtml(item.domain)}</div>
|
||
<div class="muted">${escapeHtml(item.matched_pattern || item.organization || "--")}</div>
|
||
</td>
|
||
<td>${renderAnalyticsDomainTag(item.match_state)}</td>
|
||
<td>${formatBytes(item.traffic_bytes || 0)}</td>
|
||
<td>${item.flow_count || 0}</td>
|
||
</tr>
|
||
`).join("") : '<tr><td colspan="4" class="empty">暂无二级域名数据</td></tr>'}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="analytics-section-card">
|
||
<h4>原始域名</h4>
|
||
<div class="analytics-domain-table">
|
||
<table class="table mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>域名</th>
|
||
<th>归因</th>
|
||
<th>流量</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${topRawDomains.length ? topRawDomains.map((item) => `
|
||
<tr>
|
||
<td>
|
||
<div>${escapeHtml(item.domain)}</div>
|
||
<div class="muted">${escapeHtml(toSecondLevelDomain(item.domain) || item.organization || "--")}</div>
|
||
</td>
|
||
<td>${renderAnalyticsDomainTag(item.match_state)}</td>
|
||
<td>${formatBytes(item.traffic_bytes || 0)}</td>
|
||
</tr>
|
||
`).join("") : '<tr><td colspan="3" class="empty">暂无原始域名数据</td></tr>'}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="analytics-section-card">
|
||
<h4>未匹配域名</h4>
|
||
<div class="analytics-chip-list">
|
||
${unmatchedDomains.length ? unmatchedDomains.map((item) => `
|
||
<span class="analytics-chip alert">${escapeHtml(item)}</span>
|
||
`).join("") : '<span class="analytics-chip">当前没有未匹配域名</span>'}
|
||
</div>
|
||
</section>
|
||
|
||
<section class="analytics-section-card">
|
||
<h4>全部二级域名</h4>
|
||
<div class="analytics-chip-list">
|
||
${secondLevelDomains.length ? secondLevelDomains.map((item) => `
|
||
<span class="analytics-chip">${escapeHtml(item)}</span>
|
||
`).join("") : '<span class="analytics-chip">暂无二级域名</span>'}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
`;
|
||
const inlineButton = document.getElementById("analytics-rebuild-inline-btn");
|
||
if (inlineButton) {
|
||
inlineButton.addEventListener("click", triggerAnalyticsRebuild);
|
||
}
|
||
}
|
||
|
||
function formatClientDateTime(value) {
|
||
return value.toLocaleString("zh-CN", { hour12: false });
|
||
}
|
||
|
||
function actionFeedbackCardClass(item, phase) {
|
||
if (phase === "running") return "action-feedback-card pending";
|
||
return item && item.ok ? "action-feedback-card ok" : "action-feedback-card fail";
|
||
}
|
||
|
||
function stepFeedbackClass(step) {
|
||
if (step.ok === true) return "step-pill ok";
|
||
if (step.ok === false) return "step-pill fail";
|
||
return "step-pill pending";
|
||
}
|
||
|
||
function renderWorkerActionFeedback(state = null) {
|
||
if (!state) {
|
||
workerActionFeedback.className = "action-feedback is-idle";
|
||
workerActionFeedback.textContent = "最近一次操作反馈会显示在这里。";
|
||
return;
|
||
}
|
||
const finishedAt = state.finishedAt ? formatClientDateTime(state.finishedAt) : "";
|
||
const startedAt = state.startedAt ? formatClientDateTime(state.startedAt) : "";
|
||
const phaseLabel = state.phase === "running"
|
||
? "执行中"
|
||
: state.phase === "failed"
|
||
? "执行失败"
|
||
: "执行完成";
|
||
const results = state.results || state.workerIds.map((workerId) => ({
|
||
worker_id: workerId,
|
||
ok: null,
|
||
message: "等待返回...",
|
||
steps: [],
|
||
}));
|
||
workerActionFeedback.className = "action-feedback";
|
||
workerActionFeedback.innerHTML = `
|
||
<div class="action-feedback-head">
|
||
<div class="action-feedback-title">
|
||
<strong>${escapeHtml(state.actionLabel)} · ${phaseLabel}</strong>
|
||
<div class="action-feedback-meta">
|
||
下发 ${results.length} 台 Worker
|
||
${startedAt ? ` · 开始 ${escapeHtml(startedAt)}` : ""}
|
||
${finishedAt ? ` · 结束 ${escapeHtml(finishedAt)}` : ""}
|
||
</div>
|
||
</div>
|
||
${statusBadge(state.phase === "running" ? "running" : state.phase === "failed" ? "failed" : "success")}
|
||
</div>
|
||
${state.error ? `<div class="action-feedback-message">${escapeHtml(state.error)}</div>` : ""}
|
||
<div class="action-feedback-grid">
|
||
${results.map((item) => `
|
||
<div class="${actionFeedbackCardClass(item, state.phase)}">
|
||
<div class="action-feedback-worker">
|
||
<strong class="mono">${escapeHtml(item.worker_id || "--")}</strong>
|
||
${statusBadge(item.ok === true ? "success" : item.ok === false ? "failed" : "running")}
|
||
</div>
|
||
<div class="action-feedback-message">${escapeHtml(item.message || "--")}</div>
|
||
${item.command ? `
|
||
<div class="action-feedback-command">${escapeHtml(item.command)}</div>
|
||
` : ""}
|
||
${(item.steps || []).length ? `
|
||
<div class="step-list">
|
||
${(item.steps || []).map((step) => `
|
||
<span class="${stepFeedbackClass(step)}">${escapeHtml(step.step || "--")}: ${escapeHtml(step.message || "")}</span>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function buildFeedbackStateFromJob(job, actionLabel) {
|
||
const phase = job.status === "running"
|
||
? "running"
|
||
: job.status === "failed"
|
||
? "failed"
|
||
: "completed";
|
||
return {
|
||
phase,
|
||
action: job.action,
|
||
actionLabel,
|
||
workerIds: job.worker_ids || [],
|
||
startedAt: job.started_at ? new Date(job.started_at * 1000) : null,
|
||
finishedAt: job.finished_at ? new Date(job.finished_at * 1000) : null,
|
||
error: job.error || "",
|
||
results: job.results || [],
|
||
};
|
||
}
|
||
|
||
async function pollWorkerActionJob(jobId, actionLabel) {
|
||
activeWorkerActionJobId = jobId;
|
||
while (activeWorkerActionJobId === jobId) {
|
||
const response = await fetch(`/api/workers/actions/status?job_id=${encodeURIComponent(jobId)}`, {
|
||
cache: "no-store",
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok || !payload.ok) {
|
||
throw new Error(payload.error || "worker action status failed");
|
||
}
|
||
const job = payload.job || {};
|
||
renderWorkerActionFeedback(buildFeedbackStateFromJob(job, actionLabel));
|
||
if (job.status !== "running") {
|
||
activeWorkerActionJobId = "";
|
||
await refresh("manual");
|
||
return;
|
||
}
|
||
await new Promise((resolve) => window.setTimeout(resolve, 1000));
|
||
}
|
||
}
|
||
|
||
function renderHistogram(containerId, title, items, tone = "accent", emptyMessage = "当前日期没有可绘制的分布") {
|
||
const container = document.getElementById(containerId);
|
||
const total = Array.isArray(items)
|
||
? items.reduce((sum, item) => sum + (item.count || 0), 0)
|
||
: 0;
|
||
if (!items || !items.length || total <= 0) {
|
||
container.innerHTML = `
|
||
<div class="chart-card">
|
||
<div class="chart-title"><strong>${title}</strong><span>暂无数据</span></div>
|
||
<div class="empty">${emptyMessage}</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
const max = Math.max(...items.map((item) => item.count || 0), 1);
|
||
container.innerHTML = `
|
||
<div class="chart-card">
|
||
<div class="chart-title"><strong>${title}</strong><span>共 ${total} 条</span></div>
|
||
<div class="histogram">
|
||
<div class="hist-bars">
|
||
${items.map((item) => `
|
||
<div class="hist-col">
|
||
<div class="hist-count">${item.count || 0}</div>
|
||
<div class="hist-track">
|
||
<div class="hist-bar ${tone === "subtle" ? "subtle" : ""}" style="height:${item.count ? Math.max(14, ((item.count || 0) / max) * 136) : 0}px"></div>
|
||
</div>
|
||
<div class="hist-label">${item.label}</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderStageBreakdown(containerId, noteId, items, taskCount, title) {
|
||
const container = document.getElementById(containerId);
|
||
document.getElementById(noteId).textContent = `${taskCount || 0} 个任务样本`;
|
||
if (!items || !items.length) {
|
||
container.innerHTML = '<div class="empty">暂无数据</div>';
|
||
return;
|
||
}
|
||
const maxShare = Math.max(...items.map((item) => item.share_percent || 0), 1);
|
||
container.innerHTML = `
|
||
<div class="bar-list">
|
||
${items.map((item) => `
|
||
<div>
|
||
<div class="bar-row">
|
||
<div class="bar-name">${item.label}</div>
|
||
<div>
|
||
<div class="bar-track">
|
||
<div class="bar-fill" style="width:${((item.share_percent || 0) / maxShare) * 100}%"></div>
|
||
</div>
|
||
<div class="bar-meta">
|
||
<span>平均 ${formatSeconds(item.avg_duration_seconds)}</span>
|
||
<span>占比 ${Number(item.share_percent || 0).toFixed(1)}%</span>
|
||
</div>
|
||
</div>
|
||
<div class="bar-value">${formatSeconds(item.avg_duration_seconds)}</div>
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderTimeComposition(containerId, noteId, items) {
|
||
const container = document.getElementById(containerId);
|
||
const note = document.getElementById(noteId);
|
||
if (!items || !items.length) {
|
||
note.textContent = "暂无数据";
|
||
container.innerHTML = '<div class="empty">当前没有可统计的运行时间</div>';
|
||
return;
|
||
}
|
||
const totalSeconds = items.reduce((sum, item) => sum + Number(item.duration_seconds || 0), 0);
|
||
note.textContent = `累计运行 ${formatSeconds(totalSeconds)}`;
|
||
const max = Math.max(...items.map((item) => item.share_percent || 0), 1);
|
||
container.innerHTML = `
|
||
<div class="bar-list">
|
||
${items.map((item) => `
|
||
<div>
|
||
<div class="bar-row">
|
||
<div class="bar-name">${item.label}</div>
|
||
<div ${item.details ? `data-tooltip-html="${escapeHtml(buildNonSuccessTooltip(item))}"` : ""}>
|
||
<div class="bar-track">
|
||
<div class="bar-fill ${item.bucket === "severe_restricted" ? "subtle" : ""}" style="width:${((item.share_percent || 0) / max) * 100}%"></div>
|
||
</div>
|
||
<div class="bar-meta">
|
||
<span>${formatSeconds(item.duration_seconds)}</span>
|
||
<span>占比 ${Number(item.share_percent || 0).toFixed(1)}%</span>
|
||
</div>
|
||
</div>
|
||
<div class="bar-value">${formatSeconds(item.duration_seconds)}</div>
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function buildNonSuccessTooltip(item) {
|
||
const details = item.details || [];
|
||
const rows = details.slice(0, 6).map((detail) => `
|
||
<div>${escapeHtml(detail.label)} · ${formatSeconds(detail.duration_seconds)} · 整体 ${formatShare(detail.overall_share_percent)} · 错误 ${formatShare(detail.error_share_percent)}</div>
|
||
`).join("");
|
||
return `
|
||
<strong>严重受限时间拆分</strong>
|
||
<div class="text-muted small">总耗时 ${formatSeconds(item.duration_seconds)} · 占整体 ${formatShare(item.share_percent)}</div>
|
||
<div class="tooltip-list">
|
||
${rows || '<div>暂无严重受限明细</div>'}
|
||
<div>点击失败大类可查看完整列表</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function buildDomainTooltip(domain) {
|
||
const subtypeRows = (domain.subtypes || []).slice(0, 6).map((item) => `
|
||
<div>${escapeHtml(item.label)} · ${formatSeconds(item.duration_seconds)} · 占大类 ${formatShare(item.domain_share_percent)}</div>
|
||
`).join("");
|
||
return `
|
||
<strong>${escapeHtml(domain.label)}</strong>
|
||
<div class="text-muted small">总耗时 ${formatSeconds(domain.duration_seconds)} · 占整体 ${formatShare(domain.overall_share_percent)} · 占错误 ${formatShare(domain.error_share_percent)} · ${domain.task_count || 0} 个任务</div>
|
||
<div class="tooltip-list">
|
||
${subtypeRows || '<div>暂无小错误</div>'}
|
||
<div>点击后查看完整小错误、原因、消息和样本</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function ensureFailureSelection(distributions) {
|
||
const domains = distributions.failure_domain_breakdown || [];
|
||
if (!domains.length) {
|
||
selectedFailureDomain = "";
|
||
selectedFailureSubtype = "";
|
||
return null;
|
||
}
|
||
let domain = domains.find((item) => item.domain === selectedFailureDomain);
|
||
if (!domain) {
|
||
domain = domains[0];
|
||
selectedFailureDomain = domain.domain;
|
||
}
|
||
const subtypes = domain.subtypes || [];
|
||
let subtype = subtypes.find((item) => `${item.domain}:${item.subtype}` === selectedFailureSubtype);
|
||
if (!subtype) {
|
||
subtype = subtypes[0] || null;
|
||
selectedFailureSubtype = subtype ? `${subtype.domain}:${subtype.subtype}` : "";
|
||
}
|
||
return { domain, subtype };
|
||
}
|
||
|
||
function renderFailureDomainBreakdown(distributions) {
|
||
const container = document.getElementById("failure-domain-breakdown");
|
||
const domains = distributions.failure_domain_breakdown || [];
|
||
if (!domains.length) {
|
||
container.innerHTML = '<div class="card shadow-sm rounded-4"><div class="card-body empty-card">当前范围没有失败明细</div></div>';
|
||
return;
|
||
}
|
||
ensureFailureSelection(distributions);
|
||
container.innerHTML = `
|
||
<div class="card shadow-sm rounded-4">
|
||
<div class="card-header">
|
||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<div>
|
||
<h3 class="mb-0">失败大类耗时</h3>
|
||
<div class="text-muted small">点击大类展开完整小错误列表,再点小错误看完整原因和样本。</div>
|
||
</div>
|
||
<div class="text-muted small">${domains.length} 个失败大类</div>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="accordion">
|
||
${domains.map((domain) => {
|
||
const expanded = domain.domain === selectedFailureDomain;
|
||
const maxShare = Math.max(...domains.map((item) => item.error_share_percent || 0), 1);
|
||
return `
|
||
<div class="accordion-item">
|
||
<button class="accordion-button ${expanded ? "" : "collapsed"}" type="button" data-domain-toggle="${escapeHtml(domain.domain)}" data-tooltip-html="${escapeHtml(buildDomainTooltip(domain))}">
|
||
<div class="domain-header">
|
||
<div class="domain-title">
|
||
<strong>${escapeHtml(domain.label)}</strong>
|
||
<span class="badge bg-body-secondary">${domain.task_count || 0} 个任务</span>
|
||
</div>
|
||
<div class="progress">
|
||
<div class="progress-bar" style="width:${Math.max(6, ((domain.error_share_percent || 0) / maxShare) * 100)}%"></div>
|
||
</div>
|
||
<div class="domain-metrics">
|
||
<span class="metric-pill">耗时 ${formatSeconds(domain.duration_seconds)}</span>
|
||
<span class="metric-pill">占整体 ${formatShare(domain.overall_share_percent)}</span>
|
||
<span class="metric-pill">占错误 ${formatShare(domain.error_share_percent)}</span>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
<div class="accordion-collapse collapse ${expanded ? "show" : ""}">
|
||
<div class="accordion-body">
|
||
<div class="subtype-table-wrap">
|
||
<table class="table table-hover mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>小错误</th>
|
||
<th>耗时</th>
|
||
<th>占整体</th>
|
||
<th>占大类</th>
|
||
<th>任务数</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${(domain.subtypes || []).map((subtype) => `
|
||
<tr class="subtype-row ${selectedFailureSubtype === `${subtype.domain}:${subtype.subtype}` ? "active" : ""}" data-subtype-select="${escapeHtml(`${subtype.domain}:${subtype.subtype}`)}">
|
||
<td>${escapeHtml(subtype.label)}</td>
|
||
<td>${formatSeconds(subtype.duration_seconds)}</td>
|
||
<td>${formatShare(subtype.overall_share_percent)}</td>
|
||
<td>${formatShare(subtype.domain_share_percent)}</td>
|
||
<td>${subtype.task_count || 0}</td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.querySelectorAll("[data-domain-toggle]").forEach((node) => {
|
||
node.addEventListener("click", () => {
|
||
const nextDomain = node.dataset.domainToggle;
|
||
if (selectedFailureDomain !== nextDomain) {
|
||
selectedFailureDomain = nextDomain;
|
||
const domain = domains.find((item) => item.domain === nextDomain);
|
||
const subtypeList = domain && domain.subtypes ? domain.subtypes : [];
|
||
const subtype = subtypeList[0];
|
||
selectedFailureSubtype = subtype ? `${subtype.domain}:${subtype.subtype}` : "";
|
||
}
|
||
renderFailureAnalysis(distributions);
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-subtype-select]").forEach((node) => {
|
||
node.addEventListener("click", () => {
|
||
selectedFailureSubtype = node.dataset.subtypeSelect;
|
||
renderFailureAnalysis(distributions);
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderFailureDetail(distributions) {
|
||
const container = document.getElementById("failure-detail-container");
|
||
const detailNote = document.getElementById("failure-detail-note");
|
||
const selection = ensureFailureSelection(distributions);
|
||
if (!selection) {
|
||
detailNote.textContent = "无失败数据";
|
||
container.innerHTML = '<div class="empty-card">当前范围没有失败详情</div>';
|
||
return;
|
||
}
|
||
|
||
const { domain, subtype } = selection;
|
||
const reasons = subtype ? (subtype.reasons || []) : (domain.reasons || []);
|
||
const messages = subtype ? (subtype.messages || []) : (domain.messages || []);
|
||
const tasks = subtype ? (subtype.tasks || []) : (domain.tasks || []);
|
||
|
||
detailNote.textContent = subtype
|
||
? `${domain.label} / ${subtype.label}`
|
||
: `${domain.label}`;
|
||
|
||
container.innerHTML = `
|
||
<div class="detail-summary">
|
||
<div class="summary-title">
|
||
<h4 class="mb-0">${escapeHtml(subtype ? subtype.label : domain.label)}</h4>
|
||
<span class="badge bg-body-secondary">${tasks.length} 个任务样本</span>
|
||
</div>
|
||
<div class="detail-toolbar">
|
||
<span class="metric-pill">耗时 ${formatSeconds((subtype ? subtype.duration_seconds : domain.duration_seconds) || 0)}</span>
|
||
<span class="metric-pill">占整体 ${formatShare((subtype ? subtype.overall_share_percent : domain.overall_share_percent) || 0)}</span>
|
||
<span class="metric-pill">占错误 ${formatShare((subtype ? subtype.error_share_percent : domain.error_share_percent) || 0)}</span>
|
||
${subtype ? `<span class="metric-pill">占大类 ${formatShare(subtype.domain_share_percent || 0)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
<ul class="nav nav-tabs mb-3">
|
||
<li class="nav-item"><button class="nav-link ${activeFailureTab === "subtypes" ? "active" : ""}" type="button" data-detail-tab="subtypes">小错误</button></li>
|
||
<li class="nav-item"><button class="nav-link ${activeFailureTab === "reasons" ? "active" : ""}" type="button" data-detail-tab="reasons">原因</button></li>
|
||
<li class="nav-item"><button class="nav-link ${activeFailureTab === "messages" ? "active" : ""}" type="button" data-detail-tab="messages">消息</button></li>
|
||
<li class="nav-item"><button class="nav-link ${activeFailureTab === "tasks" ? "active" : ""}" type="button" data-detail-tab="tasks">任务样本</button></li>
|
||
</ul>
|
||
<div class="tab-content">
|
||
<div class="tab-pane ${activeFailureTab === "subtypes" ? "active" : ""}">
|
||
<div class="detail-scroll">
|
||
<table class="table mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>小错误</th>
|
||
<th>耗时</th>
|
||
<th>占整体</th>
|
||
<th>占大类</th>
|
||
<th>任务数</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${(domain.subtypes || []).map((item) => `
|
||
<tr class="subtype-row ${selectedFailureSubtype === `${item.domain}:${item.subtype}` ? "active" : ""}" data-subtype-select="${escapeHtml(`${item.domain}:${item.subtype}`)}">
|
||
<td>${escapeHtml(item.label)}</td>
|
||
<td>${formatSeconds(item.duration_seconds)}</td>
|
||
<td>${formatShare(item.overall_share_percent)}</td>
|
||
<td>${formatShare(item.domain_share_percent)}</td>
|
||
<td>${item.task_count || 0}</td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="tab-pane ${activeFailureTab === "reasons" ? "active" : ""}">
|
||
<div class="detail-scroll">
|
||
<table class="table mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>原因</th>
|
||
<th>耗时</th>
|
||
<th>占整体</th>
|
||
<th>${subtype ? "占小错误" : "占大类"}</th>
|
||
<th>任务数</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${reasons.map((item) => `
|
||
<tr>
|
||
<td>${escapeHtml(item.label)}</td>
|
||
<td>${formatSeconds(item.duration_seconds)}</td>
|
||
<td>${formatShare(item.overall_share_percent)}</td>
|
||
<td>${formatShare(subtype ? item.subtype_share_percent : item.domain_share_percent)}</td>
|
||
<td>${item.task_count || 0}</td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="tab-pane ${activeFailureTab === "messages" ? "active" : ""}">
|
||
<div class="detail-scroll">
|
||
<table class="table mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>原始消息</th>
|
||
<th>耗时</th>
|
||
<th>占整体</th>
|
||
<th>${subtype ? "占小错误" : "占大类"}</th>
|
||
<th>任务数</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${messages.map((item) => `
|
||
<tr>
|
||
<td class="message-cell">${escapeHtml(item.message || item.label)}</td>
|
||
<td>${formatSeconds(item.duration_seconds)}</td>
|
||
<td>${formatShare(item.overall_share_percent)}</td>
|
||
<td>${formatShare(subtype ? item.subtype_share_percent : item.domain_share_percent)}</td>
|
||
<td>${item.task_count || 0}</td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="tab-pane ${activeFailureTab === "tasks" ? "active" : ""}">
|
||
<div class="detail-scroll">
|
||
<div class="table-responsive">
|
||
<table class="table mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>任务</th>
|
||
<th>Worker</th>
|
||
<th>失败原因</th>
|
||
<th>消息</th>
|
||
<th>过程标记</th>
|
||
<th>失败耗时</th>
|
||
<th>开始时间</th>
|
||
<th>结束时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${tasks.map((item) => `
|
||
<tr>
|
||
<td>
|
||
<div>${escapeHtml(item.app_name)}</div>
|
||
<div class="mono">${escapeHtml(item.package_name)}</div>
|
||
</td>
|
||
<td class="mono">${escapeHtml(item.worker_id)}</td>
|
||
<td>${escapeHtml(item.failure_reason)}</td>
|
||
<td class="message-cell">${escapeHtml(item.error_message || "--")}</td>
|
||
<td>${escapeHtml(item.diagnostics_note || "--")}</td>
|
||
<td>${formatSeconds(item.failed_total_duration_seconds || item.total_duration_seconds)}</td>
|
||
<td>${escapeHtml(item.task_started_at)}</td>
|
||
<td>${escapeHtml(item.task_ended_at)}</td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.querySelectorAll("[data-detail-tab]").forEach((node) => {
|
||
node.addEventListener("click", () => {
|
||
activeFailureTab = node.dataset.detailTab;
|
||
renderFailureDetail(distributions);
|
||
bindTooltipTargets();
|
||
});
|
||
});
|
||
container.querySelectorAll("[data-subtype-select]").forEach((node) => {
|
||
node.addEventListener("click", () => {
|
||
selectedFailureSubtype = node.dataset.subtypeSelect;
|
||
activeFailureTab = "reasons";
|
||
renderFailureAnalysis(distributions);
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderFailureAnalysis(distributions) {
|
||
document.getElementById("failure-domain-note").textContent = `${distributions.non_success_task_count || 0} 个严重受限任务样本`;
|
||
renderFailureDomainBreakdown(distributions);
|
||
renderFailureDetail(distributions);
|
||
bindTooltipTargets();
|
||
}
|
||
|
||
function renderRuntimeStateBreakdown(containerId, noteId, items) {
|
||
const container = document.getElementById(containerId);
|
||
const note = document.getElementById(noteId);
|
||
if (!items || !items.length) {
|
||
note.textContent = "暂无数据";
|
||
container.innerHTML = '<div class="empty">当前没有可展示的状态累计时长</div>';
|
||
return;
|
||
}
|
||
const totalSeconds = items.reduce((sum, item) => sum + Number(item.duration_seconds || 0), 0);
|
||
note.textContent = `累计状态时长 ${formatSeconds(totalSeconds)}`;
|
||
const max = Math.max(...items.map((item) => item.share_percent || 0), 1);
|
||
container.innerHTML = `
|
||
<div class="chart-card">
|
||
<div class="chart-title"><strong>状态累计时长</strong><span>按 worker 状态事件聚合</span></div>
|
||
<div class="bar-list">
|
||
${items.map((item) => `
|
||
<div>
|
||
<div class="bar-row">
|
||
<div class="bar-name" title="${item.label}">${item.label}</div>
|
||
<div>
|
||
<div class="bar-track">
|
||
<div class="bar-fill ${item.state === "offline" || item.state === "disabled" ? "subtle" : ""}" style="width:${((item.share_percent || 0) / max) * 100}%"></div>
|
||
</div>
|
||
<div class="bar-meta">
|
||
<span>${formatSeconds(item.duration_seconds)}</span>
|
||
<span>占比 ${Number(item.share_percent || 0).toFixed(1)}%</span>
|
||
</div>
|
||
</div>
|
||
<div class="bar-value">${formatSeconds(item.duration_seconds)}</div>
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderWorkerCollectShareBreakdown(containerId, noteId, items, totalCollectSeconds) {
|
||
const container = document.getElementById(containerId);
|
||
const note = document.getElementById(noteId);
|
||
if (!items || !items.length) {
|
||
note.textContent = "暂无数据";
|
||
container.innerHTML = '<div class="empty">当前没有可比较的 Worker 阶段耗时</div>';
|
||
return;
|
||
}
|
||
note.textContent = `累计采集 ${formatSeconds(totalCollectSeconds || 0)},按 Worker 比较采集 / 下载阶段占比`;
|
||
const max = Math.max(...items.map((item) => Number(item.collect_share_percent || 0)), 1);
|
||
container.innerHTML = `
|
||
<div class="bar-list">
|
||
${items.map((item) => `
|
||
<div>
|
||
<div class="bar-row">
|
||
<div class="bar-name" title="${escapeHtml(item.label)}">${escapeHtml(item.label)}</div>
|
||
<div>
|
||
<div class="bar-track">
|
||
<div class="bar-fill ok" style="width:${((item.collect_share_percent || 0) / max) * 100}%"></div>
|
||
</div>
|
||
<div class="bar-meta">
|
||
<span>采集 ${formatSeconds(item.collect_duration_seconds)}</span>
|
||
<span>下载 ${formatSeconds(item.download_duration_seconds)}</span>
|
||
</div>
|
||
<div class="bar-meta">
|
||
<span>占本机阶段 ${formatShare(item.collect_share_percent)}</span>
|
||
<span>占全体采集 ${formatShare(item.overall_collect_share_percent)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="bar-value">${item.task_count || 0} 任务</div>
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderFailureSubtypeBreakdown(containerId, items) {
|
||
const container = document.getElementById(containerId);
|
||
if (!items || !items.length) {
|
||
container.innerHTML = '<div class="empty">暂无失败细分数据</div>';
|
||
return;
|
||
}
|
||
const max = Math.max(...items.map((item) => item.duration_seconds || 0), 1);
|
||
container.innerHTML = `
|
||
<div class="chart-card">
|
||
<div class="chart-title"><strong>失败细分</strong><span>共 ${items.length} 项</span></div>
|
||
<div class="bar-list">
|
||
${items.map((item) => `
|
||
<div>
|
||
<div class="bar-row">
|
||
<div class="bar-name" title="${item.label}">${item.label}</div>
|
||
<div>
|
||
<div class="bar-track">
|
||
<div class="bar-fill subtle" style="width:${((item.duration_seconds || 0) / max) * 100}%"></div>
|
||
</div>
|
||
<div class="bar-meta">
|
||
<span>${item.count || 0} 次</span>
|
||
<span>平均 ${formatSeconds(item.avg_duration_seconds)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="bar-value">${formatSeconds(item.duration_seconds)}</div>
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderWorkers(workers) {
|
||
document.getElementById("worker-count").textContent = `${workers.length} 台`;
|
||
document.getElementById("tab-badge-workers").textContent = `${workers.length}`;
|
||
const body = document.getElementById("workers-body");
|
||
if (!workers.length) {
|
||
selectedWorkerSet = new Set();
|
||
body.innerHTML = '<tr><td colspan="14" class="empty">暂无数据</td></tr>';
|
||
workerSelectionNote.textContent = "未选择 Worker";
|
||
selectAllWorkers.checked = false;
|
||
saveDashboardUiState();
|
||
return;
|
||
}
|
||
const visibleWorkerIds = new Set(workers.map((worker) => worker.worker_id));
|
||
selectedWorkerSet = new Set(Array.from(selectedWorkerSet).filter((workerId) => visibleWorkerIds.has(workerId)));
|
||
body.innerHTML = workers.map((worker) => `
|
||
<tr>
|
||
<td class="checkbox-cell"><input data-worker-select="1" type="checkbox" value="${worker.worker_id}" ${selectedWorkerSet.has(worker.worker_id) ? "checked" : ""}></td>
|
||
<td>
|
||
<div class="mono">${worker.worker_id}</div>
|
||
<div class="muted">${worker.ip_address || "--"}</div>
|
||
</td>
|
||
<td>${deviceTypeTag(worker.device_type)}</td>
|
||
<td>${statusBadge(worker.status)}</td>
|
||
<td>${statusBadge(worker.control_status)}</td>
|
||
<td>${worker.dispatch_enabled ? statusBadge("enabled") : statusBadge("disabled")}</td>
|
||
<td>${worker.task_count || 0}</td>
|
||
<td>${formatSeconds(worker.avg_download_duration_seconds)}</td>
|
||
<td>${formatSeconds(worker.avg_collect_duration_seconds)}</td>
|
||
<td>${formatSeconds(worker.running_seconds)}</td>
|
||
<td>${formatSeconds(worker.idle_waiting_seconds)}</td>
|
||
<td>${worker.failed_count || 0}</td>
|
||
<td>${worker.last_task_at || "--"}</td>
|
||
<td>
|
||
<div class="status-note">
|
||
<div>${worker.last_action || "--"} / ${worker.last_action_status || "--"}</div>
|
||
<div class="muted" title="${worker.last_action_message || ""}">${worker.last_action_message || "--"}</div>
|
||
<div class="muted">${formatDateTime(worker.last_action_at)}</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join("");
|
||
document.querySelectorAll('input[data-worker-select="1"]').forEach((node) => {
|
||
node.addEventListener("change", () => {
|
||
if (node.checked) {
|
||
selectedWorkerSet.add(node.value);
|
||
} else {
|
||
selectedWorkerSet.delete(node.value);
|
||
}
|
||
saveDashboardUiState();
|
||
refreshWorkerSelectionNote();
|
||
});
|
||
});
|
||
saveDashboardUiState();
|
||
refreshWorkerSelectionNote();
|
||
}
|
||
|
||
function renderTasks(tasks) {
|
||
document.getElementById("task-count").textContent = `${tasks.length} 条`;
|
||
document.getElementById("tab-badge-tasks").textContent = `${tasks.length}`;
|
||
const body = document.getElementById("tasks-body");
|
||
if (!tasks.length) {
|
||
body.innerHTML = '<tr><td colspan="11" class="empty">暂无数据</td></tr>';
|
||
return;
|
||
}
|
||
body.innerHTML = tasks.map((task) => `
|
||
<tr>
|
||
<td>
|
||
<div>${task.app_name}</div>
|
||
<div class="mono">${task.package_name}</div>
|
||
</td>
|
||
<td class="mono">${task.worker_id}</td>
|
||
<td>${statusBadge(task.status)}</td>
|
||
<td>${formatSeconds(task.download_duration_seconds)}</td>
|
||
<td>${formatSeconds(task.collect_duration_seconds)}</td>
|
||
<td>${formatSeconds(task.total_duration_seconds || task.failed_total_duration_seconds)}</td>
|
||
<td>${task.failure_domain_label || "--"}</td>
|
||
<td title="${task.failure_reason || '--'}">${task.failure_reason || "--"}</td>
|
||
<td title="${task.diagnostics_note || '--'}">${task.diagnostics_note || "--"}</td>
|
||
<td>${task.task_started_at}</td>
|
||
<td>${task.task_ended_at}</td>
|
||
</tr>
|
||
`).join("");
|
||
}
|
||
|
||
async function resetMonitoring() {
|
||
const confirmed = window.confirm("将清空当前监控数据库,并从当前时刻重新开始记录。此操作不会影响任务分发和 Worker 正在运行的任务。是否继续?");
|
||
if (!confirmed) return;
|
||
resetBtn.disabled = true;
|
||
try {
|
||
const response = await fetch("/api/monitor/reset", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: "{}",
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok || !payload.ok) {
|
||
throw new Error(payload.error || "重置失败");
|
||
}
|
||
datePicker.value = todayStr();
|
||
window.alert(`看板已重置,新的记录已开始。\n重置时间: ${payload.reset_at}\n已续接 ${payload.reseeded_workers || 0} 台在线 Worker。`);
|
||
await refresh("manual");
|
||
} catch (error) {
|
||
console.error(error);
|
||
window.alert(`重置看板失败: ${error.message || error}`);
|
||
} finally {
|
||
resetBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function runWorkerAction(action, options = null) {
|
||
const workerIds = selectedWorkerIds();
|
||
if (!workerIds.length) {
|
||
window.alert("请先选择至少一台 Worker。");
|
||
return;
|
||
}
|
||
const actionLabel = workerActionLabels[action] || action;
|
||
if ((action === "stop_worker" || action === "reboot") && !window.confirm(`确认对 ${workerIds.length} 台 Worker 执行“${actionLabel}”?`)) {
|
||
return;
|
||
}
|
||
const body = { worker_ids: workerIds, action };
|
||
if (options) {
|
||
body.options = options;
|
||
}
|
||
const startedAt = new Date();
|
||
workerActionButtons.forEach((button) => { button.disabled = true; });
|
||
renderWorkerActionFeedback({
|
||
phase: "running",
|
||
action,
|
||
actionLabel,
|
||
workerIds,
|
||
startedAt,
|
||
});
|
||
try {
|
||
const response = await fetch("/api/workers/actions/submit", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok || !payload.ok) {
|
||
throw new Error(payload.error || "worker action failed");
|
||
}
|
||
const job = payload.job || {};
|
||
renderWorkerActionFeedback(buildFeedbackStateFromJob(job, actionLabel));
|
||
await pollWorkerActionJob(job.job_id, actionLabel);
|
||
} catch (error) {
|
||
console.error(error);
|
||
activeWorkerActionJobId = "";
|
||
renderWorkerActionFeedback({
|
||
phase: "failed",
|
||
action,
|
||
actionLabel,
|
||
workerIds,
|
||
startedAt,
|
||
finishedAt: new Date(),
|
||
error: error.message || String(error),
|
||
});
|
||
window.alert(`Worker 操作失败: ${error.message || error}`);
|
||
} finally {
|
||
workerActionButtons.forEach((button) => { button.disabled = false; });
|
||
}
|
||
}
|
||
|
||
function syncAnalyticsStateFromInputs() {
|
||
analyticsSearch = analyticsSearchInput.value.trim();
|
||
analyticsCollectionStatus = analyticsCollectionSelect.value || "";
|
||
analyticsIncrementalBatchTag = analyticsBatchSelect.value || "";
|
||
analyticsTopN = normalizeAnalyticsTopN(analyticsTopNInput.value);
|
||
analyticsSort = analyticsSortSelect.value || "updated_at";
|
||
analyticsOrder = analyticsOrderSelect.value || "desc";
|
||
analyticsPage = Math.max(1, analyticsPage);
|
||
saveDashboardUiState();
|
||
}
|
||
|
||
function resetAnalyticsFilters() {
|
||
analyticsSearch = "";
|
||
analyticsCollectionStatus = "";
|
||
analyticsIncrementalBatchTag = "";
|
||
analyticsTopN = 3000;
|
||
analyticsSort = "updated_at";
|
||
analyticsOrder = "desc";
|
||
analyticsPage = 1;
|
||
applyAnalyticsStateToInputs();
|
||
saveDashboardUiState();
|
||
}
|
||
|
||
async function triggerAnalyticsRebuild() {
|
||
if (!selectedAnalyticsPackage) {
|
||
window.alert("请先在左侧选择一个应用。");
|
||
return;
|
||
}
|
||
analyticsRebuildBtn.disabled = true;
|
||
setAnalyticsActionFeedback({
|
||
phase: "running",
|
||
title: "单应用重算",
|
||
subtitle: selectedAnalyticsPackage,
|
||
message: "正在创建 manual_rebuild job ...",
|
||
});
|
||
try {
|
||
const response = await fetch(`/api/analytics/apps/${encodeURIComponent(selectedAnalyticsPackage)}/rebuild`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: "{}",
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok || !payload.ok) {
|
||
throw new Error(payload.error || "analytics rebuild failed");
|
||
}
|
||
setAnalyticsActionFeedback({
|
||
phase: "completed",
|
||
title: "单应用重算",
|
||
subtitle: selectedAnalyticsPackage,
|
||
message: "已成功入队,稍后会自动刷新详情。",
|
||
});
|
||
await refresh("manual");
|
||
} catch (error) {
|
||
console.error(error);
|
||
setAnalyticsActionFeedback({
|
||
phase: "failed",
|
||
title: "单应用重算",
|
||
subtitle: selectedAnalyticsPackage,
|
||
message: error.message || String(error),
|
||
});
|
||
} finally {
|
||
analyticsRebuildBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function triggerAnalyticsBackfill() {
|
||
if (!window.confirm("确认触发全量回灌?这会清空并重建全部应用画像。")) {
|
||
return;
|
||
}
|
||
analyticsBackfillBtn.disabled = true;
|
||
setAnalyticsActionFeedback({
|
||
phase: "running",
|
||
title: "全量回灌",
|
||
subtitle: "全部应用",
|
||
message: "正在创建 backfill job ...",
|
||
});
|
||
try {
|
||
const response = await fetch("/api/analytics/backfill", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ scope: "all" }),
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok || !payload.ok) {
|
||
throw new Error(payload.error || "analytics backfill failed");
|
||
}
|
||
setAnalyticsActionFeedback({
|
||
phase: "completed",
|
||
title: "全量回灌",
|
||
subtitle: "全部应用",
|
||
message: "已成功入队,后台会按名单重建全部画像。",
|
||
});
|
||
await refresh("manual");
|
||
} catch (error) {
|
||
console.error(error);
|
||
setAnalyticsActionFeedback({
|
||
phase: "failed",
|
||
title: "全量回灌",
|
||
subtitle: "全部应用",
|
||
message: error.message || String(error),
|
||
});
|
||
} finally {
|
||
analyticsBackfillBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function refresh(mode = "manual") {
|
||
const date = datePicker.value || todayStr();
|
||
updateDashboardContext();
|
||
if (mode === "poll" && activeRefreshController) {
|
||
return;
|
||
}
|
||
const requestSeq = ++refreshRequestSeq;
|
||
if (activeRefreshController) {
|
||
activeRefreshController.abort();
|
||
}
|
||
const controller = new AbortController();
|
||
activeRefreshController = controller;
|
||
setLoadingState(true);
|
||
const query = new URLSearchParams({ date });
|
||
if (currentAnalysisWorkerId) {
|
||
query.set("worker_id", currentAnalysisWorkerId);
|
||
}
|
||
const analyticsShouldLoadFull = activeView === "analytics";
|
||
const shouldLoadDistributions = activeView === "overview" || activeView === "failures";
|
||
const shouldLoadWorkers = activeView === "workers";
|
||
const shouldLoadTasks = activeView === "tasks";
|
||
const shouldLoadAnalyticsJobs = activeView === "analytics";
|
||
const analyticsScopeQuery = new URLSearchParams({
|
||
top_n: String(normalizeAnalyticsTopN(analyticsTopN)),
|
||
});
|
||
if (analyticsIncrementalBatchTag) {
|
||
analyticsScopeQuery.set("incremental_batch_tag", analyticsIncrementalBatchTag);
|
||
}
|
||
const analyticsAppsQuery = new URLSearchParams({
|
||
page: String(analyticsPage),
|
||
page_size: "14",
|
||
top_n: String(normalizeAnalyticsTopN(analyticsTopN)),
|
||
sort: analyticsSort,
|
||
order: analyticsOrder,
|
||
});
|
||
if (analyticsSearch) analyticsAppsQuery.set("q", analyticsSearch);
|
||
if (analyticsCollectionStatus) analyticsAppsQuery.set("collection_status", analyticsCollectionStatus);
|
||
if (analyticsIncrementalBatchTag) analyticsAppsQuery.set("incremental_batch_tag", analyticsIncrementalBatchTag);
|
||
try {
|
||
const overviewPromise = fetch(`/api/monitor/overview?${query.toString()}`, { cache: "no-store", signal: controller.signal }).then((r) => r.json());
|
||
const analyticsOverviewPromise = fetch(`/api/analytics/overview?${analyticsScopeQuery.toString()}`, { cache: "no-store", signal: controller.signal }).then((r) => r.json());
|
||
const [overview, analyticsOverview] = await Promise.all([overviewPromise, analyticsOverviewPromise]);
|
||
if (requestSeq !== refreshRequestSeq || controller !== activeRefreshController) {
|
||
return;
|
||
}
|
||
|
||
updateWorkerFilterOptions(overview.worker_options || []);
|
||
renderSummary(overview, analyticsOverview || {});
|
||
renderAnalyticsOverview(analyticsOverview || {});
|
||
|
||
const requests = {
|
||
distributions: shouldLoadDistributions
|
||
? fetch(`/api/monitor/distributions?${query.toString()}`, { cache: "no-store", signal: controller.signal }).then((r) => r.json())
|
||
: Promise.resolve(null),
|
||
workers: shouldLoadWorkers
|
||
? fetch(`/api/workers/control?${query.toString()}`, { cache: "no-store", signal: controller.signal }).then((r) => r.json())
|
||
: Promise.resolve(null),
|
||
tasks: shouldLoadTasks
|
||
? fetch(`/api/monitor/tasks?${query.toString()}`, { cache: "no-store", signal: controller.signal }).then((r) => r.json())
|
||
: Promise.resolve(null),
|
||
analyticsJobs: shouldLoadAnalyticsJobs
|
||
? fetch(`/api/analytics/jobs?limit=6`, { cache: "no-store", signal: controller.signal }).then((r) => r.json())
|
||
: Promise.resolve(null),
|
||
analyticsApps: analyticsShouldLoadFull
|
||
? fetch(`/api/analytics/apps?${analyticsAppsQuery.toString()}`, { cache: "no-store", signal: controller.signal }).then((r) => r.json())
|
||
: Promise.resolve(null),
|
||
};
|
||
const {
|
||
distributions,
|
||
workers,
|
||
tasks,
|
||
analyticsJobs,
|
||
analyticsApps,
|
||
} = await (async () => {
|
||
const entries = await Promise.all(
|
||
Object.entries(requests).map(async ([key, promise]) => [key, await promise])
|
||
);
|
||
return Object.fromEntries(entries);
|
||
})();
|
||
if (requestSeq !== refreshRequestSeq || controller !== activeRefreshController) {
|
||
return;
|
||
}
|
||
|
||
if (analyticsShouldLoadFull) {
|
||
const items = (analyticsApps && analyticsApps.items) || [];
|
||
const visiblePackages = new Set(items.map((item) => item.package_name));
|
||
if (!selectedAnalyticsPackage || !visiblePackages.has(selectedAnalyticsPackage)) {
|
||
selectedAnalyticsPackage = items.length ? items[0].package_name : "";
|
||
saveDashboardUiState();
|
||
}
|
||
}
|
||
|
||
if (shouldLoadAnalyticsJobs) {
|
||
renderAnalyticsJobs(Array.isArray(analyticsJobs) ? analyticsJobs : []);
|
||
}
|
||
if (shouldLoadDistributions && distributions) {
|
||
renderDailyCollectionOverview(distributions);
|
||
renderTimeComposition(
|
||
"time-composition",
|
||
"time-composition-note",
|
||
distributions.time_composition || []
|
||
);
|
||
renderStageBreakdown(
|
||
"success-stage-breakdown",
|
||
"success-stage-note",
|
||
distributions.success_stage_breakdown || [],
|
||
distributions.success_task_count || 0
|
||
);
|
||
renderRuntimeStateBreakdown(
|
||
"runtime-state-breakdown",
|
||
"runtime-state-note",
|
||
distributions.runtime_state_breakdown || []
|
||
);
|
||
renderWorkerCollectShareBreakdown(
|
||
"worker-collect-share",
|
||
"worker-collect-share-note",
|
||
distributions.worker_collect_share_breakdown || [],
|
||
distributions.total_collect_duration_seconds || 0
|
||
);
|
||
renderFailureAnalysis(distributions);
|
||
renderHistogram("non-success-distribution", "严重受限任务总耗时", distributions.non_success_total_duration || [], "subtle");
|
||
}
|
||
if (shouldLoadWorkers && !shouldFreezeWorkerPanel(mode)) {
|
||
renderWorkers(workers || []);
|
||
}
|
||
if (shouldLoadTasks) {
|
||
renderTasks(tasks || []);
|
||
}
|
||
if (analyticsShouldLoadFull) {
|
||
renderAnalyticsApps(analyticsApps || { items: [], total: 0, page: analyticsPage, page_size: 14 });
|
||
updateAnalyticsSelectedRows();
|
||
if (selectedAnalyticsPackage) {
|
||
void loadAnalyticsDetail(selectedAnalyticsPackage, { preferCache: true, force: true });
|
||
} else {
|
||
renderAnalyticsDetail(null);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (error && error.name === "AbortError") {
|
||
return;
|
||
}
|
||
throw error;
|
||
} finally {
|
||
if (controller === activeRefreshController) {
|
||
activeRefreshController = null;
|
||
}
|
||
if (!activeRefreshController) {
|
||
setLoadingState(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
datePicker.value = todayStr();
|
||
todayBtn.addEventListener("click", () => {
|
||
datePicker.value = todayStr();
|
||
refresh("manual");
|
||
});
|
||
resetBtn.addEventListener("click", resetMonitoring);
|
||
datePicker.addEventListener("change", () => refresh("manual"));
|
||
analysisWorkerSelect.addEventListener("change", () => {
|
||
setCurrentAnalysisWorker(analysisWorkerSelect.value || "");
|
||
selectedFailureDomain = "";
|
||
selectedFailureSubtype = "";
|
||
activeFailureTab = "subtypes";
|
||
refresh("manual");
|
||
});
|
||
selectAllWorkers.addEventListener("change", () => {
|
||
const checked = selectAllWorkers.checked;
|
||
const workerNodes = Array.from(document.querySelectorAll('input[data-worker-select="1"]'));
|
||
selectedWorkerSet = checked ? new Set(workerNodes.map((node) => node.value)) : new Set();
|
||
workerNodes.forEach((node) => {
|
||
node.checked = checked;
|
||
});
|
||
saveDashboardUiState();
|
||
refreshWorkerSelectionNote();
|
||
});
|
||
viewButtons.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
setActiveView(button.dataset.view);
|
||
refresh("manual");
|
||
});
|
||
});
|
||
workerActionButtons.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const action = button.dataset.workerAction;
|
||
let options = null;
|
||
if (action === "run_pipeline") {
|
||
options = collectRunPipelineOptions();
|
||
} else if (action === "execute_command") {
|
||
const cmdInput = document.getElementById("execute-command-input");
|
||
const cmdVal = cmdInput ? cmdInput.value.trim() : "";
|
||
if (!cmdVal) {
|
||
alert("请输入要执行的命令");
|
||
return;
|
||
}
|
||
options = { command: cmdVal };
|
||
} else if (action === "force_reapk") {
|
||
const pkgInput = document.getElementById("force-reapk-input");
|
||
const pkgVal = pkgInput ? pkgInput.value.trim() : "";
|
||
if (!pkgVal) {
|
||
alert("请输入包名");
|
||
return;
|
||
}
|
||
options = { package_name: pkgVal };
|
||
}
|
||
runWorkerAction(action, options);
|
||
});
|
||
});
|
||
analyticsBackfillBtn.addEventListener("click", triggerAnalyticsBackfill);
|
||
analyticsRefreshBtn.addEventListener("click", () => refresh("manual"));
|
||
analyticsRebuildBtn.addEventListener("click", triggerAnalyticsRebuild);
|
||
analyticsClearBtn.addEventListener("click", () => {
|
||
resetAnalyticsFilters();
|
||
refresh("manual");
|
||
});
|
||
analyticsPrevBtn.addEventListener("click", () => {
|
||
if (analyticsPage <= 1) return;
|
||
analyticsPage -= 1;
|
||
saveDashboardUiState();
|
||
refresh("manual");
|
||
});
|
||
analyticsNextBtn.addEventListener("click", () => {
|
||
analyticsPage += 1;
|
||
saveDashboardUiState();
|
||
refresh("manual");
|
||
});
|
||
analyticsCollectionSelect.addEventListener("change", () => {
|
||
analyticsPage = 1;
|
||
syncAnalyticsStateFromInputs();
|
||
refresh("manual");
|
||
});
|
||
analyticsBatchSelect.addEventListener("change", () => {
|
||
analyticsPage = 1;
|
||
syncAnalyticsStateFromInputs();
|
||
refresh("manual");
|
||
});
|
||
analyticsTopNInput.addEventListener("change", () => {
|
||
analyticsPage = 1;
|
||
syncAnalyticsStateFromInputs();
|
||
applyAnalyticsStateToInputs();
|
||
refresh("manual");
|
||
});
|
||
analyticsTopNInput.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter") return;
|
||
analyticsPage = 1;
|
||
syncAnalyticsStateFromInputs();
|
||
applyAnalyticsStateToInputs();
|
||
refresh("manual");
|
||
});
|
||
analyticsSortSelect.addEventListener("change", () => {
|
||
analyticsPage = 1;
|
||
syncAnalyticsStateFromInputs();
|
||
refresh("manual");
|
||
});
|
||
analyticsOrderSelect.addEventListener("change", () => {
|
||
analyticsPage = 1;
|
||
syncAnalyticsStateFromInputs();
|
||
refresh("manual");
|
||
});
|
||
analyticsSearchInput.addEventListener("change", () => {
|
||
analyticsPage = 1;
|
||
syncAnalyticsStateFromInputs();
|
||
refresh("manual");
|
||
});
|
||
analyticsSearchInput.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter") return;
|
||
analyticsPage = 1;
|
||
syncAnalyticsStateFromInputs();
|
||
refresh("manual");
|
||
});
|
||
|
||
loadDashboardUiState();
|
||
applyRunPipelineDefaults();
|
||
applyAnalyticsStateToInputs();
|
||
setAnalyticsActionFeedback();
|
||
updateDashboardContext();
|
||
setActiveView(activeView);
|
||
refresh("manual");
|
||
setInterval(() => {
|
||
if (shouldAutoRefresh()) {
|
||
refresh("poll");
|
||
}
|
||
}, 5000);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def create_app(dispatcher=None):
|
||
if Flask is None:
|
||
raise RuntimeError("Flask is not installed. Run `pip install -r requirements.txt` first.")
|
||
|
||
dispatcher = dispatcher or RedisTaskDispatcher()
|
||
action_jobs = WorkerActionJobManager(dispatcher)
|
||
app = Flask(__name__)
|
||
|
||
def _get_date_arg() -> str:
|
||
return request.args.get("date") or datetime.now().strftime("%Y-%m-%d")
|
||
|
||
def _get_int_arg(name, default):
|
||
value = request.args.get(name, default)
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
def _parse_worker_action_request():
|
||
payload = request.get_json(silent=True) or {}
|
||
worker_ids = payload.get("worker_ids", [])
|
||
action = str(payload.get("action", "")).strip()
|
||
allowed_actions = {
|
||
"run_pipeline",
|
||
"start",
|
||
"drain",
|
||
"recover_and_start",
|
||
"enable",
|
||
"disable",
|
||
"clone",
|
||
"pull",
|
||
"pull_pcap_files",
|
||
"setup",
|
||
"restart_mumu",
|
||
"stop_worker",
|
||
"reboot",
|
||
"status",
|
||
"execute_command",
|
||
"fix_adb_connection",
|
||
"configure_mumu_network",
|
||
"recover_mumu_full",
|
||
"force_reapk",
|
||
}
|
||
if not isinstance(worker_ids, list) or not worker_ids:
|
||
return None, (jsonify({"ok": False, "error": "worker_ids is required"}), 400)
|
||
if not action:
|
||
return None, (jsonify({"ok": False, "error": "action is required"}), 400)
|
||
if action not in allowed_actions:
|
||
return None, (jsonify({"ok": False, "error": f"unsupported action: {action}"}), 400)
|
||
return {
|
||
"worker_ids": worker_ids,
|
||
"action": action,
|
||
"options": payload.get("options"),
|
||
}, None
|
||
|
||
@app.get("/")
|
||
def index():
|
||
return render_template_string(
|
||
TEMPLATE,
|
||
instance_name=INSTANCE_NAME,
|
||
redis_db=REDIS_DB,
|
||
timezone=MONITORING_TIMEZONE,
|
||
run_pipeline_defaults=json.dumps(RUN_PIPELINE_DEFAULT_OPTIONS, ensure_ascii=True),
|
||
default_date=datetime.now().strftime("%Y-%m-%d"),
|
||
)
|
||
|
||
@app.get("/api/monitor/overview")
|
||
def monitor_overview():
|
||
return jsonify(
|
||
dispatcher.get_monitoring_overview(
|
||
date_str=_get_date_arg(),
|
||
worker_id=request.args.get("worker_id"),
|
||
)
|
||
)
|
||
|
||
@app.get("/api/monitor/timeline")
|
||
def monitor_timeline():
|
||
return jsonify(
|
||
dispatcher.get_monitoring_timeline(
|
||
date_str=_get_date_arg(),
|
||
bucket_minutes=_get_int_arg("bucket_minutes", 15),
|
||
worker_id=request.args.get("worker_id"),
|
||
)
|
||
)
|
||
|
||
@app.get("/api/monitor/distributions")
|
||
def monitor_distributions():
|
||
return jsonify(
|
||
dispatcher.get_monitoring_distributions(
|
||
date_str=_get_date_arg(),
|
||
worker_id=request.args.get("worker_id"),
|
||
)
|
||
)
|
||
|
||
@app.get("/api/monitor/workers")
|
||
def monitor_workers():
|
||
return jsonify(dispatcher.get_monitoring_workers(date_str=_get_date_arg()))
|
||
|
||
@app.get("/api/workers/control")
|
||
def worker_control():
|
||
return jsonify(
|
||
dispatcher.get_worker_control_rows(
|
||
date_str=_get_date_arg(),
|
||
worker_id=request.args.get("worker_id"),
|
||
)
|
||
)
|
||
|
||
@app.post("/api/workers/actions")
|
||
def worker_actions():
|
||
try:
|
||
parsed, error_response = _parse_worker_action_request()
|
||
if error_response is not None:
|
||
return error_response
|
||
results = dispatcher.run_dashboard_action(parsed["worker_ids"], parsed["action"], options=parsed["options"])
|
||
return jsonify({"ok": True, "results": results})
|
||
except Exception as e:
|
||
logger = logging.getLogger("worker-actions")
|
||
logger.exception("Failed to run worker action")
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
@app.post("/api/workers/actions/submit")
|
||
def worker_actions_submit():
|
||
try:
|
||
parsed, error_response = _parse_worker_action_request()
|
||
if error_response is not None:
|
||
return error_response
|
||
job = action_jobs.submit(parsed["worker_ids"], parsed["action"], options=parsed["options"])
|
||
return jsonify({"ok": True, "job": job})
|
||
except Exception as e:
|
||
logger = logging.getLogger("worker-actions-submit")
|
||
logger.exception("Failed to submit worker action")
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
@app.get("/api/workers/actions/status")
|
||
def worker_actions_status():
|
||
job_id = str(request.args.get("job_id", "")).strip()
|
||
if not job_id:
|
||
return jsonify({"ok": False, "error": "job_id is required"}), 400
|
||
job = action_jobs.get(job_id)
|
||
if not job:
|
||
return jsonify({"ok": False, "error": "job not found"}), 404
|
||
return jsonify({"ok": True, "job": job})
|
||
|
||
@app.get("/api/monitor/tasks")
|
||
def monitor_tasks():
|
||
return jsonify(
|
||
dispatcher.get_monitoring_tasks(
|
||
date_str=_get_date_arg(),
|
||
worker_id=request.args.get("worker_id"),
|
||
limit=_get_int_arg("limit", 60),
|
||
)
|
||
)
|
||
|
||
@app.post("/api/monitor/reset")
|
||
def monitor_reset():
|
||
try:
|
||
return jsonify(dispatcher.reset_monitoring_dashboard())
|
||
except Exception as e:
|
||
logger = logging.getLogger("monitor-reset")
|
||
logger.exception("Failed to reset monitoring data")
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
@app.get("/api/analytics/overview")
|
||
def analytics_overview():
|
||
return jsonify(
|
||
dispatcher.get_analytics_overview(
|
||
incremental_batch_tag=str(request.args.get("incremental_batch_tag", "")).strip(),
|
||
top_n=_get_int_arg("top_n", 3000),
|
||
)
|
||
)
|
||
|
||
@app.get("/api/analytics/apps")
|
||
def analytics_apps():
|
||
return jsonify(
|
||
dispatcher.get_analytics_apps(
|
||
q=str(request.args.get("q", "")).strip(),
|
||
latest_status=str(request.args.get("latest_status", "")).strip(),
|
||
artifact_status=str(request.args.get("artifact_status", "")).strip(),
|
||
collection_status=str(request.args.get("collection_status", "")).strip(),
|
||
restriction_status=str(request.args.get("restriction_status", "")).strip(),
|
||
retryability=str(request.args.get("retryability", "")).strip(),
|
||
incremental_batch_tag=str(request.args.get("incremental_batch_tag", "")).strip(),
|
||
top_n=_get_int_arg("top_n", 3000),
|
||
sort=str(request.args.get("sort", "updated_at")).strip(),
|
||
order=str(request.args.get("order", "desc")).strip(),
|
||
page=_get_int_arg("page", 1),
|
||
page_size=_get_int_arg("page_size", 50),
|
||
)
|
||
)
|
||
|
||
@app.get("/api/analytics/apps/<path:package_name>")
|
||
def analytics_app_detail(package_name):
|
||
payload = dispatcher.get_analytics_app_detail(package_name)
|
||
if payload is None:
|
||
return jsonify({"ok": False, "error": "package not found"}), 404
|
||
return jsonify(payload)
|
||
|
||
@app.post("/api/analytics/apps/<path:package_name>/rebuild")
|
||
def analytics_app_rebuild(package_name):
|
||
if not str(package_name or "").strip():
|
||
return jsonify({"ok": False, "error": "package_name is required"}), 400
|
||
job = dispatcher.enqueue_analytics_rebuild(str(package_name).strip())
|
||
return jsonify({"ok": True, "job": job})
|
||
|
||
@app.get("/api/analytics/jobs")
|
||
def analytics_jobs():
|
||
return jsonify(
|
||
dispatcher.get_analytics_jobs(
|
||
job_type=str(request.args.get("job_type", "")).strip(),
|
||
status=str(request.args.get("status", "")).strip(),
|
||
package_name=str(request.args.get("package_name", "")).strip(),
|
||
limit=_get_int_arg("limit", 50),
|
||
)
|
||
)
|
||
|
||
@app.post("/api/analytics/backfill")
|
||
def analytics_backfill():
|
||
payload = request.get_json(silent=True) or {}
|
||
scope = str(payload.get("scope", "all") or "all").strip()
|
||
packages = payload.get("packages") or []
|
||
if scope not in {"all", "packages"}:
|
||
return jsonify({"ok": False, "error": "scope must be all or packages"}), 400
|
||
if scope == "packages":
|
||
if not isinstance(packages, list) or not packages:
|
||
return jsonify({"ok": False, "error": "packages is required when scope=packages"}), 400
|
||
packages = [str(item).strip() for item in packages if str(item).strip()]
|
||
if not packages:
|
||
return jsonify({"ok": False, "error": "packages is required when scope=packages"}), 400
|
||
else:
|
||
packages = None
|
||
job = dispatcher.enqueue_analytics_backfill(packages=packages)
|
||
return jsonify({"ok": True, "job": job})
|
||
|
||
return app
|
||
|
||
|
||
def run_dashboard(dispatcher=None, host=None, port=None):
|
||
silence_flask_runtime_logs()
|
||
app = create_app(dispatcher=dispatcher)
|
||
app.logger.disabled = True
|
||
app.run(
|
||
host=host or DASHBOARD_HOST,
|
||
port=port or DASHBOARD_PORT,
|
||
debug=False,
|
||
use_reloader=False,
|
||
threaded=True,
|
||
)
|
||
|
||
|
||
def main():
|
||
run_dashboard()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|