Files
ip/index.html
T
2026-10-01 14:34:09 -04:00

284 lines
14 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>IPTV 运行状态</title>
<style>
:root { --bg:#eef3fb; --panel:#ffffff; --panel-2:#f6f9ff; --text:#1c2333; --muted:#667085; --line:#dfe7f5; --accent:#2d6cdf; --accent-2:#34a853; --warn:#f59e0b; --danger:#ef4444; --shadow:0 8px 18px rgba(21,37,63,0.08); }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); }
.container { max-width:1100px; margin:32px auto; padding:0 16px 40px; }
h1 { margin:0 0 8px; font-size:32px; }
.meta { color:var(--muted); margin-bottom:18px; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:16px; box-shadow:var(--shadow); }
.progress-card { padding:22px 20px; margin-bottom:18px; }
.row { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.badge { font-weight:700; color:var(--accent); background:#edf3ff; padding:6px 10px; border-radius:999px; }
.progress { height:14px; background:#edf2fb; border-radius:999px; overflow:hidden; margin:16px 0; border:1px solid var(--line); }
.progress-bar { width:0%; height:100%; background:linear-gradient(90deg, var(--accent), #6aa7ff); transition:width .35s ease; }
.mini-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(170px, 1fr)); gap:12px; }
.mini { background:var(--panel-2); border:1px solid var(--line); border-radius:12px; padding:12px 14px; }
.mini label { display:block; color:var(--muted); font-size:12px; margin-bottom:6px; }
.mini strong { font-size:20px; }
.grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin:18px 0; }
.stat { padding:16px 14px; }
.stat small { color:var(--muted); display:block; margin-bottom:6px; }
.stat .n { font-size:26px; font-weight:700; }
.section { margin-top:18px; }
.section h2 { margin:0 0 12px; font-size:20px; }
.stage-list, .probe-list, .run-list { display:grid; gap:10px; }
.stage-item, .probe-item, .run-item { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:12px 14px; }
.stage-head, .probe-head { display:flex; justify-content:space-between; align-items:center; gap:10px; }
.stage-name, .probe-name { font-weight:700; }
.muted { color:var(--muted); }
.tag { display:inline-block; padding:4px 8px; border-radius:999px; font-size:12px; font-weight:600; }
.tag.ok { background:#ebf9f0; color:#19763d; }
.tag.running { background:#edf3ff; color:var(--accent); }
.tag.wait { background:#f6f7fb; color:#59708d; }
.tag.err { background:#fff0f1; color:#c62828; }
.percent-line { margin-top:10px; height:8px; border-radius:999px; overflow:hidden; background:#edf2fb; }
.percent-fill { height:100%; border-radius:999px; background:linear-gradient(90deg, var(--accent-2), #7dd48a); }
.summary-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin:18px 0; }
.summary-box { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:14px; }
.summary-box .label { color:var(--muted); font-size:12px; margin-bottom:6px; }
.summary-box .value { font-size:22px; font-weight:700; }
.error-box { border:1px solid #f3d7d9; background:#fff5f6; color:#8b1e2f; border-radius:12px; padding:12px 14px; margin-top:18px; }
.probe-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:12px; }
.run-item { display:grid; grid-template-columns: 110px 1fr 90px 120px; gap:12px; align-items:center; }
.run-item .right { text-align:right; }
table { width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--line); border-radius:12px; overflow:hidden; }
th, td { padding:10px 12px; border-bottom:1px solid var(--line); text-align:left; }
th { background:#f7faff; }
a { color:var(--accent); text-decoration:none; }
@media (max-width: 720px) { .run-item { grid-template-columns:1fr; } .run-item .right { text-align:left; } }
</style>
</head>
<body>
<div class="container">
<h1>IPTV 运行状态</h1>
<div id="meta" class="meta">加载中...</div>
<div class="card progress-card">
<div class="row">
<h2 style="margin:0">总任务进度</h2>
<span id="progress-badge" class="badge">0%</span>
</div>
<div class="progress"><div id="progress-bar" class="progress-bar"></div></div>
<div class="mini-grid">
<div class="mini"><label>已完成阶段</label><strong id="stage-count">0/0</strong></div>
<div class="mini"><label>已耗时</label><strong id="elapsed-total">0s</strong></div>
<div class="mini"><label>当前阶段</label><strong id="current-stage">-</strong></div>
</div>
</div>
<div id="summary-grid" class="summary-grid"></div>
<div id="error-box" class="error-box" style="display:none"></div>
<div id="stream-cards" class="grid"></div>
<div class="section">
<h2>各阶段耗时</h2>
<div id="stage-list" class="stage-list"></div>
</div>
<div class="section">
<h2>GitHub / 阿里云探针</h2>
<div id="probe-list" class="probe-grid"></div>
</div>
<div class="section">
<h2>质量闭环</h2>
<div id="quality-box" class="card stage-item"></div>
</div>
<div class="section">
<h2>最近阶段记录</h2>
<div id="recent-runs" class="run-list"></div>
</div>
</div>
<script>
function fmtSeconds(v) {
const n = Number(v || 0);
if (n < 60) return n.toFixed(1) + 's';
const m = Math.floor(n / 60);
const s = Math.round(n % 60);
return m + 'm ' + s + 's';
}
function statusTag(state) {
if (state === 'ok') return '<span class="tag ok">完成</span>'
if (state === 'running') return '<span class="tag running">运行中</span>'
if (state === 'err') return '<span class="tag err">异常</span>'
if (state === 'failed') return '<span class="tag err">失败</span>'
return '<span class="tag wait">待处理</span>'
}
function renderSummary(job) {
const summary = job || {};
const health = summary.stage_health || {};
const items = [
{label: '任务状态', value: summary.state || 'unknown'},
{label: '当前阶段', value: summary.current_stage || '待定'},
{label: '已完成', value: `${health.ok || 0} / ${(health.ok || 0) + (health.running || 0) + (health.waiting || 0) + (health.err || 0)}`},
{label: '异常数', value: health.err || 0}
];
document.getElementById('summary-grid').innerHTML = items.map(item => `
<div class="summary-box">
<div class="label">${item.label}</div>
<div class="value">${item.value}</div>
</div>
`).join('');
const lastError = summary.last_error || '';
const errorBox = document.getElementById('error-box');
if (lastError && lastError !== '未发现异常') {
errorBox.style.display = 'block';
errorBox.textContent = '最近异常: ' + lastError;
} else {
errorBox.style.display = 'none';
errorBox.textContent = '';
}
}
function renderStageList(stages) {
const items = Object.entries(stages || {}).map(([name, stage]) => {
const label = stage.label || name;
const state = stage.state || 'wait';
const total = Number(stage.total || 0);
const current = Number(stage.current || 0);
const known = total > 0;
const pct = known ? Math.min(100, Math.round((current / total) * 100)) : (state === 'ok' ? 100 : 0);
const progressText = known ? `${current}/${total} · ${pct}%` : '实际计数未提供';
return `
<div class="stage-item">
<div class="stage-head">
<div class="stage-name">${label}</div>
${statusTag(state)}
</div>
<div class="muted">${state === 'ok' ? '耗时' : '已用'}: ${fmtSeconds(stage.elapsed_seconds || 0)} · ${progressText}</div>
<div class="percent-line"><div class="percent-fill" style="width:${pct}%"></div></div>
</div>
`;
});
return items.join('');
}
function batchTag(state) {
if (state === 'consumed') return '<span class="tag ok">已消费</span>';
if (state === 'submitted') return '<span class="tag running">已提交</span>';
if (state === 'rejected') return '<span class="tag err">已拒绝</span>';
if (state === 'expired') return '<span class="tag err">已过期</span>';
return '<span class="tag wait">待处理</span>';
}
function renderBatchRows(batches) {
if (!batches || !batches.length) return '<div class="muted" style="margin-top:6px">暂无批次记录</div>';
return batches.map(b => `
<div class="muted" style="margin-top:6px">
${batchTag(b.state)} ${(b.batch_id || '-').slice(-12)} · 提交于 ${b.created_at || '-'}${b.consumed_at ? ' · 回传于 ' + b.consumed_at : ''}${b.last_state_reason ? ' · ' + b.last_state_reason : ''}
</div>
`).join('');
}
function renderProbe(data) {
const summary = data.probe_summary || {};
const details = data.probe_details || {};
const as = details.aliyun_speed || {};
const items = [];
items.push({
name: '阿里云国内测速',
value: data.pending_probe ? (data.pending_probe.batch_id || '已提交') : '无待处理批次',
extra: data.pending_probe ? '过期: ' + (data.pending_probe.expires_at || '-') : '等待下一批次',
state: data.pending_probe ? 'running' : 'wait',
meta: `累计 ${summary.aliyun_total || 0} 批 · 已消费 ${summary.aliyun_consumed || 0} · 国内测速成功 ${as.ok || 0}/${as.total || 0}`
});
items.push({
name: 'GitHub deep 探针',
value: data.pending_github ? (data.pending_github.batch_id || '已提交') : '无待处理批次',
extra: data.pending_github ? '过期: ' + (data.pending_github.expires_at || '-') : '等待下一批次',
state: data.pending_github ? 'running' : 'wait',
meta: `累计 ${summary.github_total || 0} 批 · 已消费 ${summary.github_consumed || 0}`
});
const rows = [
'<div class="muted" style="margin-top:8px; font-weight:600">最近批次</div>' + renderBatchRows(details.aliyun_batches),
'<div class="muted" style="margin-top:10px; font-weight:600">最近批次</div>' + renderBatchRows(details.github_batches)
];
return items.map((item, i) => `
<div class="probe-item">
<div class="probe-head">
<div class="probe-name">${item.name}</div>
${statusTag(item.state)}
</div>
<div class="muted" style="margin-top:8px">${item.value}</div>
<div class="muted">${item.extra}</div>
<div class="muted" style="margin-top:6px; color:#2d6cdf; font-weight:600">${item.meta}</div>${rows[i]}
</div>
`).join('');
}
function renderRuns(rows) {
if (!rows || !rows.length) return '<div class="card stage-item">暂无最近阶段记录</div>'
return rows.map(r => `
<div class="run-item card">
<div>${(r.run_id || '-').slice(0, 12)}</div>
<div>${r.stage || '-'}</div>
<div>${r.status || '-'}</div>
<div class="right">${r.end_at || '-'}</div>
</div>
`).join('')
}
function loadData() {
fetch('status.json', {cache:'no-store'}).then(r => r.json()).then(d => {
const rt = d.runtime || {};
const job = d.job_summary || {};
const pct = Number(rt.progress_pct || 0);
document.getElementById('meta').textContent = `状态: ${rt.state || 'unknown'} · 更新于 ${d.generated_at || '-'}`;
document.getElementById('progress-badge').textContent = pct.toFixed(2) + '%';
document.getElementById('progress-bar').style.width = pct + '%';
document.getElementById('stage-count').textContent = `${rt.completed_stage_count || 0}/${rt.total_stage_count || 0}`;
document.getElementById('elapsed-total').textContent = fmtSeconds(rt.elapsed_seconds || 0);
document.getElementById('current-stage').textContent = rt.current_stage || '-';
renderSummary(job);
const streamCards = Object.entries(d.streams || {}).map(([k, v]) => `
<div class="card stat">
<small>${k}</small>
<div class="n">${v}</div>
</div>
`).join('') + `
<div class="card stat"><small>严格稳定</small><div class="n">${d.strict_streams || 0}</div></div>`;
streamCards += `<div class="card stat"><small>时间性观察</small><div class="n">${d.intermittent_streams || 0}</div></div>`;
document.getElementById('stream-cards').innerHTML = streamCards;
document.getElementById('stage-list').innerHTML = renderStageList(rt.stages || {});
document.getElementById('probe-list').innerHTML = renderProbe(d);
const quality = d.quality || {};
const qText = `GitHub 已深检 ${quality.github_total || 0} 条,可解码 ${quality.github_video_ok || 0} 条,风险事件 ${quality.github_risk_events || 0}。时间性观察线路 ${d.intermittent_streams || 0} 条,需跨时段复测后再进入严格列表。`;
document.getElementById('quality-box').innerHTML = `<div class="muted">${qText}</div>`;
document.getElementById('recent-runs').innerHTML = renderRuns(d.recent_runs || []);
}).catch(err => {
document.getElementById('meta').textContent = '状态读取失败: ' + err;
});
}
let refreshTimer = null;
function startRefresh() {
if (document.hidden || refreshTimer) return;
refreshTimer = setInterval(loadData, 10000);
}
function stopRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
document.addEventListener('visibilitychange', () => document.hidden ? stopRefresh() : startRefresh());
window.addEventListener('pagehide', stopRefresh);
loadData();
startRefresh();
</script>
</body>
</html>