autool-dispatcher/dashboard_main.py
2026-06-17 19:50:39 +08:00

4932 lines
182 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
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()