Commit bb648a72 authored by 郭阔's avatar 郭阔

feat(panel): harvest phase display — homepage badge + detail timeline bar

- homepage: drop 成本/Session columns (detail-page info); running badge now
  shows inferred phase (采集中/摘要生成/推送收尾) via _harvest_phase():
  runlock mtime = start, vault-day .manifest/.summaries/.registry mtimes
  mark phase transitions — zero intrusion into the harvest process
- detail page: timeline-style progress bar (采集中→摘要生成→推送/收尾→完成)
  with pulsing current-stage dot, green done segments, elapsed minutes,
  10s auto-refresh while running
parent 3994a761
...@@ -557,6 +557,51 @@ def _active_harvests(): ...@@ -557,6 +557,51 @@ def _active_harvests():
return pl.active_harvests() return pl.active_harvests()
def _harvest_phase(source):
"""返回正在运行任务的 (开始时间, 阶段名)。阶段由磁盘信号推断,不打扰采集进程:
runlock 建立 = 任务开始;vault 当日目录里 .manifest.json 比开始新 = 浏览器抓取
已完成;.summaries.json 更新 = 摘要生成中;registry.json 更新 = 推送/收尾。
"""
port = _site_cdp_port(source.split("-")[0])
lockdir = WORK_DIR / "runlocks"
if not lockdir.is_dir():
return None, None
locks = list(lockdir.glob(f"port-{port}.*.lock"))
if not locks:
return None, None
try:
start = datetime.fromtimestamp(locks[0].stat().st_mtime)
except OSError:
return None, None
def mtime(p):
try:
return datetime.fromtimestamp(p.stat().st_mtime)
except OSError:
return None
day_dir = None
try:
cfg = json.loads(pl.skill_config_path().read_text(encoding="utf-8"))
src = next(s for s in cfg.get("sources", []) if s.get("id") == source)
day_dir = Path(src.get("vaultPath") or cfg.get("vaultPath", "")) / \
src.get("vaultFolder", "") / datetime.now().strftime("%Y%m%d")
except (StopIteration, ValueError, OSError):
day_dir = None
if day_dir and day_dir.is_dir():
reg = mtime(day_dir / "registry.json")
if reg and reg > start:
return start, "推送/收尾"
summ = mtime(day_dir / ".summaries.json")
if summ and summ > start:
return start, "摘要生成"
mani = mtime(day_dir / ".manifest.json")
if mani and mani > start:
return start, "摘要生成"
return start, "采集中"
# ── 路由 ─────────────────────────────────────────────────── # ── 路由 ───────────────────────────────────────────────────
@app.route("/") @app.route("/")
...@@ -564,6 +609,14 @@ def _active_harvests(): ...@@ -564,6 +609,14 @@ def _active_harvests():
def index(): def index():
sources = get_source_list() sources = get_source_list()
active = set(_active_harvests()) active = set(_active_harvests())
# 运行中任务的阶段与已用时(磁盘信号推断)
run_info = {}
for a in active:
start, phase = _harvest_phase(a)
run_info[a] = {
"phase": phase or "采集中",
"elapsed": int((datetime.now() - start).total_seconds() // 60) if start else None,
}
# 合并已安装但未建调度的技能源(可通过开关开启,开启时自动错峰创建调度) # 合并已安装但未建调度的技能源(可通过开关开启,开启时自动错峰创建调度)
scheduled = {s["source"] for s in sources} scheduled = {s["source"] for s in sources}
for row in pl.source_rows(LOG_DIR): for row in pl.source_rows(LOG_DIR):
...@@ -615,6 +668,7 @@ def index(): ...@@ -615,6 +668,7 @@ def index():
flash(f"每日汇总失败 (exit {digest_status['rc']})\n{digest_status['stdout']}\n{digest_status['stderr']}", "error") flash(f"每日汇总失败 (exit {digest_status['rc']})\n{digest_status['stdout']}\n{digest_status['stderr']}", "error")
return render_template("index.html", sources=sources, active=active, return render_template("index.html", sources=sources, active=active,
run_info=run_info,
digest_props=digest_props, digest_run_time=digest_run_time, digest_props=digest_props, digest_run_time=digest_run_time,
digest_status=digest_status, auto_refresh=auto_refresh, digest_status=digest_status, auto_refresh=auto_refresh,
default_provider=default_provider, default_provider=default_provider,
...@@ -675,6 +729,11 @@ def source_detail(name): ...@@ -675,6 +729,11 @@ def source_detail(name):
# 调度状态归一化:macOS 读 launchd,Linux 读 systemd;下次触发从排期计算兜底 # 调度状态归一化:macOS 读 launchd,Linux 读 systemd;下次触发从排期计算兜底
agent_running = name in _active_harvests() agent_running = name in _active_harvests()
run_phase = run_elapsed = None
if agent_running:
run_start, run_phase = _harvest_phase(name)
if run_start:
run_elapsed = int((datetime.now() - run_start).total_seconds() // 60)
if agent_running: if agent_running:
state_label = "运行中" state_label = "运行中"
elif props.get("active") == "active": elif props.get("active") == "active":
...@@ -706,6 +765,7 @@ def source_detail(name): ...@@ -706,6 +765,7 @@ def source_detail(name):
providers=get_providers(), history_runs=history_runs, providers=get_providers(), history_runs=history_runs,
result_time=result_time, result_start=result_start, result_time=result_time, result_start=result_start,
agent_running=agent_running, state_label=state_label, agent_running=agent_running, state_label=state_label,
run_phase=run_phase, run_elapsed=run_elapsed,
last_trigger=last_trigger, next_trigger=next_trigger) last_trigger=last_trigger, next_trigger=next_trigger)
......
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ name }} — News Harvester{% endblock %} {% block title %}{{ name }} — News Harvester{% endblock %}
{% block extra_head %} {% block extra_head %}
{% if auto_refresh %} {% if auto_refresh or agent_running %}
<meta http-equiv="refresh" content="10"> <meta http-equiv="refresh" content="10">
{% endif %} {% endif %}
<style>
.phasebar { display:flex; align-items:flex-start; margin:18px 0 0; }
.pstage { display:flex; flex-direction:column; align-items:center; min-width:76px; }
.pdot { width:16px; height:16px; border-radius:50%; background:#3a3d47; border:2px solid #4a4e5a; box-sizing:border-box; }
.pstage.done .pdot { background:#2ea043; border-color:#2ea043; }
.pstage.cur .pdot { background:#e8c34a; border-color:#e8c34a; box-shadow:0 0 9px #e8c34a; animation:ppulse 1.4s ease-in-out infinite; }
.plabel { margin-top:6px; font-size:12px; color:#777; }
.pstage.done .plabel { color:#3fb950; }
.pstage.cur .plabel { color:#e8c34a; font-weight:700; }
.pconn { flex:1; height:3px; border-radius:2px; background:#2a2d37; margin-top:7px; }
.pconn.done { background:#2ea043; }
.pconn.cur { background:linear-gradient(90deg,#2ea043,#e8c34a); }
@keyframes ppulse { 50% { box-shadow:0 0 3px rgba(232,195,74,.35); } }
</style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
...@@ -24,6 +38,27 @@ ...@@ -24,6 +38,27 @@
{% endif %} {% endif %}
</div> </div>
{% if agent_running and run_phase %}
<div class="phasebar">
{% set stages = ['采集中', '摘要生成', '推送/收尾', '完成'] %}
{% set cur = stages.index(run_phase) if run_phase in stages else 0 %}
{% for st in stages %}
<div class="pstage {{ 'done' if loop.index0 < cur else ('cur' if loop.index0 == cur else '') }}">
<div class="pdot"></div>
<div class="plabel">{{ st }}</div>
</div>
{% if not loop.last %}
<div class="pconn {{ 'done' if loop.index0 < cur else ('cur' if loop.index0 == cur else '') }}"></div>
{% endif %}
{% endfor %}
</div>
<div style="color:#888;font-size:12px;margin:4px 0 14px">
当前阶段:<strong style="color:#e8c34a">{{ run_phase }}</strong>
{% if run_elapsed is not none %} · 已运行 {{ run_elapsed }} 分钟{% endif %}
· 采集中(浏览器)→ 摘要生成 → 推送/收尾 · 页面每 10 秒自动刷新
</div>
{% endif %}
{% if continue_status and continue_status.running %} {% if continue_status and continue_status.running %}
<div class="running-banner"> <div class="running-banner">
<div class="spinner"></div> <div class="spinner"></div>
......
...@@ -34,8 +34,8 @@ ...@@ -34,8 +34,8 @@
<h1>栏目列表 <span style="font-size:14px;color:#666">({{ sources|length }} 个)</span></h1> <h1>栏目列表 <span style="font-size:14px;color:#666">({{ sources|length }} 个)</span></h1>
{% if active %} {% if active %}
<div class="flash" style="border-color:#2ea043"> <div class="flash" style="border-color:#2ea043">
⟳ 正在采集:{% for a in active|sort %}<strong>{{ a }}</strong>{{ ", " if not loop.last }}{% endfor %} ⟳ 正在采集:{% for a in active|sort %}<strong>{{ a }}</strong> · {{ run_info[a].phase }}{% if run_info[a].elapsed is not none %} · 已 {{ run_info[a].elapsed }} 分钟{% endif %}{{ "; " if not loop.last }}{% endfor %}
<span style="color:#888;font-size:12px">(结束前浏览器保持打开属正常;页面每 30 秒自动刷新)</span> <span style="color:#888;font-size:12px">(阶段:采集中 → 摘要生成 → 推送/收尾;结束前浏览器保持打开属正常;页面每 30 秒自动刷新)</span>
</div> </div>
{% endif %} {% endif %}
<div class="table-wrap"> <div class="table-wrap">
...@@ -49,8 +49,6 @@ ...@@ -49,8 +49,6 @@
<th>Provider</th> <th>Provider</th>
<th>上次 / 下次触发</th> <th>上次 / 下次触发</th>
<th>最近运行</th> <th>最近运行</th>
<th>成本</th>
<th>Session</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
...@@ -103,7 +101,7 @@ ...@@ -103,7 +101,7 @@
</td> </td>
<td> <td>
{% if s.source in active %} {% if s.source in active %}
<span class="badge active">⟳ 运行中</span><br> <span class="badge active">⟳ {{ run_info[s.source].phase }}</span><br>
{% endif %} {% endif %}
{% if s.log %} {% if s.log %}
{% set status_text = s.log.get('status', '') %} {% set status_text = s.log.get('status', '') %}
...@@ -112,22 +110,6 @@ ...@@ -112,22 +110,6 @@
<span style="color:#666">—</span> <span style="color:#666">—</span>
{% endif %} {% endif %}
</td> </td>
<td>
{% if s.log and s.log.get('cost') %}
{% set cost_num = s.log.cost | replace('$', '') | replace('成本:', '') | trim %}
{% set cost_str = cost_num | float %}
${{ "%.2f"|format(cost_str) }}
{% else %}
<span style="color:#666">—</span>
{% endif %}
</td>
<td>
{% if s.log and s.log.get('session') %}
<span class="session-id truncate" title="{{ s.log.session }}">{{ s.log.session }}</span>
{% else %}
<span style="color:#666">—</span>
{% endif %}
</td>
<td> <td>
{% if s.timer_name == '—' %} {% if s.timer_name == '—' %}
<span style="color:#888;font-size:12px" title="打开开关创建调度后可查看详情/编辑">未排程</span> <span style="color:#888;font-size:12px" title="打开开关创建调度后可查看详情/编辑">未排程</span>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment