ShiErFeng/shierfeng-fastapi-backend/cli/tui/adapters/jobs.py
2026-07-24 09:43:10 +08:00

748 lines
28 KiB
Python

from typing import Any
from cli.tui.adapters.base import BaseBrowserAdapter
from cli.tui.adapters.models import (
TUI_ADAPTER_MODEL_RENDERER,
BrowserPageSnapshot,
BrowserRecordSnapshot,
DetailSectionSnapshot,
)
from cli.tui.copy import TUI_COPY
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
from cli.tui.search import JOB_FILTER_OPTIONS
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
class JobRenderingSupport:
"""
任务浏览页渲染支持对象。
该对象负责状态文本、时间线标记、成功率信号条和失败任务提取等基础
逻辑,供筛选、分区和记录构建复用。
:param page_adapter: 任务浏览页适配器
"""
def __init__(self, page_adapter: BaseBrowserAdapter) -> None:
"""
初始化任务浏览页渲染支持对象。
:param page_adapter: 任务浏览页适配器
:return: None
"""
self.page_adapter = page_adapter
def extract_failed_job_names(self, payload: dict[str, Any] | None) -> set[str]:
"""
从失败日志结果中提取任务名称集合。
:param payload: 失败日志负载
:return: 失败任务名称集合
"""
rows = self.page_adapter.extract_page_rows(payload)
failed_job_names: set[str] = set()
for row in rows:
job_name = str(row.get('jobName', '') or '').strip()
if job_name:
failed_job_names.add(job_name)
return failed_job_names
@staticmethod
def render_job_status(value: object) -> str:
"""
将任务状态码转换为可读文本。
:param value: 原始状态值
:return: 中文状态文本
"""
normalized = str(value).strip()
if normalized == '0':
return '正常'
if normalized == '1':
return '暂停'
return normalized or '-'
@staticmethod
def render_job_log_status(value: object) -> str:
"""
将任务日志状态码转换为可读文本。
:param value: 原始状态值
:return: 中文状态文本
"""
normalized = str(value).strip()
if normalized == '0':
return '成功'
if normalized == '1':
return '失败'
return normalized or '-'
def render_job_log_timeline_title(self, row: dict[str, Any]) -> str:
"""
渲染单条任务日志时间线标题。
:param row: 日志行数据
:return: 时间线标题文本
"""
created_at = SHELL_TEXT_FORMATTER.truncate_text(row.get('createTime', '-'), 24)
if created_at and created_at != '-':
return f'{created_at} · {self.render_job_log_status(row.get("status", "-"))}'
return f'日志 {row.get("jobLogId", "-")} · {self.render_job_log_status(row.get("status", "-"))}'
@staticmethod
def render_timeline_marker(status: object) -> str:
"""
根据日志状态渲染时间线节点标记。
:param status: 原始日志状态
:return: 节点标记
"""
return 'x' if str(status).strip() == '1' else 'o'
@staticmethod
def render_signal_bar(passed: int, total: int, *, width: int = 8) -> str:
"""
渲染 ASCII 成功率条。
:param passed: 成功数量
:param total: 总数量
:param width: 条形宽度
:return: ASCII 条形
"""
if total <= 0:
return f'[{"-" * width}]'
safe_passed = max(0, min(passed, total))
filled = round((safe_passed / total) * width)
return f'[{"#" * filled}{"-" * max(0, width - filled)}]'
@staticmethod
def extract_latest_log_time(rows: list[dict[str, Any]]) -> str:
"""
提取日志列表中的最近时间。
:param rows: 日志行数据
:return: 最近时间文本
"""
if not rows:
return '-'
latest_time = str(rows[0].get('createTime', '-') or '-').strip()
return latest_time or '-'
class JobRowFilter:
"""
任务浏览行过滤器。
该对象负责按筛选键和搜索词过滤任务行数据。
"""
@staticmethod
def apply_job_filter(
rows: list[dict[str, Any]],
failed_job_names: set[str],
filter_key: str,
) -> list[dict[str, Any]]:
"""
按筛选键过滤任务行数据。
:param rows: 原始任务行列表
:param failed_job_names: 最近失败任务名称集合
:param filter_key: 当前筛选键
:return: 过滤后的任务行列表
"""
normalized_filter = str(filter_key).strip().lower()
if normalized_filter == 'failed':
return [row for row in rows if str(row.get('jobName', '') or '').strip() in failed_job_names]
if normalized_filter == 'paused':
return [row for row in rows if str(row.get('status', '')).strip() == '1']
if normalized_filter == 'ok':
return [
row
for row in rows
if str(row.get('jobName', '') or '').strip() not in failed_job_names
and str(row.get('status', '')).strip() != '1'
]
return rows
@staticmethod
def apply_job_query(rows: list[dict[str, Any]], query: str) -> list[dict[str, Any]]:
"""
按任务名称查询词过滤任务行数据。
:param rows: 原始任务行列表
:param query: 当前搜索词
:return: 过滤后的任务行列表
"""
normalized_query = str(query).strip().lower()
if not normalized_query:
return rows
return [
row
for row in rows
if normalized_query in str(row.get('jobName', '') or '').strip().lower()
or normalized_query in str(row.get('jobGroup', '') or '').strip().lower()
]
class JobSectionBuilder:
"""
任务浏览页分区构建器。
该构建器负责构建任务页共享分区以及单条任务详情分区。
:param page_adapter: 任务浏览页适配器
:param rendering: 任务浏览页渲染支持对象
"""
def __init__(
self,
page_adapter: BaseBrowserAdapter,
rendering: JobRenderingSupport,
) -> None:
"""
初始化任务浏览页分区构建器。
:param page_adapter: 任务浏览页适配器
:param rendering: 任务浏览页渲染支持对象
:return: None
"""
self.page_adapter = page_adapter
self.rendering = rendering
def build_job_failure_aggregate_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建任务失败聚合共享分区。
:param payload: 失败日志 JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='失败聚合',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='失败聚合', empty_value='不可用'
),
)
rows = self.page_adapter.extract_page_rows(payload)
if not rows:
return DetailSectionSnapshot(
title='失败聚合',
status='ok',
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='失败日志',
empty_value='0 条',
detail='当前失败日志池为空,最近没有采集到失败执行记录',
),
)
grouped_rows: dict[str, list[dict[str, Any]]] = {}
for row in rows:
job_name = str(row.get('jobName', '-') or '-').strip() or '-'
grouped_rows.setdefault(job_name, []).append(row)
sorted_groups = sorted(
grouped_rows.items(),
key=lambda item: (-len(item[1]), self.rendering.extract_latest_log_time(item[1]), item[0]),
reverse=False,
)
lines = [
'## 失败池',
f'失败日志: {len(rows)}',
f'涉及任务: {len(grouped_rows)}',
f'最近失败: {self.rendering.extract_latest_log_time(rows)}',
'',
'## 高频失败任务',
]
for index, (job_name, job_rows) in enumerate(sorted_groups[:6], start=1):
latest_row = job_rows[0]
latest_time = self.rendering.extract_latest_log_time(job_rows)
latest_message = SHELL_TEXT_FORMATTER.truncate_text(latest_row.get('jobMessage', '-'), 48)
lines.extend(
[
f'[{index}] {SHELL_TEXT_FORMATTER.truncate_text(job_name, 28)} · {len(job_rows)}',
f'> 最近失败: {latest_time}',
f'> 最近结果: {latest_message}',
]
)
exception_info = SHELL_TEXT_FORMATTER.truncate_text(latest_row.get('exceptionInfo', ''), 48)
if exception_info:
lines.append(f'> 异常摘要: {exception_info}')
lines.append('')
if lines[-1] == '':
lines.pop()
return DetailSectionSnapshot(
title='失败聚合',
status='warn',
lines=lines,
)
def build_jobs_overview_section(
self,
rows: list[dict[str, Any]],
filtered_rows: list[dict[str, Any]],
failed_job_names: set[str],
paused_count: int,
failed_logs_payload: dict[str, Any] | None,
filter_label: str,
) -> DetailSectionSnapshot:
"""
构建任务页总览判断共享分区。
:param rows: 原始任务行列表
:param filtered_rows: 当前筛选后的任务行列表
:param failed_job_names: 最近失败任务名称集合
:param paused_count: 暂停任务数量
:param failed_logs_payload: 失败日志负载
:param filter_label: 当前筛选标签
:return: 分区快照
"""
failed_rows = self.page_adapter.extract_page_rows(failed_logs_payload)
latest_failure = self.rendering.extract_latest_log_time(failed_rows)
status = 'ok'
conclusion = '当前任务基线正常,可继续查看执行轨迹与最近执行记录'
if failed_job_names:
status = 'warn'
conclusion = '存在失败任务,优先查看失败聚合与失败执行记录'
elif paused_count > 0:
status = 'warn'
conclusion = '存在暂停任务,建议确认停用原因与恢复窗口'
return DetailSectionSnapshot(
title='总览判断',
status=status,
lines=[
'## 当前结论',
conclusion,
'',
'## 核心指标',
f'当前筛选: {filter_label}',
f'已加载任务: {len(rows)}',
f'当前匹配: {len(filtered_rows)}',
f'失败任务: {len(failed_job_names)}',
f'暂停任务: {paused_count}',
f'最近失败: {latest_failure}',
'',
'## 建议入口',
'优先关注:失败聚合 / 暂停任务 / 执行轨迹',
],
)
def build_job_focus_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建任务概览分区。
:param payload: `job detail` JSON 负载
:return: 分区快照
"""
job = payload.get('job') if isinstance(payload, dict) else None
if not isinstance(job, dict):
return DetailSectionSnapshot(
title='任务摘要',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='任务', empty_value=''),
)
return DetailSectionSnapshot(
title='任务摘要',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 任务身份',
f'任务 ID: {job.get("jobId", "-")}',
f'任务名称: {SHELL_TEXT_FORMATTER.truncate_text(job.get("jobName", "-"), 40)}',
f'任务分组: {SHELL_TEXT_FORMATTER.truncate_text(job.get("jobGroup", "-"), 24)}',
f'运行状态: {self.rendering.render_job_status(job.get("status", "-"))}',
],
)
@staticmethod
def build_job_schedule_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建任务调度配置分区。
:param payload: `job detail` JSON 负载
:return: 分区快照
"""
job = payload.get('job') if isinstance(payload, dict) else None
if not isinstance(job, dict):
return DetailSectionSnapshot(
title='调度配置',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='调度配置', empty_value='不可用'
),
)
return DetailSectionSnapshot(
title='调度配置',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 调度表达式',
f'Cron 表达式: {SHELL_TEXT_FORMATTER.truncate_text(job.get("cronExpression", "-"), 48)}',
'',
'## 执行入口',
f'调用目标: {SHELL_TEXT_FORMATTER.truncate_text(job.get("invokeTarget", "-"), 72)}',
],
)
def build_job_logs_section(self, payload: dict[str, Any] | None, *, title: str) -> DetailSectionSnapshot:
"""
构建任务日志分区。
:param payload: `job logs` JSON 负载
:param title: 分区标题
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title=title,
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='日志', empty_value='不可用'),
)
rows = self.page_adapter.extract_page_rows(payload)
lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='日志记录',
empty_value='0 条',
detail='当前筛选条件下没有相关日志',
)
section_status = 'ok'
if rows:
lines = []
if any(str(row.get('status', '')).strip() == '1' for row in rows):
section_status = 'warn'
for row in rows[:8]:
exception_info = SHELL_TEXT_FORMATTER.truncate_text(row.get('exceptionInfo', '-'), 56)
timeline_marker = self.rendering.render_timeline_marker(row.get('status', '-'))
rendered_status = self.rendering.render_job_log_status(row.get('status', '-'))
lines.extend(
[
f'## 节点 {row.get("jobLogId", "-")} · {self.rendering.render_job_log_timeline_title(row)}',
f'> {timeline_marker} 状态: {rendered_status}',
f'> 时间: {SHELL_TEXT_FORMATTER.truncate_text(row.get("createTime", "-"), 24)}',
f'> 任务: {SHELL_TEXT_FORMATTER.truncate_text(row.get("jobName", "-"), 24)}',
f'> 结果: {SHELL_TEXT_FORMATTER.truncate_text(row.get("jobMessage", "-"), 56)}',
*([f'> 异常: {exception_info}'] if exception_info and exception_info != '-' else []),
'> 轨道: ├─采集 ├─执行 └─落盘',
f'> 轨迹: {"" if timeline_marker == "o" else ""}{"" if timeline_marker == "o" else ""}─◎',
'',
]
)
return DetailSectionSnapshot(
title=title,
status=section_status,
lines=lines[:-1] if len(lines) > 1 and lines[-1] == '' else lines,
)
def build_job_log_summary_section(
self,
recent_logs_payload: dict[str, Any] | None,
failed_logs_payload: dict[str, Any] | None,
) -> DetailSectionSnapshot:
"""
构建任务日志摘要分区。
:param recent_logs_payload: 最近日志 JSON 负载
:param failed_logs_payload: 失败日志 JSON 负载
:return: 分区快照
"""
if not isinstance(recent_logs_payload, dict) or not recent_logs_payload.get('ok', False):
return DetailSectionSnapshot(
title='执行摘要',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
recent_logs_payload, empty_label='日志摘要', empty_value='不可用'
),
)
recent_rows = self.page_adapter.extract_page_rows(recent_logs_payload)
failed_rows = self.page_adapter.extract_page_rows(failed_logs_payload)
success_count = sum(1 for row in recent_rows if str(row.get('status', '')).strip() == '0')
failed_count = sum(1 for row in recent_rows if str(row.get('status', '')).strip() not in {'', '0'})
status = 'warn' if failed_count > 0 or failed_rows else 'ok'
total_count = len(recent_rows)
latest_run = self.rendering.extract_latest_log_time(recent_rows)
latest_failure = self.rendering.extract_latest_log_time(failed_rows)
return DetailSectionSnapshot(
title='执行摘要',
status=status,
lines=[
'## 执行统计',
f'最近采样日志: {total_count}',
f'执行成功: {success_count}',
f'执行失败: {failed_count}',
f'成功率信号: {self.rendering.render_signal_bar(success_count, total_count)} {success_count}/{total_count or 0}',
'',
'## 时间线锚点',
f'最近一次执行: {latest_run}',
f'最近一次失败: {latest_failure}',
'',
'## 轨道视图',
f'执行轨道: {"".join("" if str(row.get("status", "")).strip() == "0" else "" for row in recent_rows[:6]) or "-"}',
f'失败轨道: {"".join("" for _ in failed_rows[:6]) or "-"}',
f'轨道窗口: {"[执行流]" if recent_rows else "-"} {"[失败流]" if failed_rows else ""}'.strip(),
'',
'## 风险信号',
f'失败记录池: {len(failed_rows)}',
],
)
def load_job_detail_sections(self, job_row: dict[str, Any], env: str) -> list[DetailSectionSnapshot]:
"""
按需加载单条任务详情与日志分区。
:param job_row: 任务列表行数据
:param env: 当前运行环境
:return: 详情分区列表
"""
job_id = job_row.get('jobId', '-')
job_name = str(job_row.get('jobName', '-') or '-')
detail_payload = NESTED_CLI_SUPPORT.run(
'job',
'detail',
str(job_id),
f'--env={env}',
'--output=json',
parse_json=True,
).payload
log_arguments = ['job', 'logs', f'--env={env}', '--paged', '--page-size=8', '--output=json']
if job_name.strip() and job_name != '-':
log_arguments.append(f'--job-name={job_name}')
recent_logs_payload = NESTED_CLI_SUPPORT.run(*log_arguments, parse_json=True).payload
failed_logs_payload = NESTED_CLI_SUPPORT.run(*[*log_arguments, '--status=1'], parse_json=True).payload
return [
self.build_job_focus_section(detail_payload),
self.build_job_schedule_section(detail_payload),
self.build_job_log_summary_section(recent_logs_payload, failed_logs_payload),
self.build_job_logs_section(recent_logs_payload, title='最近执行记录'),
self.build_job_logs_section(failed_logs_payload, title='失败执行记录'),
]
class JobRecordBuilder:
"""
任务浏览记录构建器。
该对象负责构建任务页单条浏览记录与失败兜底记录。
:param page_adapter: 任务浏览页适配器
:param rendering: 任务浏览页渲染支持对象
:param section_builder: 任务浏览页分区构建器
"""
def __init__(
self,
page_adapter: BaseBrowserAdapter,
rendering: JobRenderingSupport,
section_builder: JobSectionBuilder,
) -> None:
"""
初始化任务浏览记录构建器。
:param page_adapter: 任务浏览页适配器
:param rendering: 任务浏览页渲染支持对象
:param section_builder: 任务浏览页分区构建器
:return: None
"""
self.page_adapter = page_adapter
self.rendering = rendering
self.section_builder = section_builder
def build_job_record(self, job_row: dict[str, Any], env: str) -> BrowserRecordSnapshot:
"""
构建单条任务浏览记录。
:param job_row: 任务列表行数据
:param env: 当前运行环境
:return: 浏览记录快照
"""
job_id = job_row.get('jobId', '-')
job_name = str(job_row.get('jobName', '-') or '-')
job_status = str(job_row.get('status', '-') or '-')
job_group = str(job_row.get('jobGroup', '-') or '-')
cron_expression = str(job_row.get('cronExpression', '-') or '-')
rendered_status = self.rendering.render_job_status(job_status)
return BrowserRecordSnapshot(
key=f'job:{job_id}',
title=SHELL_TEXT_FORMATTER.truncate_text(job_name, 40),
status='warn' if str(job_status).strip() == '1' else 'ok',
summary=f'{rendered_status} · Cron {SHELL_TEXT_FORMATTER.truncate_text(cron_expression, 32)}',
metadata_lines=[
'## 任务标识',
f'任务 ID: {job_id}',
f'任务分组: {SHELL_TEXT_FORMATTER.truncate_text(job_group, 24)}',
f'运行状态: {rendered_status}',
'',
'## 调度信息',
f'Cron 表达式: {SHELL_TEXT_FORMATTER.truncate_text(cron_expression, 48)}',
],
detail_sections=[],
detail_loader=lambda job_row=job_row, env=env: self.section_builder.load_job_detail_sections(job_row, env),
)
def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot:
"""
构建任务页失败兜底记录。
:param payload: 失败结果负载
:return: 浏览记录快照
"""
return self.page_adapter.build_failure_record(
key='job:unavailable',
subject='任务',
section_subject='任务列表',
payload=payload,
)
class JobsBrowserAdapter(BaseBrowserAdapter):
"""
任务浏览页适配器。
该适配器负责采集任务列表、失败日志和单条任务详情,并委托协作对象
完成过滤、共享分区、详情分区和记录构建。
"""
def __init__(
self,
rendering: JobRenderingSupport | None = None,
row_filter: JobRowFilter | None = None,
section_builder: JobSectionBuilder | None = None,
record_builder: JobRecordBuilder | None = None,
) -> None:
"""
初始化任务浏览页适配器。
:param rendering: 任务浏览页渲染支持对象
:param row_filter: 任务浏览行过滤器
:param section_builder: 任务浏览页分区构建器
:param record_builder: 任务浏览记录构建器
:return: None
"""
super().__init__(
page_title='任务',
search_view_key='jobs',
filter_options=JOB_FILTER_OPTIONS,
)
self.rendering = rendering or JobRenderingSupport(self)
self.row_filter = row_filter or JobRowFilter()
self.section_builder = section_builder or JobSectionBuilder(self, self.rendering)
self.record_builder = record_builder or JobRecordBuilder(self, self.rendering, self.section_builder)
def collect_snapshot(self, env: str, filter_key: str = 'all', query: str = '') -> BrowserPageSnapshot:
"""
采集定时任务浏览页只读快照。
:param env: 当前运行环境
:param filter_key: 当前筛选键
:param query: 当前搜索词
:return: 浏览页快照
"""
active_filter_option = self.resolve_active_filter(filter_key)
active_filter = active_filter_option.key
active_filter_label = active_filter_option.label
search_context = self.resolve_search_context(query)
jobs_payload = NESTED_CLI_SUPPORT.run(
'job',
'list',
f'--env={env}',
'--paged',
'--page-size=8',
'--output=json',
parse_json=True,
).payload
failed_logs_payload = NESTED_CLI_SUPPORT.run(
'job',
'logs',
f'--env={env}',
'--paged',
'--page-size=20',
'--status=1',
'--output=json',
parse_json=True,
).payload
failed_job_names = self.rendering.extract_failed_job_names(failed_logs_payload)
if not isinstance(jobs_payload, dict) or not jobs_payload.get('ok', False):
return BrowserPageSnapshot(
title='任务',
subtitle=TUI_COPY.build_unavailable_subtitle(
'任务',
SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(jobs_payload), 72),
),
records=[self.record_builder.build_failure_record(jobs_payload)],
shared_sections=[
self.section_builder.build_jobs_overview_section(
[], [], failed_job_names, 0, failed_logs_payload, active_filter_label
),
self.section_builder.build_job_failure_aggregate_section(failed_logs_payload),
],
filters=list(self.filter_options),
active_filter_key=active_filter,
search=search_context,
)
rows = self.extract_page_rows(jobs_payload)
sorted_rows = sorted(
rows,
key=lambda row: (
0
if str(row.get('jobName', '') or '').strip() in failed_job_names
else 1
if str(row.get('status', '')).strip() == '1'
else 2,
str(row.get('jobName', '') or ''),
),
)
filtered_rows = self.row_filter.apply_job_query(
self.row_filter.apply_job_filter(sorted_rows, failed_job_names, active_filter),
query,
)
records = [self.record_builder.build_job_record(job_row, env) for job_row in filtered_rows[:8]]
if not records:
records = [
self.build_empty_record(
key='job:none',
subject='任务',
empty_label='任务列表',
has_source_rows=bool(sorted_rows),
filtered_summary='当前筛选条件下没有匹配任务',
empty_summary='当前环境中还没有定时任务',
filtered_empty_value='暂无任务',
empty_empty_value='暂无任务配置',
filtered_detail='当前筛选条件下没有匹配任务',
empty_detail='当前环境中尚未配置定时任务',
)
]
paused_count = sum(1 for row in rows if str(row.get('status', '')).strip() == '1')
shared_sections = [
self.section_builder.build_jobs_overview_section(
rows,
filtered_rows,
failed_job_names,
paused_count,
failed_logs_payload,
active_filter_label,
),
self.section_builder.build_job_failure_aggregate_section(failed_logs_payload),
]
return BrowserPageSnapshot(
title='任务',
subtitle=TUI_DIAGNOSTIC_SERVICE.build_jobs_diagnostic_subtitle(
active_filter_label,
len(filtered_rows),
failed_job_names,
paused_count,
),
records=records,
shared_sections=shared_sections,
filters=list(self.filter_options),
active_filter_key=active_filter,
search=search_context,
)
JOBS_BROWSER_ADAPTER = JobsBrowserAdapter()