diff --git a/admin/dashboard/blueprints/plugin_schedules.py b/admin/dashboard/blueprints/plugin_schedules.py index aed8c00..3f4a7b2 100644 --- a/admin/dashboard/blueprints/plugin_schedules.py +++ b/admin/dashboard/blueprints/plugin_schedules.py @@ -40,7 +40,7 @@ def api_list_schedules(): data = server.plugin_schedule_manager.list_schedules_with_runtime() # 后端统一格式化时间字段,避免前端出现 Fri, 17 Apr 2026 ... 这类 RFC 时间串。 for row in data: - for key in ("next_run_at", "last_run_at", "created_at", "updated_at"): + for key in ("next_run_at", "last_run_at", "latest_success_at", "latest_failed_at", "created_at", "updated_at"): if key in row: row[key] = _normalize_datetime_text(row.get(key)) return jsonify({"success": True, "data": data}) diff --git a/admin/dashboard/blueprints/system_jobs.py b/admin/dashboard/blueprints/system_jobs.py index d994e2f..54fdfa3 100644 --- a/admin/dashboard/blueprints/system_jobs.py +++ b/admin/dashboard/blueprints/system_jobs.py @@ -21,6 +21,40 @@ def _normalize_datetime_text(value): return text +def _build_job_health_status(*, enabled: bool, running: bool, last_status: str, latest_success_at, latest_failure_summary: str) -> str: + """根据任务启停、运行态和历史结果输出后台可读的健康状态。""" + # 状态设计尽量贴近运维判断顺序: + # 1. 停用态单独标记,避免和“从未执行”混淆; + # 2. 执行中的任务优先展示 running,方便后台快速识别实时动作; + # 3. 最近一次执行失败时直接标记 failed,让异常任务在列表里一眼可见; + # 4. 有成功历史且最近不是失败时视为 healthy,否则落到 idle。 + if not enabled: + return "disabled" + if running: + return "running" + if str(last_status or "").strip().lower() == "failed": + return "failed" + if latest_success_at or str(last_status or "").strip().lower() == "success": + return "healthy" + if str(latest_failure_summary or "").strip(): + return "failed" + return "idle" + + +def _build_job_health_message(*, health_status: str, latest_success_at, latest_failure_summary: str) -> str: + """为后台列表生成一句简短的任务健康提示。""" + if health_status == "disabled": + return "任务已停用" + if health_status == "running": + return "任务正在执行中" + if health_status == "failed": + return str(latest_failure_summary or "最近一次执行失败").strip() + if health_status == "healthy": + success_text = _normalize_datetime_text(latest_success_at) + return f"最近成功于 {success_text}" if success_text else "任务近期执行正常" + return "暂无执行记录" + + @system_jobs_bp.route("/") @login_required def page_system_jobs(): @@ -34,11 +68,31 @@ def api_list_jobs(): db_rows = server.system_job_db.list_jobs() runtime_rows = async_job.get_jobs_snapshot() runtime_by_key = {row.get("job_key", ""): row for row in runtime_rows if row.get("job_key")} + job_keys = [str(row.get("job_key") or "").strip() for row in db_rows if str(row.get("job_key") or "").strip()] + latest_log_by_key = server.system_job_db.get_latest_logs_map(job_keys) + history_summary_by_key = server.system_job_db.get_job_history_summary_map(job_keys) result = [] for row in db_rows: job_key = row.get("job_key") runtime = runtime_by_key.get(job_key, {}) + latest_log = latest_log_by_key.get(job_key, {}) + history_summary = history_summary_by_key.get(job_key, {}) + last_status = runtime.get("last_status") or latest_log.get("status") or "never" + last_run_at = runtime.get("last_run_at") or latest_log.get("triggered_at") + last_error = runtime.get("last_error") or "" + if not last_error and str(last_status or "").strip().lower() == "failed": + last_error = ( + str(latest_log.get("summary") or "").strip() + or str(history_summary.get("latest_failure_summary") or "").strip() + ) + health_status = _build_job_health_status( + enabled=bool(row.get("enabled", 0)), + running=bool(runtime.get("running", False)), + last_status=str(last_status or ""), + latest_success_at=history_summary.get("latest_success_at"), + latest_failure_summary=str(history_summary.get("latest_failure_summary") or ""), + ) result.append( { "job_key": job_key, @@ -51,14 +105,26 @@ def api_list_jobs(): "runtime_enabled": runtime.get("enabled"), "running": runtime.get("running", False), "trigger_text": runtime.get("trigger_text", ""), - "last_run_at": _normalize_datetime_text(runtime.get("last_run_at")), - "last_status": runtime.get("last_status"), - "last_error": runtime.get("last_error"), - "last_duration_ms": runtime.get("last_duration_ms"), + "last_run_at": _normalize_datetime_text(last_run_at), + "last_status": last_status, + "last_error": last_error, + "last_duration_ms": runtime.get("last_duration_ms") or latest_log.get("duration_ms"), "next_run_at": _normalize_datetime_text(runtime.get("next_run_at")), "run_count": runtime.get("run_count", 0), "success_count": runtime.get("success_count", 0), "fail_count": runtime.get("fail_count", 0), + "latest_success_at": _normalize_datetime_text(history_summary.get("latest_success_at")), + "latest_failed_at": _normalize_datetime_text(history_summary.get("latest_failed_at")), + "latest_failure_summary": str(history_summary.get("latest_failure_summary") or "").strip(), + "history_success_count": int(history_summary.get("history_success_count", 0) or 0), + "history_fail_count": int(history_summary.get("history_fail_count", 0) or 0), + "history_total_count": int(history_summary.get("history_total_count", 0) or 0), + "health_status": health_status, + "health_message": _build_job_health_message( + health_status=health_status, + latest_success_at=history_summary.get("latest_success_at"), + latest_failure_summary=str(history_summary.get("latest_failure_summary") or "").strip(), + ), } ) diff --git a/admin/dashboard/templates/plugin_schedules.html b/admin/dashboard/templates/plugin_schedules.html index 1952250..cc792da 100644 --- a/admin/dashboard/templates/plugin_schedules.html +++ b/admin/dashboard/templates/plugin_schedules.html @@ -42,11 +42,44 @@ {% raw %}{{ scope.row.last_status || 'never' }}{% endraw %} - + + + + + + + + + + + + + @@ -197,6 +230,25 @@ new Vue({ if (status === 'running') return 'warning' return 'info' }, + healthTag(status) { + if (status === 'healthy') return 'success' + if (status === 'running') return 'warning' + if (status === 'failed') return 'danger' + if (status === 'degraded') return 'warning' + if (status === 'disabled') return 'info' + return '' + }, + healthLabel(status) { + const mapping = { + healthy: '健康', + running: '执行中', + failed: '异常', + degraded: '有告警', + disabled: '停用', + idle: '待运行' + } + return mapping[status] || '待运行' + }, formatDateTime(value) { // 统一清洗时间展示:去掉 ISO 'T',并兼容字符串与日期对象。 if (!value) return '' @@ -330,6 +382,25 @@ new Vue({ } await this.loadSchedules() }, + async toggleEnabled(row) { + const payload = { + action_name: row.action_name, + description: row.description, + enabled: !row.enabled, + trigger_type: row.trigger_type, + trigger_config: row.trigger_config, + target_scope: row.target_scope, + target_config: row.target_config, + payload: row.payload || {} + } + const resp = await axios.put(`/plugin_schedules/api/schedules/${row.id}`, payload) + if (resp.data.success) { + this.$message.success(row.enabled ? '已停用' : '已启用') + await this.loadSchedules() + } else { + this.$message.error(resp.data.message || '更新失败') + } + }, async viewLogs(row) { const resp = await axios.get(`/plugin_schedules/api/schedules/${row.id}/logs`) if (resp.data.success) { @@ -352,5 +423,10 @@ new Vue({ .page-hero-copy p{color:#64748b;font-size:14px} .action-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .detail-pre{white-space:pre-wrap;word-break:break-word;background:rgba(248,250,252,.85);border:1px solid rgba(148,163,184,.12);border-radius:14px;padding:10px;color:#334155} +.cell-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#475569} +.history-metrics{display:flex;align-items:center;justify-content:center;gap:8px} +.metric-success{color:#16a34a;font-weight:600} +.metric-fail{color:#dc2626;font-weight:600} +.history-total{margin-top:4px;color:#64748b;font-size:12px} {% endblock %} diff --git a/admin/dashboard/templates/system_jobs.html b/admin/dashboard/templates/system_jobs.html index 35d3de9..af46ddd 100644 --- a/admin/dashboard/templates/system_jobs.html +++ b/admin/dashboard/templates/system_jobs.html @@ -33,6 +33,28 @@ {% raw %}{{ scope.row.last_status || 'never' }}{% endraw %} + + + + + + + + + +