#!/usr/bin/env python3
from __future__ import annotations

import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any

SOURCE_DIR = Path('/Users/bot1/.hermes/profiles/it/cron/output/a49833a87c60')
PROJECT_DIR = Path(__file__).resolve().parents[1]
RAW_REPORT_DIR = PROJECT_DIR / 'work' / 'raw_reports'
OUT_DIR = PROJECT_DIR / 'deliverables'
DATA_PATH = OUT_DIR / 'profile_audit_dashboard_data.json'
HTML_PATH = OUT_DIR / 'index.html'

SECRET_PAT = re.compile(r'(?i)(secret|token|key|password|access_token|refresh_token|app_secret|verification_token|encrypt_key)(\s*[:=]\s*)([^\s,;`]+)')


def redact(s: str) -> str:
    return SECRET_PAT.sub(lambda m: m.group(1) + m.group(2) + '[REDACTED]', s)


def split_md_row(line: str) -> list[str]:
    return [c.strip() for c in line.strip().strip('|').split('|')]


def parse_mem_cell(cell: str) -> dict[str, Any]:
    raw = cell.strip()
    if re.search(r'(?i)missing|read_error|不存在', raw):
        return {'chars': 0, 'pct': 0.0, 'status': 'missing', 'raw': raw}
    # Supported report formats:
    #   1950字/88.6%/高
    #   1950 / 88.6%
    #   1950/88.6%/高
    m = re.search(r'(\d+)\s*(?:字)?\s*/\s*([\d.]+)%\s*(?:/\s*([^/\s]+))?', raw)
    if not m:
        return {'chars': 0, 'pct': 0.0, 'status': '未知', 'raw': raw}
    pct = float(m.group(2))
    status = m.group(3) or ('临界' if pct >= 95 else '高' if pct >= 80 else '正常')
    return {'chars': int(m.group(1)), 'pct': pct, 'status': status, 'raw': raw}


def parse_int(cell: str) -> int:
    try:
        return int(re.sub(r'\D+', '', cell) or 0)
    except Exception:
        return 0


def parse_report(path: Path) -> dict[str, Any]:
    raw_text = redact(path.read_text(encoding='utf-8', errors='replace'))
    # Cron output includes the original prompt before "## Response"; parse only the actual delivered report.
    text = raw_text.split('## Response', 1)[1] if '## Response' in raw_text else raw_text
    date_match = re.search(r'每日 Profile 记忆与异常巡检报告 - (\d{4}-\d{2}-\d{2})', text)
    report_date = date_match.group(1) if date_match else path.name[:10]
    run_match = re.search(r'\*\*Run Time:\*\*\s*([^\n]+)', text)
    run_time = run_match.group(1).strip() if run_match else ''
    overview_match = re.search(r'总览：([^\n]+)', text)
    overview = overview_match.group(1).strip() if overview_match else ''

    rows: list[dict[str, Any]] = []
    for line in text.splitlines():
        if not line.startswith('|') or '---' in line or '机器' in line:
            continue
        cols = split_md_row(line)
        if len(cols) < 8:
            continue
        machine, profile, gateway, user_mem, memory_mem, timeout, errors, rating = cols[:8]
        if machine not in {'bot1', 'bot2'}:
            continue
        rows.append({
            'machine': machine,
            'profile': profile,
            'gateway': gateway,
            'user_mem': parse_mem_cell(user_mem),
            'memory_mem': parse_mem_cell(memory_mem),
            'timeouts_24h': parse_int(timeout),
            'errors_24h': parse_int(errors),
            'rating': rating,
        })

    red = sum(1 for r in rows if r['rating'] == '红')
    yellow = sum(1 for r in rows if r['rating'] == '黄')
    green = sum(1 for r in rows if r['rating'] == '绿')
    high_mem = sum(1 for r in rows if r['user_mem']['pct'] >= 80 or r['memory_mem']['pct'] >= 80)
    critical_mem = sum(1 for r in rows if r['user_mem']['pct'] >= 95 or r['memory_mem']['pct'] >= 95)
    gateway_bad = sum(1 for r in rows if re.search(r'停止|异常|stale|not running', r['gateway'], re.I))
    total_timeout = sum(r['timeouts_24h'] for r in rows)
    total_errors = sum(r['errors_24h'] for r in rows)

    def block_after(title: str, until_titles: list[str]) -> list[str]:
        lines = text.splitlines()
        start = None
        for i, line in enumerate(lines):
            if line.strip().startswith(title):
                start = i + 1
                break
        if start is None:
            return []
        out: list[str] = []
        for line in lines[start:]:
            stripped = line.strip()
            if any(stripped.startswith(t) for t in until_titles):
                break
            if stripped and not stripped.startswith('|'):
                out.append(stripped)
        return out

    def clean_anomaly_lines(lines: list[str]) -> list[str]:
        cleaned = []
        for line in lines:
            # Markdown bullets from the source report become real list bullets in the dashboard,
            # so strip the source bullet marker to avoid "• - ..." visual noise.
            cleaned.append(re.sub(r'^[-*]\s*', '', line).strip())
        return cleaned

    return {
        'source_file': str(path),
        'report_date': report_date,
        'run_time': run_time,
        'overview': overview,
        'rows': rows,
        'summary': {
            'profiles': len(rows),
            'red': red,
            'yellow': yellow,
            'green': green,
            'high_mem': high_mem,
            'critical_mem': critical_mem,
            'gateway_bad': gateway_bad,
            'timeouts_24h': total_timeout,
            'errors_24h': total_errors,
            'bot2_reachable': 'bot2 可达' in text,
        },
        'anomalies': clean_anomaly_lines(block_after('重点异常：', ['建议动作：'])),
        'suggestions': block_after('建议动作：', ['##', '# Cron Job:']),
    }


def severity_rank(row: dict[str, Any]) -> tuple:
    rating_rank = {'红': 0, '黄': 1, '绿': 2}.get(row['rating'], 3)
    max_mem = max(row['user_mem']['pct'], row['memory_mem']['pct'])
    return (rating_rank, -row['errors_24h'], -row['timeouts_24h'], -max_mem, row['machine'], row['profile'])


def build_data() -> dict[str, Any]:
    report_paths: list[Path] = []
    report_paths.extend(sorted(SOURCE_DIR.glob('*.md')))
    if RAW_REPORT_DIR.exists():
        report_paths.extend(sorted(RAW_REPORT_DIR.glob('*.md')))
    # De-dupe by report date and prefer the explicit raw_reports file generated by the dashboard cron.
    by_date: dict[str, dict[str, Any]] = {}
    for path in report_paths:
        parsed = parse_report(path)
        if parsed['rows']:
            by_date[parsed['report_date']] = parsed
    reports = sorted(by_date.values(), key=lambda r: r['report_date'])
    latest = reports[-1] if reports else {'rows': [], 'summary': {}, 'anomalies': [], 'suggestions': []}
    latest['rows'] = sorted(latest['rows'], key=severity_rank)
    trends = [{'date': r['report_date'], **r['summary']} for r in reports]
    return {
        'generated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
        'source_dir': str(SOURCE_DIR),
        'report_count': len(reports),
        'latest': latest,
        'trends': trends,
        'top_memory': sorted(latest['rows'], key=lambda r: max(r['user_mem']['pct'], r['memory_mem']['pct']), reverse=True)[:10],
        'top_errors': sorted(latest['rows'], key=lambda r: (r['errors_24h'], r['timeouts_24h']), reverse=True)[:10],
        'machines': sorted({r['machine'] for r in latest['rows']}),
    }


HTML = r'''<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="robots" content="noindex,nofollow" />
  <title>每日 Profile 记忆与异常巡检看板</title>
  <style>
    :root{color-scheme:light;--bg:#f7f4ee;--panel:rgba(255,255,255,.78);--ink:#1f2933;--muted:#6b7280;--line:rgba(31,41,51,.10);--green:#27855f;--green-bg:#e6f5ed;--yellow:#b98200;--yellow-bg:#fff0c2;--red:#c6453c;--red-bg:#ffe0dc;--blue:#3765a3;--shadow:0 18px 55px rgba(45,38,24,.10);--radius:22px;--mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;--serif:"Songti SC","STSong","Noto Serif CJK SC",Georgia,serif}
    *{box-sizing:border-box}body{margin:0;min-height:100vh;color:var(--ink);background:radial-gradient(circle at top left,rgba(55,101,163,.16),transparent 34rem),radial-gradient(circle at 85% 5%,rgba(198,69,60,.10),transparent 25rem),linear-gradient(180deg,#faf7f0 0%,var(--bg) 55%,#f2ede5 100%);font-family:var(--sans);-webkit-font-smoothing:antialiased}.page{width:min(1440px,calc(100vw - 40px));margin:0 auto;padding:36px 0 56px}.hero{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(360px,.8fr);gap:22px;align-items:stretch;margin-bottom:22px}.hero-main,.panel,.metric,.wide-panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);backdrop-filter:blur(18px)}.hero-main{padding:34px 36px;position:relative;overflow:hidden}.hero-main:after{content:"";position:absolute;inset:auto -90px -130px auto;width:340px;height:340px;background:radial-gradient(circle,rgba(31,41,51,.10),transparent 68%);border-radius:50%;pointer-events:none}.eyebrow{color:var(--blue);font-size:13px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;margin-bottom:18px}h1{font-family:var(--serif);font-weight:650;font-size:clamp(34px,4vw,58px);line-height:1.02;letter-spacing:-.045em;margin:0 0 18px;text-wrap:balance}.subtitle{max-width:840px;color:#4d5968;font-size:16px;line-height:1.75;margin:0}.hero-meta{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px}.pill{display:inline-flex;align-items:center;gap:7px;padding:8px 11px;border-radius:999px;background:rgba(255,255,255,.7);border:1px solid var(--line);color:#4b5563;font-size:12px;font-weight:650}.dot{width:8px;height:8px;border-radius:50%;background:var(--green);display:inline-block}.hero-side{display:grid;grid-template-columns:1fr 1fr;gap:14px}.metric{padding:20px;min-height:132px;display:flex;flex-direction:column;justify-content:space-between}.metric .label{color:var(--muted);font-size:13px;font-weight:650}.metric .value{font-family:var(--serif);font-size:38px;letter-spacing:-.04em;line-height:1;margin-top:18px}.metric .note{color:var(--muted);font-size:12px;margin-top:10px}.metric.red .value{color:var(--red)}.metric.yellow .value{color:var(--yellow)}.metric.blue .value{color:var(--blue)}.metric.green .value{color:var(--green)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin-top:22px}.wide-panel,.panel{padding:24px}.panel-title{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:18px}h2{margin:0;font-size:19px;letter-spacing:-.02em}.hint{color:var(--muted);font-size:12px;line-height:1.5}.chart{width:100%;min-height:240px}.bar-row{display:grid;grid-template-columns:150px minmax(90px,1fr) 72px;gap:12px;align-items:center;margin:13px 0}.bar-label{font-weight:700;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bar-label small{display:block;color:var(--muted);font-weight:550;margin-top:2px}.track{height:12px;border-radius:999px;background:rgba(31,41,51,.08);overflow:hidden}.fill{height:100%;border-radius:999px;background:var(--green);min-width:2px}.fill.high{background:var(--yellow)}.fill.critical{background:var(--red)}.fill.blue{background:var(--blue)}.bar-value{text-align:right;font-family:var(--mono);font-size:12px;color:#374151}.toolbar{display:flex;flex-wrap:wrap;gap:10px;margin:4px 0 18px;align-items:center}.seg{border:1px solid var(--line);background:rgba(255,255,255,.72);border-radius:999px;padding:8px 12px;cursor:pointer;font-size:13px;color:#4b5563}.seg.active{background:#1f2933;color:#fff;border-color:#1f2933}input[type=search]{min-width:260px;flex:1 1 260px;border:1px solid var(--line);border-radius:999px;padding:10px 14px;background:rgba(255,255,255,.78);font:inherit;outline:none}table{width:100%;border-collapse:separate;border-spacing:0 8px}th{text-align:left;color:var(--muted);font-size:12px;font-weight:700;padding:0 10px 6px}td{background:rgba(255,255,255,.76);border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:12px 10px;vertical-align:middle;font-size:13px}td:first-child{border-left:1px solid var(--line);border-radius:14px 0 0 14px}td:last-child{border-right:1px solid var(--line);border-radius:0 14px 14px 0}.profile-name{font-weight:800;letter-spacing:-.01em}.machine{color:var(--muted);font-size:12px;margin-top:3px}.badge{display:inline-flex;align-items:center;justify-content:center;min-width:36px;padding:4px 9px;border-radius:999px;font-size:12px;font-weight:800}.badge.red{color:var(--red);background:var(--red-bg)}.badge.yellow{color:var(--yellow);background:var(--yellow-bg)}.badge.green{color:var(--green);background:var(--green-bg)}.gateway.bad{color:var(--red);font-weight:750}.gateway.ok{color:var(--green);font-weight:750}.mem-mini{min-width:120px}.mem-line{display:flex;justify-content:space-between;gap:8px;font-family:var(--mono);font-size:11px;color:#4b5563;margin-bottom:5px}.list{margin:0;padding-left:1.15em;color:#3f4855;line-height:1.62;font-size:13px}.list li{margin:6px 0}.footer{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.7}.mono{font-family:var(--mono)}.empty{color:var(--muted);padding:28px;text-align:center;border:1px dashed var(--line);border-radius:16px}@media(max-width:1040px){.hero,.grid{grid-template-columns:1fr}.hero-side{grid-template-columns:repeat(4,1fr)}}@media(max-width:760px){.page{width:min(100vw - 24px,1440px);padding-top:18px}.hero-main{padding:24px}.hero-side{grid-template-columns:1fr 1fr}.grid{gap:14px}.wide-panel,.panel{padding:16px}table{min-width:980px}.table-wrap{overflow:auto}}
  </style>
  <style>
    .priority-panel{margin:0 0 22px;padding:24px}.priority-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:12px}.priority-card{border:1px solid rgba(198,69,60,.18);background:linear-gradient(180deg,rgba(255,224,220,.72),rgba(255,255,255,.72));border-radius:18px;padding:15px}.priority-card h3{margin:0 0 8px;font-size:16px}.priority-card p{margin:0;color:#4b5563;line-height:1.55;font-size:13px}.priority-card .tag{display:inline-flex;margin-bottom:8px;padding:4px 8px;border-radius:999px;background:var(--red-bg);color:var(--red);font-weight:800;font-size:12px}.routine-details{margin-top:14px;border-top:1px solid var(--line);padding-top:12px}.routine-details summary{cursor:pointer;color:#4b5563;font-weight:800}.routine-list{margin:10px 0 0;padding-left:20px;color:#4b5563;line-height:1.6}.routine-list li{margin:3px 0}
  </style>
</head>
<body>
  <main class="page">
    <section class="hero"><div class="hero-main"><div class="eyebrow">Hermes Profiles · Daily Audit</div><h1>每日 Profile 记忆与异常巡检看板</h1><p class="subtitle">把早上 7 点巡检报告转成可视化视图：一眼看出哪些 profile 记忆接近上限、哪些网关异常、哪些 profile 最近 24 小时错误/超时偏高。当前页面为静态 HTML，数据来自 cron 历史报告。</p><div class="hero-meta" id="heroMeta"></div></div><div class="hero-side" id="metricCards"></div></section>
    <section class="wide-panel priority-panel"><div class="panel-title"><div><h2>今日需要关注</h2><div class="hint">只把会明显影响使用体验的高优先级项放在这里：记忆临界、重连/错误集中、gateway 未运行。普通项折叠在下方。</div></div><div class="hint mono" id="priorityCount"></div></div><div id="priorityCards" class="priority-grid"></div><details class="routine-details"><summary>展开普通项 / 低优先级明细</summary><ul id="routineList" class="routine-list"></ul></details></section>
    <section class="grid"><div class="panel"><div class="panel-title"><div><h2>近日报告趋势</h2><div class="hint">红/黄项、临界记忆、错误量的变化。</div></div></div><div id="trendChart" class="chart"></div></div><div class="panel"><div class="panel-title"><div><h2>记忆使用 TOP 10</h2><div class="hint">取 USER 与 MEMORY 两项中的最大使用率。</div></div></div><div id="memoryBars"></div></div></section>
    <section class="grid"><div class="panel"><div class="panel-title"><div><h2>24h 错误 TOP 10</h2><div class="hint">用于优先定位模型/API/网关/工具异常。</div></div></div><div id="errorBars"></div></div><div class="panel"><div class="panel-title"><div><h2>重点异常与建议</h2><div class="hint">保留报告里的红/黄项摘要，敏感字段已脱敏。</div></div></div><div id="adviceBox"></div></div></section>
    <section class="wide-panel" style="margin-top:22px"><div class="panel-title"><div><h2>Profile 明细</h2><div class="hint">默认按风险排序：红项、错误量、超时量、记忆使用率。</div></div><div class="hint mono" id="rowCount"></div></div><div class="toolbar"><button class="seg active" data-filter="all">全部</button><button class="seg" data-filter="红">红</button><button class="seg" data-filter="黄">黄</button><button class="seg" data-filter="绿">绿</button><button class="seg" data-machine="bot1">bot1</button><button class="seg" data-machine="bot2">bot2</button><input type="search" id="search" placeholder="搜索 profile / gateway / 机器…" /></div><div class="table-wrap"><table><thead><tr><th>Profile</th><th>评级</th><th>Gateway</th><th>USER 记忆</th><th>MEMORY 记忆</th><th>24h 超时</th><th>24h 错误</th></tr></thead><tbody id="profileRows"></tbody></table></div></section>
    <div class="footer">数据源：<span class="mono" id="sourceDir"></span><br>说明：这是只读巡检结果的可视化，不会修改 profile、不会重启 gateway、不会清理 memory。要刷新页面，请运行 <span class="mono">python3 work/generate_dashboard.py</span>。</div>
  </main>
<script>
const DATA=__DATA__;
const $=s=>document.querySelector(s);const fmt=new Intl.NumberFormat('zh-CN');const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));const ratingClass=r=>r==='红'?'red':r==='黄'?'yellow':'green';const memClass=p=>p>=95?'critical':p>=80?'high':'';const latest=DATA.latest||{summary:{},rows:[]};
function renderHero(){const s=latest.summary||{};$('#heroMeta').innerHTML=[['最新报告',latest.report_date||'-'],['生成时间',DATA.generated_at||'-'],['历史报告',`${DATA.report_count||0} 份`],['bot2',s.bot2_reachable?'可达':'不可达/未知']].map(([k,v],i)=>`<span class="pill"><span class="dot" style="background:${i===3&&!s.bot2_reachable?'var(--red)':'var(--green)'}"></span>${k}：${esc(v)}</span>`).join('');const cards=[['检查 Profile',s.profiles??0,'覆盖 bot1 / bot2','blue'],['红色异常',s.red??0,`黄色 ${s.yellow??0} · 绿色 ${s.green??0}`,'red'],['临界记忆',s.critical_mem??0,`高记忆 ${s.high_mem??0}`,'yellow'],['24h 错误',fmt.format(s.errors_24h??0),`超时 ${fmt.format(s.timeouts_24h??0)} · gateway异常 ${s.gateway_bad??0}`,'green']];$('#metricCards').innerHTML=cards.map(([label,value,note,cls])=>`<div class="metric ${cls}"><div class="label">${label}</div><div><div class="value">${value}</div><div class="note">${note}</div></div></div>`).join('');$('#sourceDir').textContent=DATA.source_dir||''}
function impactReason(r){const parts=[];const u=Number(r.user_mem?.pct||0),m=Number(r.memory_mem?.pct||0),mx=Math.max(u,m);if(mx>=95){parts.push(`${u>=m?'USER':'MEMORY'} 记忆 ${mx.toFixed(1)}%，可能让 profile 变笨/遗忘规则`)}if((r.errors_24h||0)>=300||(r.timeouts_24h||0)>=50){parts.push(`24h 错误 ${fmt.format(r.errors_24h||0)}、超时 ${fmt.format(r.timeouts_24h||0)}，可能导致重连/请求失败`)}if(/停止|异常|stale|not running|stopped|unknown/i.test(r.gateway||'')&&r.profile!=='default'){parts.push(`gateway ${r.gateway}`)}return parts.join('；')||'红/黄项，建议复核'}
function isPriority(r){const mx=Math.max(Number(r.user_mem?.pct||0),Number(r.memory_mem?.pct||0));const gw=/停止|异常|stale|not running|stopped|unknown/i.test(r.gateway||'')&&r.profile!=='default';return mx>=95||(r.errors_24h||0)>=300||(r.timeouts_24h||0)>=50||gw}
function renderPriority(){const rows=latest.rows||[];const priority=rows.filter(isPriority);const routine=rows.filter(r=>!isPriority(r)&&r.rating!=='绿');$('#priorityCount').textContent=`${priority.length} high priority`;$('#priorityCards').innerHTML=priority.slice(0,8).map(r=>`<article class="priority-card"><span class="tag">${esc(r.rating)} · ${esc(r.machine)}</span><h3>${esc(r.profile)}</h3><p>${esc(impactReason(r))}</p></article>`).join('')||'<div class="empty" style="grid-column:1/-1">暂无会明显影响体验的高优先级项。</div>';$('#routineList').innerHTML=routine.slice(0,20).map(r=>`<li><b>${esc(r.machine)}/${esc(r.profile)}</b>：${esc(r.rating)}，USER ${Number(r.user_mem?.pct||0).toFixed(1)}%，MEMORY ${Number(r.memory_mem?.pct||0).toFixed(1)}%，错误 ${fmt.format(r.errors_24h||0)}，超时 ${fmt.format(r.timeouts_24h||0)}</li>`).join('')||'<li>暂无普通红/黄项。</li>'}
function renderTrend(){const t=DATA.trends||[];if(!t.length){$('#trendChart').innerHTML='<div class="empty">暂无历史趋势</div>';return}const w=680,h=245,p={l:42,r:16,t:18,b:42};const maxErr=Math.max(1,...t.map(d=>d.errors_24h||0));const maxRed=Math.max(1,...t.map(d=>d.red||0));const x=i=>p.l+(t.length===1?0:i*(w-p.l-p.r)/(t.length-1));const yErr=v=>p.t+(h-p.t-p.b)*(1-v/maxErr);const yRed=v=>p.t+(h-p.t-p.b)*(1-v/maxRed);const ptsErr=t.map((d,i)=>`${x(i)},${yErr(d.errors_24h||0)}`).join(' ');const ptsRed=t.map((d,i)=>`${x(i)},${yRed(d.red||0)}`).join(' ');const bars=t.map((d,i)=>{const bw=Math.max(6,(w-p.l-p.r)/Math.max(t.length,1)*.36);const bx=x(i)-bw/2,by=yRed(d.critical_mem||0),bh=h-p.b-by;return `<rect x="${bx}" y="${by}" width="${bw}" height="${bh}" rx="4" fill="rgba(185,130,0,.28)"><title>${d.date} 临界记忆 ${d.critical_mem||0}</title></rect>`}).join('');const labels=t.filter((_,i)=>i===0||i===t.length-1||i%3===0).map(d=>`<text x="${x(t.indexOf(d))}" y="${h-16}" text-anchor="middle" fill="#6b7280" font-size="11">${d.date.slice(5)}</text>`).join('');$('#trendChart').innerHTML=`<svg viewBox="0 0 ${w} ${h}" width="100%" height="100%" role="img" aria-label="趋势图"><line x1="${p.l}" x2="${w-p.r}" y1="${h-p.b}" y2="${h-p.b}" stroke="rgba(31,41,51,.12)"/><line x1="${p.l}" x2="${p.l}" y1="${p.t}" y2="${h-p.b}" stroke="rgba(31,41,51,.12)"/>${bars}<polyline points="${ptsErr}" fill="none" stroke="var(--blue)" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/><polyline points="${ptsRed}" fill="none" stroke="var(--red)" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>${t.map((d,i)=>`<circle cx="${x(i)}" cy="${yErr(d.errors_24h||0)}" r="3" fill="var(--blue)"><title>${d.date} 错误 ${d.errors_24h||0}</title></circle><circle cx="${x(i)}" cy="${yRed(d.red||0)}" r="3" fill="var(--red)"><title>${d.date} 红项 ${d.red||0}</title></circle>`).join('')}${labels}<text x="${p.l}" y="14" fill="#6b7280" font-size="11">蓝=24h错误量 · 红=红色profile数 · 黄柱=临界记忆数</text></svg>`}
function barRows(items,type){if(!items?.length)return'<div class="empty">暂无数据</div>';const max=Math.max(1,...items.map(r=>type==='error'?r.errors_24h:Math.max(r.user_mem.pct,r.memory_mem.pct)));return items.map(r=>{const val=type==='error'?r.errors_24h:Math.max(r.user_mem.pct,r.memory_mem.pct);const pct=Math.max(2,val/max*100);const cls=type==='error'?'blue':memClass(val);const sub=type==='error'?`${r.machine} · 超时 ${r.timeouts_24h}`:`${r.machine} · USER ${r.user_mem.pct}% · MEMORY ${r.memory_mem.pct}%`;const show=type==='error'?fmt.format(val):`${val.toFixed(1)}%`;return `<div class="bar-row"><div class="bar-label">${esc(r.profile)}<small>${esc(sub)}</small></div><div class="track"><div class="fill ${cls}" style="width:${pct}%"></div></div><div class="bar-value">${show}</div></div>`}).join('')}
function renderBars(){$('#memoryBars').innerHTML=barRows(DATA.top_memory,'memory');$('#errorBars').innerHTML=barRows(DATA.top_errors,'error')}
function renderAdvice(){const anomalies=(latest.anomalies||[]).filter(x=>!/^[-|]*$/.test(x)).slice(0,14);const suggestions=(latest.suggestions||[]).filter(x=>!/^[-|]*$/.test(x)).slice(0,7);$('#adviceBox').innerHTML=`<div style="display:grid;gap:16px"><div><div class="hint" style="margin-bottom:6px;font-weight:800;color:#374151">重点异常</div><ul class="list">${anomalies.map(x=>`<li>${esc(x)}</li>`).join('')||'<li>暂无重点异常。</li>'}</ul></div><div><div class="hint" style="margin-bottom:6px;font-weight:800;color:#374151">建议动作</div><ul class="list">${suggestions.map(x=>`<li>${esc(x)}</li>`).join('')||'<li>暂无建议。</li>'}</ul></div></div>`}
let filterRating='all',filterMachine='all',query='';function memCell(mem){const pct=Number(mem.pct||0);return `<div class="mem-mini"><div class="mem-line"><span>${mem.chars}字</span><span>${pct.toFixed(1)}%</span></div><div class="track" style="height:7px"><div class="fill ${memClass(pct)}" style="width:${Math.min(100,pct)}%"></div></div></div>`}function renderRows(){const rows=(latest.rows||[]).filter(r=>{const hitRating=filterRating==='all'||r.rating===filterRating;const hitMachine=filterMachine==='all'||r.machine===filterMachine;const txt=`${r.machine} ${r.profile} ${r.gateway} ${r.rating}`.toLowerCase();return hitRating&&hitMachine&&txt.includes(query.toLowerCase())});$('#rowCount').textContent=`${rows.length} / ${(latest.rows||[]).length} rows`;$('#profileRows').innerHTML=rows.map(r=>{const gwBad=/停止|异常|stale|not running/i.test(r.gateway||'');return `<tr><td><div class="profile-name">${esc(r.profile)}</div><div class="machine">${esc(r.machine)}</div></td><td><span class="badge ${ratingClass(r.rating)}">${esc(r.rating)}</span></td><td><span class="gateway ${gwBad?'bad':'ok'}">${esc(r.gateway)}</span></td><td>${memCell(r.user_mem)}</td><td>${memCell(r.memory_mem)}</td><td class="mono">${fmt.format(r.timeouts_24h)}</td><td class="mono">${fmt.format(r.errors_24h)}</td></tr>`}).join('')||`<tr><td colspan="7"><div class="empty">没有匹配的 profile</div></td></tr>`}
function wireFilters(){document.querySelectorAll('.seg').forEach(btn=>btn.addEventListener('click',()=>{if(btn.dataset.filter){filterRating=btn.dataset.filter;document.querySelectorAll('[data-filter]').forEach(b=>b.classList.toggle('active',b.dataset.filter===filterRating))}if(btn.dataset.machine){filterMachine=filterMachine===btn.dataset.machine?'all':btn.dataset.machine;document.querySelectorAll('[data-machine]').forEach(b=>b.classList.toggle('active',filterMachine===b.dataset.machine))}renderRows()}));$('#search').addEventListener('input',e=>{query=e.target.value||'';renderRows()})}
renderHero();renderPriority();renderTrend();renderBars();renderAdvice();renderRows();wireFilters();
</script>
</body>
</html>
'''


def json_for_html(data: dict[str, Any]) -> str:
    return json.dumps(data, ensure_ascii=False, separators=(',', ':')).replace('</', '<\\/')


def main() -> None:
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    data = build_data()
    DATA_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
    HTML_PATH.write_text(HTML.replace('__DATA__', json_for_html(data)), encoding='utf-8')
    print(json.dumps({
        'ok': True,
        'html': str(HTML_PATH),
        'data': str(DATA_PATH),
        'report_count': data['report_count'],
        'latest_date': data.get('latest', {}).get('report_date'),
        'profiles': data.get('latest', {}).get('summary', {}).get('profiles'),
        'red': data.get('latest', {}).get('summary', {}).get('red'),
        'critical_mem': data.get('latest', {}).get('summary', {}).get('critical_mem'),
    }, ensure_ascii=False))


if __name__ == '__main__':
    main()
