diff --git a/doc/API.md b/doc/API.md index 48c4090..ad3fd4f 100644 --- a/doc/API.md +++ b/doc/API.md @@ -266,12 +266,27 @@ { "ok": true, "jobs": [{"id": "abc123", "name": "抖音养号", "task_type": "douyin_nurture", - "next_run": "2026-08-12 09:00", "...": "..."}], + "next_run": "2026-08-12 09:00", + "coverage": {"mode": "all", "total": 3, + "serials": ["192.168.20.206:5555", "..."]}, + "...": "..."}], "task_types": [...] } ``` `next_run`:下次真正执行时间(已按运行窗口跳过窗口外触发点,格式 `YYYY-MM-DD HH:MM`);手动任务/已停用为 `null`。 +`coverage`:**任务覆盖的设备**(监控页「任务运行概况」展示用,每次请求现算)。按 `target` 定义解析, +不因设备当前是否空闲而变(在线/运行中由前端拿 `/api/status` 标注),也不做离线过滤、不写调度日志: + +| target.mode | coverage.serials | +|------|------| +| `all` | 设备池全部启用设备 | +| `group` | 该分组的 serial ∩ 设备池启用设备(池外的手填 IP 不计入,与调度口径一致) | +| `serial` | 仅该 serial(即使不在池中也照实返回) | + +> 口径差异:真正调度时走 `TaskJob.resolve_serials()`(`all` 取"池内∩在线"、并跳过离线设备); +> `coverage` 是**定义层**的覆盖面,供人看"这台任务管哪些设备"。POST/PUT 的 `job` 里同样带 `coverage`。 + ### POST /api/jobs 创建任务计划。 diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 47d7799..42a8c3d 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -320,6 +320,13 @@ tasks/douyin/actions/like.py — @register_action(ACTIONS) LikeAction - **数据获取**:`fetch()` 调 JSON API,5 秒轮询 `/api/status` - **状态渲染**:设备表格、任务卡片、进度条、徽章,纯 DOM 操作 +**监控页「任务运行概况」卡片(2026-09-10 收敛)**:每张卡片只保留两个操作——**执行任务**(`POST /api/jobs/:id/run`) +与**停用任务/启用任务**(`POST /api/jobs/:id/toggle`,按当前状态切换文案);原"编辑/删除"已移除 +(编辑与删除统一去「任务」Tab 操作,避免监控页误删)。卡片新增**覆盖设备**一行:设备清单来自 +`/api/jobs` 的 `coverage`(后端按 target 定义解析,见 [API.md](API.md) §5),在线/运行中状态由前端 +用已在手的 `/api/status` 数据标注成彩色 chip(在线=蓝、运行中=黄+⏳、离线或不在池=灰划线+✕, +完整 serial 与型号放 tooltip)。 + ### 5.2 步骤编辑器 `generic_steps` 任务的步骤编辑器(任务弹窗已加宽到 1000px): diff --git a/static/admin/monitor.js b/static/admin/monitor.js index 529b9f0..8b18d1a 100644 --- a/static/admin/monitor.js +++ b/static/admin/monitor.js @@ -445,6 +445,34 @@ function fmtParams(params){ return parts.join(''); } +// 任务覆盖的设备:后端给 serial 清单(coverage),这里用实时状态标注在线/运行中 +function _jobCoverageHtml(j){ + const cov=j.coverage||{}, serials=cov.serials||[]; + const st={}; + if(_statusData)for(const d of (_statusData.devices||[]))st[d.serial]=d; + if(!serials.length){ + return '
覆盖设备 0 台' + +'该目标下暂无可调度设备(检查设备池/分组)
'; + } + let onlineN=0,busyN=0; + const chips=serials.map(s=>{ + const d=st[s]||{}; + const online=!!d.present; + const busy=(d.worker_status==='running'||d.worker_status==='connecting'); + if(online)onlineN++; + if(busy)busyN++; + const cls=busy?'running':(online?'online':'offline'); + const title=s+(d.model?' · '+d.model:'') + +(busy?' · 任务中:'+(d.task_job||''):'') + +(online?'':' · 离线或不在设备池'); + return '' + +esc(s.replace(/:5555$/,''))+(busy?' ⏳':'')+(online?'':' ✕')+''; + }).join(''); + return '
覆盖 '+serials.length+' 台 · 在线 '+onlineN + +(busyN?' · 运行中 '+busyN:'')+'' + +'
'+chips+'
'; +} + function renderJobCards(){ const jobs=_jobsData||[]; const running={}; @@ -470,12 +498,11 @@ function renderJobCards(){ '调度:'+esc(sched)+''+ '重试:'+esc(retry)+''+ '
'+fmtParams(j.params)+'
'+ + _jobCoverageHtml(j)+ ''+ '
'+ - ''+ - ''+ - ''+ - ''+ + ''+ + ''+ '
'+ ''; }).join(''):'
暂无任务,到"任务"Tab 新建
'; diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index a4d5c80..03f666e 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -169,6 +169,14 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 .job-card .job-body{font-size:12px;color:var(--text-muted);margin-bottom:11px;line-height:1.65} .job-card .job-body .kv{display:inline-block;background:#faf9f5;border:1px solid var(--border);border-radius:6px;padding:1px 7px;margin:2px 3px 0 0;font-family:var(--mono);font-size:11px} .job-card .job-actions{display:flex;gap:6px;flex-wrap:wrap} +/* 任务覆盖的设备(监控页任务概况) */ +.job-cov{margin-top:6px} +.cov-chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px} +.cov-chip{font-family:var(--mono);font-size:11px;padding:1px 7px;border-radius:9px;border:1px solid;white-space:nowrap;cursor:default} +.cov-chip.online{color:#0e7490;background:#e0f2fe;border-color:#bae6fd} +.cov-chip.running{color:#b45309;background:#fef3c7;border-color:#fde68a} +.cov-chip.offline{color:#9ca3af;background:#f3f4f6;border-color:#e5e7eb;text-decoration:line-through} +.cov-note{font-size:11px;color:#94a3b8;margin-left:4px} /* 空状态 / 辅助文本 */ .empty{text-align:center;padding:42px;color:var(--text-light);font-size:13px} diff --git a/web/tasks_api.py b/web/tasks_api.py index 0628c21..f512e87 100644 --- a/web/tasks_api.py +++ b/web/tasks_api.py @@ -54,13 +54,47 @@ def _job_next_run(job): nr = context.mgr.next_run_of(job) return nr.strftime("%Y-%m-%d %H:%M") if nr else None +def _job_coverage(job, configured): + """任务覆盖的设备(监控页「任务运行概况」展示用)。 + + 与 TaskJob.resolve_serials 的差别:这里按 **target 定义**给出「这台任务会覆盖 + 哪些设备」,不因设备当前是否空闲而变(在线/运行中由前端拿实时状态标注), + 也不做离线过滤、不写调度日志——它是每 5s 轮询的展示接口。 + """ + tgt = job.target or {} + mode = tgt.get("mode", "all") + try: + if mode == "serial": + serials = [tgt.get("serial") or ""] + elif mode == "group": + g = context.mgr.groups.get(tgt.get("group_name")) + serials = [s for s in (list(g.serials) if g else []) if s in configured] + else: + serials = sorted(configured) + serials = [s for s in serials if s] + return {"mode": mode, "serials": serials, "total": len(serials)} + except Exception as e: + _log.warning(f"任务「{job.name}」覆盖设备解析失败: {e}") + return {"mode": mode, "serials": [], "total": 0, "error": str(e)[:80]} + + +def _configured_set(): + """设备池清单(coverage 计算用,取一次给所有任务复用)。""" + try: + return set(device_pool.list_configured()) + except Exception: + return set() + + @bp.route("/api/jobs") @login_required def api_jobs_list(): + configured = _configured_set() jobs = [] for j in context.mgr.jobs.values(): d = j.to_dict() d["next_run"] = _job_next_run(j) + d["coverage"] = _job_coverage(j, configured) jobs.append(d) return jsonify({"ok": True, "jobs": jobs, "task_types": list_task_types()}) @@ -84,6 +118,7 @@ def api_jobs_create(): enabled=data.get("enabled", True)) d = job.to_dict() d["next_run"] = _job_next_run(job) + d["coverage"] = _job_coverage(job, _configured_set()) return jsonify({"ok": True, "msg": "任务已创建", "job": d}) @bp.route("/api/jobs/", methods=["PUT"]) @@ -102,6 +137,7 @@ def api_jobs_update(job_id): context.mgr.update_job(job_id, **fields) d = job.to_dict() d["next_run"] = _job_next_run(job) + d["coverage"] = _job_coverage(job, _configured_set()) return jsonify({"ok": True, "msg": "任务已更新", "job": d}) @bp.route("/api/jobs/", methods=["DELETE"])