Commit ed91787c authored by 郭阔's avatar 郭阔

feat: macOS platform adaptation, concurrency guard and detail page redesign

- web/app.py: IS_DARWIN branches (launchctl kickstart for manual trigger,
  direct execution for digest/continue without sudo); _active_harvests()
  concurrency guard on /run and /continue; normalized detail state
  (last run time from log filename, next trigger computed from schedule)
- detail.html: layout redesign — status badge header, real overview cards,
  merged duplicate run metadata, collapsible logs/config, history moved up
- gitignore: exclude .zcode/
parent 4e3e086a
...@@ -37,3 +37,4 @@ __pycache__/ ...@@ -37,3 +37,4 @@ __pycache__/
# Claude Provider 配置(含敏感 token,不提交;只保留 .example.json) # Claude Provider 配置(含敏感 token,不提交;只保留 .example.json)
config/providers/*.json config/providers/*.json
!config/providers/*.example.json !config/providers/*.example.json
.zcode/
...@@ -21,7 +21,7 @@ import re ...@@ -21,7 +21,7 @@ import re
import subprocess import subprocess
import sys import sys
import threading import threading
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from flask import ( from flask import (
...@@ -44,6 +44,11 @@ MODIFY_SOURCE = str(WORK_DIR / "bin" / "modify-source.sh") ...@@ -44,6 +44,11 @@ MODIFY_SOURCE = str(WORK_DIR / "bin" / "modify-source.sh")
CONTINUE_SCRIPT = str(WORK_DIR / "bin" / "continue.sh") CONTINUE_SCRIPT = str(WORK_DIR / "bin" / "continue.sh")
DIGEST_SCRIPT = str(WORK_DIR / "bin" / "daily-digest.sh") DIGEST_SCRIPT = str(WORK_DIR / "bin" / "daily-digest.sh")
# ── 平台适配 ──────────────────────────────────────────────
# macOS 上调度走 launchd(标签 com.newsharvester.<source>),无 systemctl/sudo -u。
IS_DARWIN = sys.platform == "darwin"
LAUNCHD_GUI = f"gui/{os.getuid()}"
# ── Flask 应用 ───────────────────────────────────────────── # ── Flask 应用 ─────────────────────────────────────────────
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get("WEB_SECRET", "news-harvester-web-change-me") app.secret_key = os.environ.get("WEB_SECRET", "news-harvester-web-change-me")
...@@ -502,6 +507,31 @@ def run_script(cmd, timeout=30): ...@@ -502,6 +507,31 @@ def run_script(cmd, timeout=30):
return 1, "", str(e) return 1, "", str(e)
def _active_harvests():
"""返回正在运行的采集源名列表(并发保护用)。
macOS 查 launchd agent 状态;Linux 查 systemd service is-active。
单个源查询失败视为未运行,不影响页面。"""
names = []
for timer in get_timer_files():
name = timer.name[len("news-harvester@"):-len(".timer")]
if IS_DARWIN:
rc, out, _ = run_script(
["launchctl", "print", f"{LAUNCHD_GUI}/com.newsharvester.{name}"],
timeout=5,
)
if rc == 0 and "state = running" in out:
names.append(name)
else:
rc, out, _ = run_script(
["systemctl", "is-active", f"news-harvester@{name}.service"],
timeout=5,
)
if rc == 0 and out.strip() == "active":
names.append(name)
return names
# ── 路由 ─────────────────────────────────────────────────── # ── 路由 ───────────────────────────────────────────────────
@app.route("/") @app.route("/")
...@@ -578,11 +608,56 @@ def source_detail(name): ...@@ -578,11 +608,56 @@ def source_detail(name):
business_result, legacy_result = get_business_result(json_info["data"]) if json_info else (None, None) business_result, legacy_result = get_business_result(json_info["data"]) if json_info else (None, None)
history_runs = get_history_runs(name) history_runs = get_history_runs(name)
# 采集结果的运行时间:文件名编码开始时间,JSON 里有总耗时
result_time = None
result_start = None
if json_info:
m = re.search(r"\.(\d{8})\.(\d{6})\.json$", json_info["path"])
if m:
try:
start = datetime.strptime(m.group(1) + m.group(2), "%Y%m%d%H%M%S")
result_start = start.strftime("%Y-%m-%d %H:%M:%S")
result_time = "{} 开始 · 耗时 {}".format(
result_start,
format_duration_ms(json_info["data"].get("duration_ms")),
)
except ValueError:
pass
# 调度状态归一化:macOS 读 launchd,Linux 读 systemd;下次触发从排期计算兜底
agent_running = name in _active_harvests()
if agent_running:
state_label = "运行中"
elif props.get("active") == "active":
state_label = "active"
else:
state_label = "空闲"
last_trigger = result_start or props.get("last") or "-"
next_trigger = props.get("next") if props.get("next") not in (None, "-") else None
if not next_trigger:
cands = []
for t in (run_time or "").split(","):
t = t.strip()
if ":" not in t:
continue
try:
hh, mm = t.split(":")[:2]
cand = datetime.now().replace(hour=int(hh), minute=int(mm), second=0, microsecond=0)
except ValueError:
continue
if cand <= datetime.now():
cand += timedelta(days=1)
cands.append(cand)
next_trigger = min(cands).strftime("%m-%d %H:%M") if cands else "-"
return render_template("detail.html", name=name, run_time=run_time, return render_template("detail.html", name=name, run_time=run_time,
props=props, log_info=log_info, json_info=json_info, props=props, log_info=log_info, json_info=json_info,
json_meta=json_meta, conf=conf, business_result=business_result, legacy_result=legacy_result, json_meta=json_meta, conf=conf, business_result=business_result, legacy_result=legacy_result,
continue_status=continue_status, auto_refresh=auto_refresh, continue_status=continue_status, auto_refresh=auto_refresh,
providers=get_providers(), history_runs=history_runs) providers=get_providers(), history_runs=history_runs,
result_time=result_time, result_start=result_start,
agent_running=agent_running, state_label=state_label,
last_trigger=last_trigger, next_trigger=next_trigger)
@app.route("/source/<name>/history/<run_file>") @app.route("/source/<name>/history/<run_file>")
...@@ -704,9 +779,18 @@ def edit_source(name): ...@@ -704,9 +779,18 @@ def edit_source(name):
def run_source(name): def run_source(name):
if not re.match(r"^[a-zA-Z0-9_-]+$", name): if not re.match(r"^[a-zA-Z0-9_-]+$", name):
abort(404) abort(404)
rc, stdout, stderr = run_script( active = _active_harvests()
["systemctl", "start", f"news-harvester@{name}.service"], timeout=10 if active:
) flash(
f"已有采集任务在运行({('、'.join(active))}),为避免并发冲突已拒绝触发,请等其完成后再试。",
"error",
)
return redirect(url_for("source_detail", name=name))
if IS_DARWIN:
trigger = ["launchctl", "kickstart", f"{LAUNCHD_GUI}/com.newsharvester.{name}"]
else:
trigger = ["systemctl", "start", f"news-harvester@{name}.service"]
rc, stdout, stderr = run_script(trigger, timeout=10)
if rc == 0: if rc == 0:
flash(f"已触发 {name} 抓取", "success") flash(f"已触发 {name} 抓取", "success")
else: else:
...@@ -722,9 +806,10 @@ def run_digest(): ...@@ -722,9 +806,10 @@ def run_digest():
return redirect(url_for("index")) return redirect(url_for("index"))
# 后台启动 daily-digest.sh(非阻塞) # 后台启动 daily-digest.sh(非阻塞)
# 以 yichu 用户执行,确保文件权限/邮件/PATH 全部正确 # Linux 以 yichu 用户执行,确保文件权限/邮件/PATH 全部正确;macOS 直接当前用户
digest_cmd = [DIGEST_SCRIPT] if IS_DARWIN else ["sudo", "-u", "yichu", DIGEST_SCRIPT]
proc = subprocess.Popen( proc = subprocess.Popen(
["sudo", "-u", "yichu", DIGEST_SCRIPT], digest_cmd,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
) )
...@@ -758,6 +843,14 @@ def continue_source(name): ...@@ -758,6 +843,14 @@ def continue_source(name):
flash(f"{name} 已有续采/修复任务在运行中,请等待完成", "error") flash(f"{name} 已有续采/修复任务在运行中,请等待完成", "error")
return redirect(url_for("source_detail", name=name)) return redirect(url_for("source_detail", name=name))
active = _active_harvests()
if active:
flash(
f"已有采集任务在运行({('、'.join(active))}),续采/修复会与其争用浏览器,已拒绝启动。",
"error",
)
return redirect(url_for("source_detail", name=name))
mode = request.form.get("mode", "resume").strip() mode = request.form.get("mode", "resume").strip()
if mode not in ("resume", "repair"): if mode not in ("resume", "repair"):
mode = "resume" mode = "resume"
...@@ -765,8 +858,9 @@ def continue_source(name): ...@@ -765,8 +858,9 @@ def continue_source(name):
provider = request.form.get("provider", "").strip() provider = request.form.get("provider", "").strip()
# 后台启动 continue.sh(非阻塞) # 后台启动 continue.sh(非阻塞)
# 以 yichu 用户执行(通过 sudo -u),确保文件权限/邮件/PATH 全部正确 # Linux 以 yichu 用户执行(通过 sudo -u);macOS 直接以当前用户执行(继承面板环境)
cmd = ["sudo", "-u", "yichu", CONTINUE_SCRIPT, name, "--mode", mode, "--yes"] cmd = ([CONTINUE_SCRIPT] if IS_DARWIN else ["sudo", "-u", "yichu", CONTINUE_SCRIPT]) \
+ [name, "--mode", mode, "--yes"]
if debug_flag: if debug_flag:
cmd.append("--debug") cmd.append("--debug")
if provider: if provider:
......
This diff is collapsed.
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