Incident: evening full snapshot could be recorded as ok with already_completed/elapsed=0 after mutual skip with hourly sync, then next_run jumped to the next day. - Full-site snapshot never skips because another task is running - 23:00-01:00 Asia/Shanghai: defer discovery / snapshot_recent / candidate_cleanup to after 01:00; fans never defers - Skip records ok=false + skipped=true (not success / not completed) - Skip/fail reschedule soon (~5m); daily success still next snapshot_at - TaskControl return value drives next_run; system UI shows skip fields
173 lines
8.2 KiB
JavaScript
173 lines
8.2 KiB
JavaScript
import { api, stats } from "../api.js";
|
|
import { emptyChart, mixedChart } from "../charts.js";
|
|
import { emptyState, sectionHeader } from "../components.js";
|
|
import {
|
|
escapeHtml, formatCount, formatDateTime, formatNumber,
|
|
} from "../format.js";
|
|
|
|
function taskResultLabel(record) {
|
|
const summary = record.summary && typeof record.summary === "object" ? record.summary : {};
|
|
if (record.skipped || summary.skipped) {
|
|
return `<span class="muted">跳过</span>`;
|
|
}
|
|
if (record.ok) return "成功";
|
|
return `<span class="danger">失败</span>`;
|
|
}
|
|
|
|
function taskRows(tasks) {
|
|
const entries = Object.entries(tasks || {});
|
|
if (!entries.length) return emptyState("暂无任务记录", "运行一次同步、快照或发现后会写入最近结果。");
|
|
return `<div class="table-wrap">
|
|
<table class="data-table">
|
|
<thead><tr><th>任务</th><th>结果</th><th>完成时间</th><th>摘要 / 错误</th></tr></thead>
|
|
<tbody>
|
|
${entries.map(([name, record]) => {
|
|
const summary = record.summary && typeof record.summary === "object"
|
|
? Object.entries(record.summary)
|
|
.filter(([key]) => [
|
|
"added", "removed", "candidates", "notified", "ok", "failed",
|
|
"total", "completed", "backup_error", "skipped", "already_completed",
|
|
"reason", "elapsed", "success",
|
|
].includes(key))
|
|
.map(([key, value]) => `${key}=${value}`).join(" ")
|
|
: "";
|
|
return `<tr>
|
|
<td>${escapeHtml(name)}</td>
|
|
<td>${taskResultLabel(record)}</td>
|
|
<td>${escapeHtml(formatDateTime(record.finished_at))}</td>
|
|
<td class="summary-cell">${escapeHtml(summary || record.error || "—")}</td>
|
|
</tr>`;
|
|
}).join("")}
|
|
</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
export async function renderSystem(root) {
|
|
const [system, visitStats] = await Promise.all([
|
|
api("/api/system"),
|
|
stats({ days: 30 }).catch(() => null),
|
|
]);
|
|
const health = system.snapshot_health || { total: 0, ok: 0, failed: 0 };
|
|
const counts = system.counts || {};
|
|
const next = system.next_runs || {};
|
|
const running = system.running || {};
|
|
|
|
const visitExtra = visitStats
|
|
? `<span class="section-note">近 ${visitStats.days} 天 · ${visitStats.pending ? "含未落库缓冲" : "已落库"}</span>`
|
|
: "";
|
|
const visitPanel = visitStats ? `
|
|
<section class="panel chart-panel">
|
|
${sectionHeader("访问统计", visitExtra)}
|
|
<div class="mini-stats">
|
|
<div><span>今日 PV</span><strong>${formatNumber(visitStats.today_pv)}</strong></div>
|
|
<div><span>今日 UV</span><strong>${formatNumber(visitStats.today_uv)}</strong></div>
|
|
<div><span>${visitStats.days} 天 PV</span><strong>${formatNumber(visitStats.total_pv)}</strong></div>
|
|
<div><span>${visitStats.days} 天 UV</span><strong>${formatNumber(visitStats.total_uv)}</strong></div>
|
|
</div>
|
|
<div class="chart" id="visit-chart"></div>
|
|
<p class="section-note">柱状为每日 PV,折线为每日 UV(按客户端 IP 去重)。数据来自前端埋点,先落内存、每分钟批量入库;仅统计页面访问,不含 API 与静态资源请求。</p>
|
|
${(visitStats.top_pages || []).length ? `<div class="table-wrap">
|
|
<table class="data-table">
|
|
<thead><tr><th>页面</th><th>PV</th><th>UV</th></tr></thead>
|
|
<tbody>
|
|
${visitStats.top_pages.map((page) => `<tr>
|
|
<td>${escapeHtml(page.path)}</td>
|
|
<td>${formatNumber(page.pv)}</td>
|
|
<td>${formatNumber(page.uv)}</td>
|
|
</tr>`).join("")}
|
|
</tbody>
|
|
</table>
|
|
</div>` : `<p class="section-note">暂无访问记录。</p>`}
|
|
</section>` : "";
|
|
|
|
root.innerHTML = `<section class="page system-page">
|
|
<header class="page-head">
|
|
<div>
|
|
<h1>系统状态</h1>
|
|
<p class="page-sub">数据 ${escapeHtml(system.data_date || "—")} · 快照 ${formatCount(system.snapshot_days)} 天</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="mini-stats">
|
|
<div><span>采集成功</span><strong>${formatCount(health.ok)}</strong></div>
|
|
<div><span>采集失败</span><strong class="${health.failed ? "danger" : ""}">${formatCount(health.failed)}</strong></div>
|
|
<div><span>在库</span><strong>${formatCount(counts.active)}</strong></div>
|
|
<div><span>已失效</span><strong>${formatCount(counts.removed)}</strong></div>
|
|
<div><span>已移出库</span><strong>${formatCount(counts.dropped)}</strong></div>
|
|
</div>
|
|
|
|
${visitPanel}
|
|
|
|
<section class="panel">
|
|
${sectionHeader("最近任务")}
|
|
${taskRows(system.tasks)}
|
|
</section>
|
|
|
|
<section class="panel">
|
|
${sectionHeader("预计下次运行")}
|
|
<div class="status-grid status-row-4">
|
|
<div><span>午夜落盘克隆</span><strong>${running.snapshot_seed ? "运行中" : escapeHtml(formatDateTime(next.snapshot_seed))}</strong></div>
|
|
<div><span>咻咻满粉丝数更新</span><strong>${running.fans ? "运行中" : escapeHtml(formatDateTime(next.fans))}</strong></div>
|
|
<div><span>视频数据快照 + UP 信息</span><strong>${running.snapshot ? "运行中" : escapeHtml(formatDateTime(next.snapshot))}</strong></div>
|
|
<div><span>新视频小时同步</span><strong>${running.snapshot_recent ? "运行中" : escapeHtml(formatDateTime(next.snapshot_recent))}</strong></div>
|
|
<div><span>新视频搜索</span><strong>${running.discover || running.discovery ? "运行中" : escapeHtml(formatDateTime(next.discover))}</strong></div>
|
|
</div>
|
|
<p class="section-note">进行中显示「运行中」;空闲时按该任务上次完成时间推算下次运行。23:00–01:00(上海)为全站快照窗:发现 / 小时同步 / 候选清理会延后到 01:00 后;粉丝更新不避开。跳过或失败不会记成成功,会短间隔补跑。</p>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
${sectionHeader("授权与备份")}
|
|
<div class="status-grid">
|
|
<div><span>登录态</span><strong>${system.auth.ok === "1" ? "正常" : "异常"}</strong></div>
|
|
<div><span>登录信息</span><strong>${escapeHtml(system.auth.message || "—")}</strong></div>
|
|
<div><span>最近检查</span><strong>${escapeHtml(formatDateTime(system.auth.last_check))}</strong></div>
|
|
<div><span>最近备份</span><strong>${system.backup.ok === "1" ? "成功" : "未完成"}</strong></div>
|
|
<div><span>备份错误</span><strong class="${system.backup.error ? "danger" : ""}">${escapeHtml(system.backup.error || "—")}</strong></div>
|
|
<div><span>Telegram</span><strong>${system.telegram_enabled ? "已启用" : "未启用"}</strong></div>
|
|
</div>
|
|
</section>
|
|
|
|
${(system.errors || []).length ? `<section class="panel">
|
|
${sectionHeader("最近错误")}
|
|
${system.errors.map((error) => `<div class="error-row">
|
|
<strong>${escapeHtml(error.task)}</strong>
|
|
<span>${escapeHtml(error.error || "未知错误")}</span>
|
|
</div>`).join("")}
|
|
</section>` : ""}
|
|
|
|
<section class="panel">
|
|
${sectionHeader("UP 黑名单")}
|
|
<p class="section-note">只影响未来搜索发现;已入库视频继续追踪。移除请使用 CLI 或数据库。</p>
|
|
${(system.blacklist || []).length ? `<div class="table-wrap">
|
|
<table class="data-table">
|
|
<thead><tr><th>mid</th><th>昵称</th><th>原因</th><th>在库视频</th></tr></thead>
|
|
<tbody>
|
|
${system.blacklist.map((item) => `<tr>
|
|
<td>${item.mid}</td>
|
|
<td>${escapeHtml(item.name || "—")}</td>
|
|
<td>${escapeHtml(item.reason || "—")}</td>
|
|
<td>${formatNumber(item.video_count || 0)}</td>
|
|
</tr>`).join("")}
|
|
</tbody>
|
|
</table>
|
|
</div>` : emptyState("黑名单为空")}
|
|
</section>
|
|
</section>`;
|
|
|
|
const visitChart = root.querySelector("#visit-chart");
|
|
if (visitChart) {
|
|
if (visitStats && (visitStats.total_pv || visitStats.total_uv)) {
|
|
mixedChart(visitChart, {
|
|
dates: visitStats.series.dates,
|
|
bars: visitStats.series.pv,
|
|
line: visitStats.series.uv,
|
|
barName: "PV",
|
|
lineName: "UV",
|
|
});
|
|
} else {
|
|
emptyChart(visitChart, "暂无访问数据");
|
|
}
|
|
}
|
|
}
|