调整部分前端布局和后端增删改逻辑
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cli.tui.adapters.app import APP_DETAIL_ADAPTER as _APP_DETAIL_ADAPTER
|
||||
from cli.tui.adapters.cache import CACHE_BROWSER_ADAPTER as _CACHE_BROWSER_ADAPTER
|
||||
from cli.tui.adapters.configs import CONFIGS_BROWSER_ADAPTER as _CONFIGS_BROWSER_ADAPTER
|
||||
from cli.tui.adapters.crypto import CRYPTO_DETAIL_ADAPTER as _CRYPTO_DETAIL_ADAPTER
|
||||
from cli.tui.adapters.database import DATABASE_DETAIL_ADAPTER as _DATABASE_DETAIL_ADAPTER
|
||||
from cli.tui.adapters.gen import GEN_BROWSER_ADAPTER as _GEN_BROWSER_ADAPTER
|
||||
from cli.tui.adapters.health import (
|
||||
DASHBOARD_ADAPTER as _DASHBOARD_ADAPTER,
|
||||
)
|
||||
from cli.tui.adapters.health import DashboardPanelSnapshot, DashboardSnapshot
|
||||
from cli.tui.adapters.jobs import JOBS_BROWSER_ADAPTER as _JOBS_BROWSER_ADAPTER
|
||||
from cli.tui.adapters.models import (
|
||||
BrowserPageSnapshot,
|
||||
BrowserRecordSnapshot,
|
||||
DetailPageSnapshot,
|
||||
DetailSectionSnapshot,
|
||||
)
|
||||
from cli.tui.adapters.ops import OPS_DETAIL_ADAPTER as _OPS_DETAIL_ADAPTER
|
||||
|
||||
PageSnapshot = BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot
|
||||
SnapshotCollector = Callable[..., PageSnapshot]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiSnapshotCollectorRegistry:
|
||||
"""
|
||||
TUI 页面快照采集注册表。
|
||||
|
||||
该对象集中维护页面视图标识与其采集函数之间的映射,
|
||||
使应用层和测试层可以通过统一入口发现当前可用的采集器。
|
||||
|
||||
:param collectors: 视图到采集函数的映射
|
||||
"""
|
||||
|
||||
collectors: dict[str, SnapshotCollector]
|
||||
|
||||
@staticmethod
|
||||
def normalize_view_key(view_key: str) -> str:
|
||||
"""
|
||||
规范化页面视图标识。
|
||||
|
||||
:param view_key: 原始页面视图标识
|
||||
:return: 规范化后的视图标识
|
||||
"""
|
||||
return str(view_key).strip().lower()
|
||||
|
||||
def get_collector(self, view_key: str) -> SnapshotCollector | None:
|
||||
"""
|
||||
读取指定视图的快照采集器。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:return: 采集函数
|
||||
"""
|
||||
return self.collectors.get(self.normalize_view_key(view_key))
|
||||
|
||||
def collect(self, view_key: str, *args, **kwargs) -> PageSnapshot:
|
||||
"""
|
||||
调用指定视图的快照采集器。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:param args: 位置参数
|
||||
:param kwargs: 关键字参数
|
||||
:return: 页面快照
|
||||
:raises KeyError: 当视图未注册时抛出
|
||||
"""
|
||||
collector = self.get_collector(view_key)
|
||||
if collector is None:
|
||||
raise KeyError(self.normalize_view_key(view_key))
|
||||
return collector(*args, **kwargs)
|
||||
|
||||
|
||||
TUI_SNAPSHOT_COLLECTOR_REGISTRY = TuiSnapshotCollectorRegistry(
|
||||
collectors={
|
||||
'dashboard': _DASHBOARD_ADAPTER.collect_snapshot,
|
||||
'app': _APP_DETAIL_ADAPTER.collect_snapshot,
|
||||
'ops': _OPS_DETAIL_ADAPTER.collect_snapshot,
|
||||
'database': _DATABASE_DETAIL_ADAPTER.collect_snapshot,
|
||||
'cache': _CACHE_BROWSER_ADAPTER.collect_snapshot,
|
||||
'jobs': _JOBS_BROWSER_ADAPTER.collect_snapshot,
|
||||
'gen': _GEN_BROWSER_ADAPTER.collect_snapshot,
|
||||
'configs': _CONFIGS_BROWSER_ADAPTER.collect_snapshot,
|
||||
'crypto': _CRYPTO_DETAIL_ADAPTER.collect_snapshot,
|
||||
}
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'TUI_SNAPSHOT_COLLECTOR_REGISTRY',
|
||||
'BrowserPageSnapshot',
|
||||
'BrowserRecordSnapshot',
|
||||
'DashboardPanelSnapshot',
|
||||
'DashboardSnapshot',
|
||||
'DetailPageSnapshot',
|
||||
'DetailSectionSnapshot',
|
||||
'TuiSnapshotCollectorRegistry',
|
||||
]
|
||||
@@ -0,0 +1,680 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cli.tui.adapters.base import BaseDetailAdapter
|
||||
from cli.tui.adapters.models import (
|
||||
TUI_ADAPTER_MODEL_RENDERER,
|
||||
DetailPageSnapshot,
|
||||
DetailSectionSnapshot,
|
||||
)
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
|
||||
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
|
||||
|
||||
COMPLETION_PREVIEW_LINE_LIMIT = 12
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppDetailSourcePayloads:
|
||||
"""
|
||||
应用详情页原始数据源快照。
|
||||
|
||||
:param env_payload: `app env` 结果
|
||||
:param config_payload: `app config` 结果
|
||||
:param doctor_payload: `app doctor` 结果
|
||||
:param routes_payload: `app routes` 结果
|
||||
:param completion_payload: `completion doctor` 结果
|
||||
:param completion_preview_result: `completion show` 命令结果
|
||||
:param preview_shell: 当前补全脚本预览 shell
|
||||
"""
|
||||
|
||||
env_payload: dict[str, Any] | None
|
||||
config_payload: dict[str, Any] | None
|
||||
doctor_payload: dict[str, Any] | None
|
||||
routes_payload: dict[str, Any] | None
|
||||
completion_payload: dict[str, Any] | None
|
||||
completion_preview_result: Any
|
||||
preview_shell: str
|
||||
|
||||
|
||||
class AppDetailSnapshotCollector:
|
||||
"""
|
||||
应用详情页数据采集器。
|
||||
|
||||
该对象负责拉取应用详情页所需的多路 CLI 原始结果,
|
||||
让 `AppDetailAdapter` 保持详情页编排职责。
|
||||
|
||||
:param section_builder: 应用详情分区构建器
|
||||
"""
|
||||
|
||||
def __init__(self, section_builder: 'AppSectionBuilder') -> None:
|
||||
"""
|
||||
初始化应用详情页数据采集器。
|
||||
|
||||
:param section_builder: 应用详情分区构建器
|
||||
:return: None
|
||||
"""
|
||||
self.section_builder = section_builder
|
||||
|
||||
def collect(self, env: str) -> AppDetailSourcePayloads:
|
||||
"""
|
||||
采集应用详情页所需原始结果。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:return: 应用详情页原始数据源快照
|
||||
"""
|
||||
env_payload = NESTED_CLI_SUPPORT.run(
|
||||
'app',
|
||||
'env',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
config_payload = NESTED_CLI_SUPPORT.run(
|
||||
'app',
|
||||
'config',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
doctor_payload = NESTED_CLI_SUPPORT.run(
|
||||
'app',
|
||||
'doctor',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
routes_payload = NESTED_CLI_SUPPORT.run(
|
||||
'app',
|
||||
'routes',
|
||||
f'--env={env}',
|
||||
'--group-by=tag',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
completion_payload = NESTED_CLI_SUPPORT.run(
|
||||
'completion',
|
||||
'doctor',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
preview_shell = self.section_builder.resolve_completion_preview_shell(completion_payload)
|
||||
completion_preview_result = NESTED_CLI_SUPPORT.run('completion', 'show', preview_shell)
|
||||
return AppDetailSourcePayloads(
|
||||
env_payload=env_payload,
|
||||
config_payload=config_payload,
|
||||
doctor_payload=doctor_payload,
|
||||
routes_payload=routes_payload,
|
||||
completion_payload=completion_payload,
|
||||
completion_preview_result=completion_preview_result,
|
||||
preview_shell=preview_shell,
|
||||
)
|
||||
|
||||
|
||||
class AppSectionBuilder:
|
||||
"""
|
||||
应用详情分区构建器。
|
||||
|
||||
该构建器负责将应用、补全和路由相关 CLI 结果转换为 TUI 详情页分区,
|
||||
使详情页适配器本体只保留采集与编排职责。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def resolve_completion_preview_shell(payload: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
解析补全脚本预览应使用的 shell。
|
||||
|
||||
:param payload: `completion doctor` JSON 负载
|
||||
:return: shell 名称
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return 'bash'
|
||||
active_shell = str(payload.get('activeShell') or '').strip().lower()
|
||||
shells = payload.get('shells') if isinstance(payload.get('shells'), dict) else {}
|
||||
if active_shell and isinstance(shells.get(active_shell), dict) and shells[active_shell].get('supported', False):
|
||||
return active_shell
|
||||
for shell_name in ('bash', 'zsh', 'fish', 'powershell'):
|
||||
shell_payload = shells.get(shell_name)
|
||||
if isinstance(shell_payload, dict) and shell_payload.get('supported', False):
|
||||
return shell_name
|
||||
return 'bash'
|
||||
|
||||
@staticmethod
|
||||
def build_env_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建应用环境解析分区。
|
||||
|
||||
:param payload: `app env` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
runtime = payload.get('runtime') if isinstance(payload, dict) else None
|
||||
if not isinstance(runtime, 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'CLI 目标环境: {runtime.get("cliEnv", "-")}',
|
||||
f'配置模块环境: {runtime.get("configEnv", "-")}',
|
||||
f'进程环境变量: {runtime.get("appEnv", "-") or "-"}',
|
||||
'',
|
||||
'## 文件与进程',
|
||||
f'环境文件: {runtime.get("envFile", "-")}',
|
||||
f'环境文件存在: {"是" if runtime.get("envFileExists", False) else "否"}',
|
||||
f'后端目录: {SHELL_TEXT_FORMATTER.truncate_text(runtime.get("backendDir", "-"), 64)}',
|
||||
f'Python 可执行文件: {SHELL_TEXT_FORMATTER.truncate_text(runtime.get("pythonExecutable", "-"), 64)}',
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_config_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建应用基础配置分区。
|
||||
|
||||
:param payload: `app config` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
config = payload.get('config') if isinstance(payload, dict) else None
|
||||
if not isinstance(config, 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'应用名称: {SHELL_TEXT_FORMATTER.truncate_text(config.get("name", "-"), 40)}',
|
||||
f'监听地址: {config.get("host", "-")}:{config.get("port", "-")}',
|
||||
f'根路径: {config.get("rootPath", "-") or "/"}',
|
||||
f'工作进程: {config.get("workers", "-")}',
|
||||
f'热重载: {"开启" if config.get("reload", False) else "关闭"}',
|
||||
'',
|
||||
'## 文档与日志',
|
||||
f'Swagger: {"关闭" if config.get("disableSwagger", False) else "开启"}',
|
||||
f'ReDoc: {"关闭" if config.get("disableRedoc", False) else "开启"}',
|
||||
f'日志级别: {config.get("logLevel", "-")}',
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_dependency_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建应用依赖配置分区。
|
||||
|
||||
:param payload: `app config` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
config = payload.get('config') if isinstance(payload, dict) else None
|
||||
if not isinstance(config, 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'类型: {config.get("dbType", "-")}',
|
||||
f'地址: {config.get("dbHost", "-")}:{config.get("dbPort", "-")}',
|
||||
f'数据库名: {config.get("dbDatabase", "-")}',
|
||||
'',
|
||||
'## Redis 与加密',
|
||||
f'Redis 地址: {config.get("redisHost", "-")}:{config.get("redisPort", "-")}',
|
||||
f'传输加密: {"开启" if config.get("transportCryptoEnabled", False) else "关闭"}',
|
||||
f'加密模式: {config.get("transportCryptoMode", "-")}',
|
||||
],
|
||||
)
|
||||
|
||||
def build_routes_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建应用路由摘要分区。
|
||||
|
||||
:param payload: `app routes` 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='不可用'),
|
||||
)
|
||||
|
||||
count = payload.get('count', 0)
|
||||
routes = payload.get('routes') if isinstance(payload.get('routes'), list) else []
|
||||
grouped_routes = payload.get('groupedRoutes') if isinstance(payload.get('groupedRoutes'), dict) else {}
|
||||
group_items = sorted(
|
||||
((str(tag), len(items) if isinstance(items, list) else 0) for tag, items in grouped_routes.items()),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)
|
||||
|
||||
lines = [
|
||||
'## 路由规模',
|
||||
f'总路由数: {count}',
|
||||
f'标签分组数: {len(group_items)}',
|
||||
'',
|
||||
'## 主要标签',
|
||||
]
|
||||
if group_items:
|
||||
lines.extend(f'> {tag} · {size} 条' for tag, size in group_items[:6])
|
||||
else:
|
||||
lines.append('> 暂无标签分组数据')
|
||||
|
||||
if routes:
|
||||
lines.extend(['', '## 路由样本'])
|
||||
for route in routes[:6]:
|
||||
if not isinstance(route, dict):
|
||||
continue
|
||||
methods = '/'.join(route.get('methods', [])) if isinstance(route.get('methods'), list) else '-'
|
||||
lines.extend(
|
||||
[
|
||||
f'> [{methods or "-"}] {SHELL_TEXT_FORMATTER.truncate_text(route.get("path", "-"), 48)}',
|
||||
f'> {SHELL_TEXT_FORMATTER.truncate_text(route.get("summary", "-") or "-", 48)}',
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
'',
|
||||
*TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label='路由样本',
|
||||
empty_value='0 条',
|
||||
detail='当前环境未返回可展示的路由样本',
|
||||
suggestion=TUI_COPY.build_refresh_page_suggestion('应用', '确认当前环境是否已完成路由注册'),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
return DetailSectionSnapshot(
|
||||
title='路由摘要',
|
||||
status='ok',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_completion_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建 shell completion 诊断分区。
|
||||
|
||||
:param payload: `completion doctor` 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='不可用'
|
||||
),
|
||||
)
|
||||
|
||||
shells = payload.get('shells') if isinstance(payload.get('shells'), dict) else {}
|
||||
active_shell = str(payload.get('activeShell') or '-')
|
||||
env_choices = payload.get('envChoices') if isinstance(payload.get('envChoices'), list) else []
|
||||
active_shell_payload = shells.get(active_shell) if isinstance(shells.get(active_shell), dict) else None
|
||||
target_file = active_shell_payload.get('targetFile', '-') if isinstance(active_shell_payload, dict) else '-'
|
||||
source_command = (
|
||||
active_shell_payload.get('sourceCommand', '-') if isinstance(active_shell_payload, dict) else '-'
|
||||
)
|
||||
install_command = (
|
||||
active_shell_payload.get('recommendedInstallCommand', '-')
|
||||
if isinstance(active_shell_payload, dict)
|
||||
else payload.get('recommendedInstallCommand', '-')
|
||||
)
|
||||
|
||||
lines = [
|
||||
'## 当前环境',
|
||||
f'活动 Shell: {active_shell}',
|
||||
f'项目目录: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("projectDir", "-"), 64)}',
|
||||
f'补全环境变量: {payload.get("completeEnvVar", "-")}',
|
||||
f'环境候选数: {len(env_choices)}',
|
||||
'',
|
||||
'## 激活建议',
|
||||
f'目标脚本: {SHELL_TEXT_FORMATTER.truncate_text(target_file, 64)}',
|
||||
f'加载命令: {SHELL_TEXT_FORMATTER.truncate_text(source_command, 72)}',
|
||||
f'推荐安装命令: {SHELL_TEXT_FORMATTER.truncate_text(install_command, 72)}',
|
||||
]
|
||||
|
||||
shell_lines = []
|
||||
for shell_name in ('bash', 'zsh', 'fish', 'powershell'):
|
||||
shell_payload = shells.get(shell_name)
|
||||
if not isinstance(shell_payload, dict):
|
||||
continue
|
||||
supported = '支持' if shell_payload.get('supported', False) else '不支持'
|
||||
detected = '当前 Shell' if shell_payload.get('detected', False) else '候选 Shell'
|
||||
shell_lines.append(f'> {shell_name} · {supported} · {detected}')
|
||||
if shell_lines:
|
||||
lines.extend(['', '## Shell 支持'])
|
||||
lines.extend(shell_lines)
|
||||
|
||||
return DetailSectionSnapshot(
|
||||
title='补全诊断',
|
||||
status='ok',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def build_completion_preview_section(
|
||||
self,
|
||||
shell: str,
|
||||
preview_result: Any,
|
||||
) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建补全脚本预览分区。
|
||||
|
||||
:param shell: 当前预览 shell
|
||||
:param preview_result: `completion show` 调用结果
|
||||
:return: 分区快照
|
||||
"""
|
||||
stdout = str(getattr(preview_result, 'stdout', '') or '').strip()
|
||||
stderr = str(getattr(preview_result, 'stderr', '') or '').strip()
|
||||
returncode = int(getattr(preview_result, 'returncode', 1) or 0)
|
||||
if returncode != 0:
|
||||
return DetailSectionSnapshot(
|
||||
title='补全脚本预览',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
{'ok': False, 'message': stderr or stdout or '补全脚本生成失败'},
|
||||
empty_label='补全脚本',
|
||||
empty_value='不可用',
|
||||
),
|
||||
)
|
||||
preview_lines = [
|
||||
SHELL_TEXT_FORMATTER.truncate_text(line, 88) for line in stdout.splitlines()[:COMPLETION_PREVIEW_LINE_LIMIT]
|
||||
]
|
||||
if not preview_lines:
|
||||
return DetailSectionSnapshot(
|
||||
title='补全脚本预览',
|
||||
status='info',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label='补全脚本',
|
||||
empty_value='0 行',
|
||||
detail='当前 shell 的补全脚本未返回可预览内容',
|
||||
suggestion=TUI_COPY.build_refresh_page_suggestion('应用', '重新生成补全脚本预览'),
|
||||
),
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title='补全脚本预览',
|
||||
status='ok',
|
||||
lines=[
|
||||
'## 预览目标',
|
||||
f'Shell: {shell}',
|
||||
f'预览行数: {len(preview_lines)}',
|
||||
'',
|
||||
'## 脚本片段',
|
||||
*preview_lines,
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_doctor_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建应用启动前检查分区。
|
||||
|
||||
:param payload: `app doctor` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return DetailSectionSnapshot(
|
||||
title='启动前检查',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
payload, empty_label='启动前检查', empty_value='不可用'
|
||||
),
|
||||
)
|
||||
|
||||
database = payload.get('database') if isinstance(payload.get('database'), dict) else {}
|
||||
redis = payload.get('redis') if isinstance(payload.get('redis'), dict) else {}
|
||||
crypto = payload.get('crypto') if isinstance(payload.get('crypto'), dict) else {}
|
||||
return DetailSectionSnapshot(
|
||||
title='启动前检查',
|
||||
status='ok' if payload.get('ok', False) else 'fail',
|
||||
lines=[
|
||||
'## 检查结果',
|
||||
f'环境: {payload.get("env", "-")}',
|
||||
f'数据库: {"正常" if database.get("ok", False) else "异常"}',
|
||||
f'> {SHELL_TEXT_FORMATTER.truncate_text(database.get("message", "-"), 64)}',
|
||||
f'Redis: {"正常" if redis.get("ok", False) else "异常"}',
|
||||
f'> {SHELL_TEXT_FORMATTER.truncate_text(redis.get("message", "-"), 64)}',
|
||||
f'加密组件: {"正常" if crypto.get("ok", False) else "异常"}',
|
||||
f'> {SHELL_TEXT_FORMATTER.truncate_text(crypto.get("message", "-"), 64)}',
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_run_entry_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建应用启动向导入口分区。
|
||||
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title='启动入口',
|
||||
status='info',
|
||||
lines=TUI_COPY.build_command_hint_lines(
|
||||
scenario='准备启动当前环境应用时,可直接启动应用,或先通过向导确认目标环境与启动前检查选项。',
|
||||
command=TUI_COPY.build_cli_command_hint('app', 'run', '--env=dev'),
|
||||
guide='若需要先做启动前检查或切换环境,建议改用 `wizard app-run` 进入交互式启动流程。',
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_completion_install_entry_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建 shell 补全安装入口分区。
|
||||
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title='补全安装入口',
|
||||
status='info',
|
||||
lines=TUI_COPY.build_command_hint_lines(
|
||||
scenario='准备为当前 Shell 安装或修复补全脚本时,可直接执行补全安装命令,并按需把激活命令写入 rc 文件。',
|
||||
command=TUI_COPY.build_cli_command_hint('completion', 'install', '--activate', '--output=text'),
|
||||
guide='当前页面提供补全诊断与安装入口;执行安装后可返回补全诊断分区确认 target file 和 source command 是否更新。',
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_overview_section(
|
||||
env_payload: dict[str, Any] | None,
|
||||
config_payload: dict[str, Any] | None,
|
||||
doctor_payload: dict[str, Any] | None,
|
||||
routes_payload: dict[str, Any] | None,
|
||||
completion_payload: dict[str, Any] | None,
|
||||
) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建应用页总览判断分区。
|
||||
|
||||
:param env_payload: `app env` JSON 负载
|
||||
:param config_payload: `app config` JSON 负载
|
||||
:param doctor_payload: `app doctor` JSON 负载
|
||||
:param routes_payload: `app routes` JSON 负载
|
||||
:param completion_payload: `completion doctor` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
env_ok = bool(isinstance(env_payload, dict) and env_payload.get('ok', False))
|
||||
config_ok = bool(isinstance(config_payload, dict) and config_payload.get('ok', False))
|
||||
doctor_ok = bool(isinstance(doctor_payload, dict) and doctor_payload.get('ok', False))
|
||||
routes_ok = bool(isinstance(routes_payload, dict) and routes_payload.get('ok', False))
|
||||
completion_ok = bool(isinstance(completion_payload, dict) and completion_payload.get('ok', False))
|
||||
route_count = routes_payload.get('count', 0) if isinstance(routes_payload, dict) else 0
|
||||
runtime = (
|
||||
env_payload.get('runtime')
|
||||
if isinstance(env_payload, dict) and isinstance(env_payload.get('runtime'), dict)
|
||||
else {}
|
||||
)
|
||||
config = (
|
||||
config_payload.get('config')
|
||||
if isinstance(config_payload, dict) and isinstance(config_payload.get('config'), dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
status = 'ok'
|
||||
conclusion = '应用基线正常,可继续查看环境映射、配置摘要与路由状态'
|
||||
if not env_ok or not config_ok:
|
||||
status = 'fail'
|
||||
conclusion = '应用基础信息读取异常,优先确认环境映射与应用配置是否可用'
|
||||
elif not doctor_ok:
|
||||
status = 'fail'
|
||||
conclusion = '启动前检查异常,优先确认数据库、Redis 和加密组件状态'
|
||||
elif not completion_ok:
|
||||
status = 'warn'
|
||||
conclusion = '补全诊断读取异常,建议优先确认项目目录、Shell 类型和补全脚本状态'
|
||||
elif not routes_ok:
|
||||
status = 'warn'
|
||||
conclusion = '路由摘要读取异常,建议先确认当前环境是否完成应用初始化'
|
||||
elif int(route_count or 0) <= 0:
|
||||
status = 'info'
|
||||
conclusion = '当前未返回注册路由,建议检查路由注册流程与运行上下文'
|
||||
|
||||
return DetailSectionSnapshot(
|
||||
title='总览判断',
|
||||
status=status,
|
||||
lines=[
|
||||
'## 当前结论',
|
||||
conclusion,
|
||||
'',
|
||||
'## 核心指标',
|
||||
f'CLI 环境: {runtime.get("cliEnv", "-")}',
|
||||
f'配置环境: {runtime.get("configEnv", "-")}',
|
||||
f'监听地址: {config.get("host", "-")}:{config.get("port", "-")}',
|
||||
f'注册路由: {route_count} 条',
|
||||
f'启动前检查: {"正常" if doctor_ok else "异常"}',
|
||||
f'传输加密: {"开启" if config.get("transportCryptoEnabled", False) else "关闭"}',
|
||||
f'补全诊断: {"正常" if completion_ok else "异常"}',
|
||||
'',
|
||||
'## 建议入口',
|
||||
'优先关注:环境解析 / 应用配置 / 启动前检查 / 补全诊断 / 路由摘要 / 启动入口',
|
||||
],
|
||||
)
|
||||
|
||||
def build_sections(
|
||||
self,
|
||||
*,
|
||||
env_payload: dict[str, Any] | None,
|
||||
config_payload: dict[str, Any] | None,
|
||||
doctor_payload: dict[str, Any] | None,
|
||||
routes_payload: dict[str, Any] | None,
|
||||
completion_payload: dict[str, Any] | None,
|
||||
completion_preview_result: Any,
|
||||
preview_shell: str,
|
||||
) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
构建应用页全部详情分区。
|
||||
|
||||
:param env_payload: `app env` 结果负载
|
||||
:param config_payload: `app config` 结果负载
|
||||
:param doctor_payload: `app doctor` 结果负载
|
||||
:param routes_payload: `app routes` 结果负载
|
||||
:param completion_payload: `completion doctor` 结果负载
|
||||
:param completion_preview_result: `completion show` 命令结果
|
||||
:param preview_shell: 当前预览 shell
|
||||
:return: 详情分区列表
|
||||
"""
|
||||
return [
|
||||
self.build_overview_section(
|
||||
env_payload,
|
||||
config_payload,
|
||||
doctor_payload,
|
||||
routes_payload,
|
||||
completion_payload,
|
||||
),
|
||||
self.build_env_section(env_payload),
|
||||
self.build_config_section(config_payload),
|
||||
self.build_dependency_section(config_payload),
|
||||
self.build_doctor_section(doctor_payload),
|
||||
self.build_completion_section(completion_payload),
|
||||
self.build_completion_preview_section(preview_shell, completion_preview_result),
|
||||
self.build_completion_install_entry_section(),
|
||||
self.build_routes_section(routes_payload),
|
||||
self.build_run_entry_section(),
|
||||
]
|
||||
|
||||
|
||||
class AppDetailAdapter(BaseDetailAdapter):
|
||||
"""
|
||||
应用详情页适配器。
|
||||
|
||||
该适配器负责采集应用相关 CLI 结果,并委托分区构建器组装为
|
||||
TUI 详情页快照。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
section_builder: AppSectionBuilder | None = None,
|
||||
snapshot_collector: AppDetailSnapshotCollector | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化应用详情页适配器。
|
||||
|
||||
:param section_builder: 应用详情分区构建器
|
||||
:param snapshot_collector: 应用详情页数据采集器
|
||||
:return: None
|
||||
"""
|
||||
super().__init__(
|
||||
page_title='应用',
|
||||
search_view_key='app',
|
||||
default_suggestions=[
|
||||
'总览判断',
|
||||
'环境解析',
|
||||
'应用配置',
|
||||
'依赖配置',
|
||||
'启动前检查',
|
||||
'补全诊断',
|
||||
'补全脚本预览',
|
||||
'补全安装入口',
|
||||
'路由摘要',
|
||||
'启动入口',
|
||||
],
|
||||
)
|
||||
self.section_builder = section_builder or AppSectionBuilder()
|
||||
self.snapshot_collector = snapshot_collector or AppDetailSnapshotCollector(self.section_builder)
|
||||
|
||||
def collect_snapshot(self, env: str, query: str = '') -> DetailPageSnapshot:
|
||||
"""
|
||||
采集应用状态页只读快照。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:param query: 当前搜索词
|
||||
:return: 页面快照
|
||||
"""
|
||||
source_payloads = self.snapshot_collector.collect(env)
|
||||
sections = self.section_builder.build_sections(
|
||||
env_payload=source_payloads.env_payload,
|
||||
config_payload=source_payloads.config_payload,
|
||||
doctor_payload=source_payloads.doctor_payload,
|
||||
routes_payload=source_payloads.routes_payload,
|
||||
completion_payload=source_payloads.completion_payload,
|
||||
completion_preview_result=source_payloads.completion_preview_result,
|
||||
preview_shell=source_payloads.preview_shell,
|
||||
)
|
||||
return DetailPageSnapshot(
|
||||
title='应用',
|
||||
subtitle=TUI_DIAGNOSTIC_SERVICE.build_app_diagnostic_subtitle(
|
||||
source_payloads.env_payload,
|
||||
source_payloads.config_payload,
|
||||
source_payloads.doctor_payload,
|
||||
source_payloads.routes_payload,
|
||||
source_payloads.completion_payload,
|
||||
),
|
||||
sections=self.filter_sections(sections, query),
|
||||
search=self.resolve_search_context(query),
|
||||
)
|
||||
|
||||
|
||||
APP_DETAIL_ADAPTER = AppDetailAdapter()
|
||||
@@ -0,0 +1,219 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from cli.tui.adapters.models import (
|
||||
TUI_ADAPTER_MODEL_RENDERER,
|
||||
BrowserRecordSnapshot,
|
||||
DetailSectionSnapshot,
|
||||
)
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.search import TUI_SEARCH_SERVICE, PageFilterOption, PageSearchContext
|
||||
from cli.utils import SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
class BaseBrowserAdapter:
|
||||
"""
|
||||
TUI 浏览页适配基类。
|
||||
|
||||
:param page_title: 浏览页标题
|
||||
:param search_view_key: 搜索上下文对应的页面键
|
||||
:param filter_options: 页面支持的筛选项
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
page_title: str,
|
||||
search_view_key: str,
|
||||
filter_options: Sequence[PageFilterOption],
|
||||
) -> None:
|
||||
"""
|
||||
初始化浏览页适配基类。
|
||||
|
||||
:param page_title: 浏览页标题
|
||||
:param search_view_key: 搜索上下文对应的页面键
|
||||
:param filter_options: 页面支持的筛选项
|
||||
:return: None
|
||||
"""
|
||||
self.page_title = page_title
|
||||
self.search_view_key = search_view_key
|
||||
self.filter_options = tuple(filter_options)
|
||||
|
||||
@staticmethod
|
||||
def extract_page_rows(payload: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
从标准分页 JSON 负载中提取行数据。
|
||||
|
||||
:param payload: 标准分页结果负载
|
||||
:return: 行数据列表
|
||||
"""
|
||||
page_payload = payload.get('page') if isinstance(payload, dict) else None
|
||||
rows = page_payload.get('rows') if isinstance(page_payload, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
return [row for row in rows if isinstance(row, dict)]
|
||||
|
||||
def resolve_active_filter(self, filter_key: str) -> PageFilterOption:
|
||||
"""
|
||||
解析当前激活筛选项。
|
||||
|
||||
:param filter_key: 筛选键
|
||||
:return: 已解析的筛选项
|
||||
"""
|
||||
return TUI_SEARCH_SERVICE.resolve_filter_option(self.filter_options, filter_key) or self.filter_options[0]
|
||||
|
||||
def resolve_search_context(self, query: str) -> PageSearchContext:
|
||||
"""
|
||||
解析当前页面搜索上下文。
|
||||
|
||||
:param query: 搜索词
|
||||
:return: 搜索上下文
|
||||
"""
|
||||
return TUI_SEARCH_SERVICE.resolve_search_context(self.search_view_key, query)
|
||||
|
||||
def build_failure_record(
|
||||
self,
|
||||
*,
|
||||
key: str,
|
||||
subject: str,
|
||||
section_subject: str,
|
||||
payload: dict[str, Any] | None,
|
||||
) -> BrowserRecordSnapshot:
|
||||
"""
|
||||
构建底层命令失败时的兜底记录。
|
||||
|
||||
:param key: 记录键
|
||||
:param subject: 页面主体名称
|
||||
:param section_subject: 失败分区名称
|
||||
:param payload: 失败结果负载
|
||||
:return: 浏览记录快照
|
||||
"""
|
||||
return BrowserRecordSnapshot(
|
||||
key=key,
|
||||
title=TUI_COPY.build_unavailable_record_title(subject),
|
||||
status='fail',
|
||||
summary=SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(payload), 64),
|
||||
metadata_lines=[],
|
||||
detail_sections=[
|
||||
DetailSectionSnapshot(
|
||||
title=TUI_COPY.build_load_failure_section_title(section_subject),
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
payload, empty_label=section_subject, empty_value='不可用'
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def build_empty_record(
|
||||
self,
|
||||
*,
|
||||
key: str,
|
||||
subject: str,
|
||||
empty_label: str,
|
||||
has_source_rows: bool,
|
||||
filtered_summary: str,
|
||||
empty_summary: str,
|
||||
filtered_empty_value: str,
|
||||
empty_empty_value: str,
|
||||
filtered_detail: str,
|
||||
empty_detail: str,
|
||||
) -> BrowserRecordSnapshot:
|
||||
"""
|
||||
构建当前页面没有可浏览记录时的空态记录。
|
||||
|
||||
:param key: 记录键
|
||||
:param subject: 记录主体名称
|
||||
:param empty_label: 空态主字段名称
|
||||
:param has_source_rows: 是否存在未筛选前的源数据
|
||||
:param filtered_summary: 有源数据但当前筛选无结果时的摘要
|
||||
:param empty_summary: 源数据本身为空时的摘要
|
||||
:param filtered_empty_value: 有源数据但当前筛选无结果时的空态值
|
||||
:param empty_empty_value: 源数据本身为空时的空态值
|
||||
:param filtered_detail: 有源数据但当前筛选无结果时的说明
|
||||
:param empty_detail: 源数据本身为空时的说明
|
||||
:return: 浏览记录快照
|
||||
"""
|
||||
summary = filtered_summary if has_source_rows else empty_summary
|
||||
empty_value = filtered_empty_value if has_source_rows else empty_empty_value
|
||||
detail = filtered_detail if has_source_rows else empty_detail
|
||||
return BrowserRecordSnapshot(
|
||||
key=key,
|
||||
title=TUI_COPY.build_empty_record_title(subject),
|
||||
status='info',
|
||||
summary=TUI_COPY.build_empty_record_summary(summary),
|
||||
metadata_lines=[],
|
||||
detail_sections=[
|
||||
DetailSectionSnapshot(
|
||||
title=TUI_COPY.build_empty_record_title(subject),
|
||||
status='info',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label=empty_label,
|
||||
empty_value=empty_value,
|
||||
detail=detail,
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def extract_payload_message(payload: dict[str, object] | None) -> str:
|
||||
"""
|
||||
提取结果负载中的可读消息。
|
||||
|
||||
:param payload: 标准结果负载
|
||||
:return: 摘要消息文本
|
||||
"""
|
||||
return TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload)
|
||||
|
||||
|
||||
class BaseDetailAdapter:
|
||||
"""
|
||||
TUI 详情页适配基类。
|
||||
|
||||
:param page_title: 详情页标题
|
||||
:param search_view_key: 搜索上下文对应的页面键
|
||||
:param default_suggestions: 默认搜索建议
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
page_title: str,
|
||||
search_view_key: str,
|
||||
default_suggestions: Sequence[str],
|
||||
) -> None:
|
||||
"""
|
||||
初始化详情页适配基类。
|
||||
|
||||
:param page_title: 详情页标题
|
||||
:param search_view_key: 搜索上下文对应的页面键
|
||||
:param default_suggestions: 默认搜索建议
|
||||
:return: None
|
||||
"""
|
||||
self.page_title = page_title
|
||||
self.search_view_key = search_view_key
|
||||
self.default_suggestions = tuple(default_suggestions)
|
||||
|
||||
def filter_sections(self, sections: Sequence[DetailSectionSnapshot], query: str) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
按搜索词过滤详情分区列表。
|
||||
|
||||
:param sections: 原始详情分区列表
|
||||
:param query: 搜索词
|
||||
:return: 过滤后的分区列表
|
||||
"""
|
||||
return TUI_SEARCH_SERVICE.filter_detail_sections(list(sections), query) # type: ignore[arg-type]
|
||||
|
||||
def resolve_search_context(self, query: str) -> PageSearchContext:
|
||||
"""
|
||||
解析当前详情页搜索上下文。
|
||||
|
||||
:param query: 搜索词
|
||||
:return: 搜索上下文
|
||||
"""
|
||||
return TUI_SEARCH_SERVICE.resolve_search_context(
|
||||
self.search_view_key,
|
||||
query,
|
||||
default_suggestions=list(self.default_suggestions),
|
||||
)
|
||||
@@ -0,0 +1,579 @@
|
||||
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.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
class CacheRowExtractor:
|
||||
"""
|
||||
缓存浏览行提取器。
|
||||
|
||||
该对象负责从缓存统计结果中提取缓存名称列表,供共享分区和记录构建
|
||||
逻辑复用。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def extract_cache_name_rows(payload: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
从缓存统计结果中提取缓存名称列表。
|
||||
|
||||
:param payload: `cache stats` JSON 负载
|
||||
:return: 缓存名称行列表
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
cache_names = payload.get('cacheNames')
|
||||
if not isinstance(cache_names, list):
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in cache_names:
|
||||
if isinstance(item, dict):
|
||||
rows.append(item)
|
||||
continue
|
||||
if isinstance(item, str):
|
||||
rows.append({'cacheName': item, 'remark': ''})
|
||||
return rows
|
||||
|
||||
|
||||
class CacheSectionBuilder:
|
||||
"""
|
||||
缓存浏览页分区构建器。
|
||||
|
||||
该构建器负责构建缓存浏览页共享分区,以及单个缓存名前缀下的键摘要、
|
||||
键列表和键详情分区。
|
||||
|
||||
:param page_adapter: 缓存浏览页适配器
|
||||
:param row_extractor: 缓存浏览行提取器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page_adapter: BaseBrowserAdapter,
|
||||
row_extractor: CacheRowExtractor | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化缓存浏览页分区构建器。
|
||||
|
||||
:param page_adapter: 缓存浏览页适配器
|
||||
:param row_extractor: 缓存浏览行提取器
|
||||
:return: None
|
||||
"""
|
||||
self.page_adapter = page_adapter
|
||||
self.row_extractor = row_extractor or CacheRowExtractor()
|
||||
|
||||
def build_overview_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建缓存总览共享分区。
|
||||
|
||||
:param payload: `cache stats` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
info = payload.get('info') if isinstance(payload, dict) and isinstance(payload.get('info'), dict) else {}
|
||||
cache_rows = self.row_extractor.extract_cache_name_rows(payload)
|
||||
return DetailSectionSnapshot(
|
||||
title='Redis 摘要',
|
||||
status='ok' if isinstance(payload, dict) and payload.get('ok', False) else 'fail',
|
||||
lines=[
|
||||
'## 容量状态',
|
||||
f'当前键数: {payload.get("dbSize", "-") if isinstance(payload, dict) else "-"}',
|
||||
f'已登记缓存名: {len(cache_rows)} 个',
|
||||
'',
|
||||
'## 运行指标',
|
||||
f'Redis 版本: {info.get("redis_version", "-")}',
|
||||
f'客户端连接数: {info.get("connected_clients", "-")}',
|
||||
f'内存占用: {info.get("used_memory_human", info.get("used_memory", "-"))}',
|
||||
],
|
||||
)
|
||||
|
||||
def build_overview_judgement_section(
|
||||
self,
|
||||
payload: dict[str, Any] | None,
|
||||
filtered_rows: list[dict[str, Any]],
|
||||
) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建缓存页总览判断共享分区。
|
||||
|
||||
:param payload: `cache stats` JSON 负载
|
||||
:param filtered_rows: 当前筛选后的缓存名列表
|
||||
:return: 分区快照
|
||||
"""
|
||||
info = payload.get('info') if isinstance(payload, dict) and isinstance(payload.get('info'), dict) else {}
|
||||
cache_rows = self.row_extractor.extract_cache_name_rows(payload)
|
||||
db_size = payload.get('dbSize', '-') if isinstance(payload, dict) else '-'
|
||||
client_count = info.get('connected_clients', '-')
|
||||
command_stats = payload.get('commandStats') if isinstance(payload, dict) else None
|
||||
command_count = len(command_stats) if isinstance(command_stats, list) else 0
|
||||
|
||||
status = 'ok'
|
||||
conclusion = '缓存基线正常,可继续查看键列表、键值样本与 TTL'
|
||||
if not isinstance(payload, dict) or not payload.get('ok', False):
|
||||
status = 'fail'
|
||||
conclusion = '缓存状态读取失败,优先确认 Redis 连通性与运行环境'
|
||||
elif not cache_rows:
|
||||
status = 'info'
|
||||
conclusion = '当前没有登记的缓存名前缀,可先核对缓存配置与实际键空间'
|
||||
elif not filtered_rows:
|
||||
status = 'info'
|
||||
conclusion = '当前搜索条件没有命中缓存名前缀,可调整关键字后继续排查'
|
||||
|
||||
return DetailSectionSnapshot(
|
||||
title='总览判断',
|
||||
status=status,
|
||||
lines=[
|
||||
'## 当前结论',
|
||||
conclusion,
|
||||
'',
|
||||
'## 核心指标',
|
||||
f'已登记缓存名: {len(cache_rows)} 个',
|
||||
f'当前匹配: {len(filtered_rows)} 个',
|
||||
f'Redis 键数: {db_size}',
|
||||
f'客户端连接数: {client_count}',
|
||||
f'命令统计样本: {command_count} 组',
|
||||
'',
|
||||
'## 建议入口',
|
||||
'优先关注:Redis 摘要 / 命令统计 / 键值样本 / TTL',
|
||||
],
|
||||
)
|
||||
|
||||
def build_top_commands_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建缓存命令统计共享分区。
|
||||
|
||||
:param payload: `cache stats` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
command_stats = payload.get('commandStats') if isinstance(payload, dict) else None
|
||||
lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label='命令统计',
|
||||
empty_value='暂无数据',
|
||||
detail='当前 Redis 运行快照中未返回命令统计样本',
|
||||
)
|
||||
section_status = 'info'
|
||||
if isinstance(command_stats, list) and command_stats:
|
||||
section_status = 'ok'
|
||||
lines = [
|
||||
f'{item.get("name", "-")} · {item.get("value", 0)} 次'
|
||||
for item in command_stats[:8]
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
return DetailSectionSnapshot(
|
||||
title='命令统计',
|
||||
status=section_status if isinstance(payload, dict) and payload.get('ok', False) else 'fail',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_cache_clear_entry_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建缓存清理向导入口分区。
|
||||
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title='缓存清理入口',
|
||||
status='info',
|
||||
lines=TUI_COPY.build_command_hint_lines(
|
||||
scenario='当缓存命中异常、键数量失控,或需要按缓存名和前缀清理时,应先通过向导确认影响范围。',
|
||||
command=TUI_COPY.build_cli_command_hint('wizard', 'cache-clear', '--output=text'),
|
||||
guide='向导会继续询问环境、缓存名、键前缀和 dry-run 选项,确认后再执行实际清理。',
|
||||
),
|
||||
)
|
||||
|
||||
def build_keys_summary_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建缓存键摘要分区。
|
||||
|
||||
:param payload: `cache keys` 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='不可用'
|
||||
),
|
||||
)
|
||||
keys = payload.get('keys') if isinstance(payload.get('keys'), list) else []
|
||||
rendered_keys = [str(item).strip() for item in keys if str(item).strip()]
|
||||
lines = [
|
||||
'## 统计',
|
||||
f'缓存名: {payload.get("cacheName", "-")}',
|
||||
f'键数量: {payload.get("count", len(rendered_keys))}',
|
||||
]
|
||||
if rendered_keys:
|
||||
lines.extend(
|
||||
['', '## 键样本', *[f'> {SHELL_TEXT_FORMATTER.truncate_text(item, 64)}' for item in rendered_keys[:5]]]
|
||||
)
|
||||
else:
|
||||
lines.extend(['', '## 键样本', '> 当前缓存名前缀下没有键'])
|
||||
return DetailSectionSnapshot(
|
||||
title='键摘要',
|
||||
status='ok',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def build_keys_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建缓存键列表分区。
|
||||
|
||||
:param payload: `cache keys` 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='不可用'
|
||||
),
|
||||
)
|
||||
keys = payload.get('keys') if isinstance(payload.get('keys'), list) else []
|
||||
lines = [
|
||||
SHELL_TEXT_FORMATTER.truncate_text(item, 88) for item in keys[:12] if str(item).strip()
|
||||
] or TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label='缓存键',
|
||||
empty_value='0 个',
|
||||
detail='当前缓存名前缀下没有可展示的缓存键',
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title='键列表',
|
||||
status='ok',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def render_ttl_text(self, payload: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
渲染缓存 TTL 文本。
|
||||
|
||||
:param payload: `cache ttl` JSON 负载
|
||||
:return: TTL 文本
|
||||
"""
|
||||
if not isinstance(payload, dict) or not payload.get('ok', False):
|
||||
return SHELL_TEXT_FORMATTER.truncate_text(self.page_adapter.extract_payload_message(payload), 56)
|
||||
ttl_seconds = payload.get('ttlSeconds', '-')
|
||||
if payload.get('persistent', False):
|
||||
return '永久'
|
||||
if payload.get('expires', False):
|
||||
return f'{ttl_seconds} 秒'
|
||||
return str(ttl_seconds)
|
||||
|
||||
def build_key_detail_sections(
|
||||
self,
|
||||
cache_name: str,
|
||||
key_items: list[str],
|
||||
env: str,
|
||||
) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
构建按缓存键展开的详情分区。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param key_items: 缓存键列表
|
||||
:param env: 当前运行环境
|
||||
:return: 分区快照列表
|
||||
"""
|
||||
if not key_items:
|
||||
return []
|
||||
|
||||
sections: list[DetailSectionSnapshot] = []
|
||||
for cache_key in key_items[:5]:
|
||||
value_payload = NESTED_CLI_SUPPORT.run(
|
||||
'cache',
|
||||
'get',
|
||||
cache_name,
|
||||
cache_key,
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
ttl_payload = NESTED_CLI_SUPPORT.run(
|
||||
'cache',
|
||||
'ttl',
|
||||
cache_name,
|
||||
cache_key,
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
|
||||
value_ok = isinstance(value_payload, dict) and value_payload.get('ok', False)
|
||||
ttl_ok = isinstance(ttl_payload, dict) and ttl_payload.get('ok', False)
|
||||
if value_ok and ttl_ok:
|
||||
section_status = 'ok'
|
||||
elif value_ok or ttl_ok:
|
||||
section_status = 'warn'
|
||||
else:
|
||||
section_status = 'fail'
|
||||
|
||||
cache_value = (
|
||||
'' if not value_ok or value_payload.get('cacheValue') is None else str(value_payload.get('cacheValue'))
|
||||
)
|
||||
preview_lines = cache_value.splitlines()[:6]
|
||||
ttl_text = self.render_ttl_text(ttl_payload)
|
||||
lines = [
|
||||
'## 键信息',
|
||||
f'缓存名: {cache_name}',
|
||||
f'缓存键: {SHELL_TEXT_FORMATTER.truncate_text(cache_key, 72)}',
|
||||
f'TTL: {ttl_text}',
|
||||
'',
|
||||
'## 值预览',
|
||||
]
|
||||
if value_ok:
|
||||
lines.extend(
|
||||
[f'> {SHELL_TEXT_FORMATTER.truncate_text(line, 72)}' for line in preview_lines]
|
||||
if preview_lines
|
||||
else ['> -']
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f'> {SHELL_TEXT_FORMATTER.truncate_text(self.page_adapter.extract_payload_message(value_payload), 72)}'
|
||||
)
|
||||
if not ttl_ok:
|
||||
lines.extend(
|
||||
[
|
||||
'',
|
||||
'## TTL 结果',
|
||||
f'> {SHELL_TEXT_FORMATTER.truncate_text(self.page_adapter.extract_payload_message(ttl_payload), 72)}',
|
||||
]
|
||||
)
|
||||
sections.append(
|
||||
DetailSectionSnapshot(
|
||||
title=f'键详情 · {SHELL_TEXT_FORMATTER.truncate_text(cache_key, 28)}',
|
||||
status=section_status,
|
||||
lines=lines,
|
||||
)
|
||||
)
|
||||
return sections
|
||||
|
||||
def load_cache_detail_sections(
|
||||
self,
|
||||
cache_row: dict[str, Any],
|
||||
env: str,
|
||||
) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
按需加载单个缓存名称的详情分区。
|
||||
|
||||
:param cache_row: 缓存名称行数据
|
||||
:param env: 当前运行环境
|
||||
:return: 详情分区列表
|
||||
"""
|
||||
cache_name = str(cache_row.get('cacheName', '-') or '-')
|
||||
keys_payload = NESTED_CLI_SUPPORT.run(
|
||||
'cache',
|
||||
'keys',
|
||||
cache_name,
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
key_items: list[str] = []
|
||||
if isinstance(keys_payload, dict) and keys_payload.get('ok', False):
|
||||
raw_keys = keys_payload.get('keys') if isinstance(keys_payload.get('keys'), list) else []
|
||||
key_items = [str(item).strip() for item in raw_keys if str(item).strip()]
|
||||
return [
|
||||
self.build_keys_summary_section(keys_payload),
|
||||
self.build_keys_section(keys_payload),
|
||||
*self.build_key_detail_sections(cache_name, key_items, env),
|
||||
]
|
||||
|
||||
def build_shared_sections(
|
||||
self,
|
||||
payload: dict[str, Any] | None,
|
||||
filtered_rows: list[dict[str, Any]],
|
||||
) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
构建缓存浏览页共享分区。
|
||||
|
||||
:param payload: `cache stats` 结果负载
|
||||
:param filtered_rows: 当前筛选后的缓存名列表
|
||||
:return: 共享分区列表
|
||||
"""
|
||||
return [
|
||||
self.build_overview_judgement_section(payload, filtered_rows),
|
||||
self.build_overview_section(payload),
|
||||
self.build_top_commands_section(payload),
|
||||
self.build_cache_clear_entry_section(),
|
||||
]
|
||||
|
||||
|
||||
class CacheRecordBuilder:
|
||||
"""
|
||||
缓存浏览记录构建器。
|
||||
|
||||
该对象负责构建缓存页单条浏览记录与失败兜底记录。
|
||||
|
||||
:param page_adapter: 缓存浏览页适配器
|
||||
:param section_builder: 缓存浏览页分区构建器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page_adapter: BaseBrowserAdapter,
|
||||
section_builder: CacheSectionBuilder,
|
||||
) -> None:
|
||||
"""
|
||||
初始化缓存浏览记录构建器。
|
||||
|
||||
:param page_adapter: 缓存浏览页适配器
|
||||
:param section_builder: 缓存浏览页分区构建器
|
||||
:return: None
|
||||
"""
|
||||
self.page_adapter = page_adapter
|
||||
self.section_builder = section_builder
|
||||
|
||||
def build_record(self, cache_row: dict[str, Any], env: str) -> BrowserRecordSnapshot:
|
||||
"""
|
||||
构建单条缓存浏览记录。
|
||||
|
||||
:param cache_row: 缓存名称行数据
|
||||
:param env: 当前运行环境
|
||||
:return: 浏览记录快照
|
||||
"""
|
||||
cache_name = str(cache_row.get('cacheName', '-') or '-')
|
||||
remark = str(cache_row.get('remark', '') or '').strip()
|
||||
return BrowserRecordSnapshot(
|
||||
key=f'cache:{cache_name}',
|
||||
title=SHELL_TEXT_FORMATTER.truncate_text(cache_name, 40),
|
||||
status='ok',
|
||||
summary=SHELL_TEXT_FORMATTER.truncate_text(remark or '查看当前缓存名前缀下的键、值样本和过期时间', 64),
|
||||
metadata_lines=[
|
||||
'## 缓存身份',
|
||||
f'缓存名称: {cache_name}',
|
||||
f'用途说明: {SHELL_TEXT_FORMATTER.truncate_text(remark or "-", 64)}',
|
||||
],
|
||||
detail_sections=[],
|
||||
detail_loader=lambda cache_row=cache_row, env=env: self.section_builder.load_cache_detail_sections(
|
||||
cache_row, env
|
||||
),
|
||||
)
|
||||
|
||||
def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot:
|
||||
"""
|
||||
构建缓存页失败兜底记录。
|
||||
|
||||
:param payload: 失败结果负载
|
||||
:return: 浏览记录快照
|
||||
"""
|
||||
return self.page_adapter.build_failure_record(
|
||||
key='cache:unavailable',
|
||||
subject='缓存',
|
||||
section_subject='缓存状态',
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
class CacheBrowserAdapter(BaseBrowserAdapter):
|
||||
"""
|
||||
缓存浏览页适配器。
|
||||
|
||||
该适配器负责采集 Redis 统计和缓存名前缀列表,并委托协作对象构建
|
||||
共享分区、单条记录与键详情分区。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
row_extractor: CacheRowExtractor | None = None,
|
||||
section_builder: CacheSectionBuilder | None = None,
|
||||
record_builder: CacheRecordBuilder | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化缓存浏览页适配器。
|
||||
|
||||
:param row_extractor: 缓存浏览行提取器
|
||||
:param section_builder: 缓存浏览页分区构建器
|
||||
:param record_builder: 缓存浏览记录构建器
|
||||
:return: None
|
||||
"""
|
||||
super().__init__(
|
||||
page_title='缓存',
|
||||
search_view_key='cache',
|
||||
filter_options=(),
|
||||
)
|
||||
self.row_extractor = row_extractor or CacheRowExtractor()
|
||||
self.section_builder = section_builder or CacheSectionBuilder(self, self.row_extractor)
|
||||
self.record_builder = record_builder or CacheRecordBuilder(self, self.section_builder)
|
||||
|
||||
@staticmethod
|
||||
def apply_cache_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('cacheName', '') or '').strip().lower()
|
||||
or normalized_query in str(row.get('remark', '') or '').strip().lower()
|
||||
]
|
||||
|
||||
def collect_snapshot(self, env: str, query: str = '') -> BrowserPageSnapshot:
|
||||
"""
|
||||
采集缓存浏览页只读快照。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:param query: 当前搜索词
|
||||
:return: 浏览页快照
|
||||
"""
|
||||
search_context = self.resolve_search_context(query)
|
||||
payload = NESTED_CLI_SUPPORT.run(
|
||||
'cache',
|
||||
'stats',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
cache_rows = self.row_extractor.extract_cache_name_rows(payload)
|
||||
filtered_rows = self.apply_cache_query(cache_rows, query)
|
||||
shared_sections = self.section_builder.build_shared_sections(payload, filtered_rows)
|
||||
if not isinstance(payload, dict) or not payload.get('ok', False):
|
||||
return BrowserPageSnapshot(
|
||||
title='缓存',
|
||||
subtitle=TUI_COPY.build_unavailable_subtitle(
|
||||
'缓存状态',
|
||||
SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(payload), 72),
|
||||
),
|
||||
records=[self.record_builder.build_failure_record(payload)],
|
||||
shared_sections=shared_sections,
|
||||
search=search_context,
|
||||
)
|
||||
|
||||
records = [self.record_builder.build_record(cache_row, env) for cache_row in filtered_rows[:12]]
|
||||
if not records:
|
||||
records = [
|
||||
self.build_empty_record(
|
||||
key='cache:none',
|
||||
subject='缓存名',
|
||||
empty_label='缓存名前缀',
|
||||
has_source_rows=bool(cache_rows),
|
||||
filtered_summary='当前搜索条件下没有匹配缓存名前缀',
|
||||
empty_summary='当前运行环境没有登记可浏览的缓存名前缀',
|
||||
filtered_empty_value='暂无匹配',
|
||||
empty_empty_value='暂无登记',
|
||||
filtered_detail='当前搜索条件下没有匹配缓存名前缀',
|
||||
empty_detail='当前运行环境没有登记可浏览的缓存名前缀',
|
||||
)
|
||||
]
|
||||
return BrowserPageSnapshot(
|
||||
title='缓存',
|
||||
subtitle=TUI_DIAGNOSTIC_SERVICE.build_cache_diagnostic_subtitle(payload, len(filtered_rows)),
|
||||
records=records,
|
||||
shared_sections=shared_sections,
|
||||
search=search_context,
|
||||
)
|
||||
|
||||
|
||||
CACHE_BROWSER_ADAPTER = CacheBrowserAdapter()
|
||||
@@ -0,0 +1,760 @@
|
||||
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 CONFIG_FILTER_OPTIONS
|
||||
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
|
||||
|
||||
CONFIG_RISK_PRIORITY = {
|
||||
'mismatch': 0,
|
||||
'missing-cache': 1,
|
||||
'orphan-cache': 2,
|
||||
'normal': 3,
|
||||
}
|
||||
|
||||
CONFIG_RISK_LABELS = {
|
||||
'mismatch': '值不一致',
|
||||
'missing-cache': '缓存缺失',
|
||||
'orphan-cache': '缓存孤立',
|
||||
'normal': '正常',
|
||||
}
|
||||
|
||||
CONFIG_RISK_STATUSES = {
|
||||
'mismatch': 'fail',
|
||||
'missing-cache': 'warn',
|
||||
'orphan-cache': 'warn',
|
||||
'normal': 'ok',
|
||||
}
|
||||
|
||||
|
||||
class ConfigRiskSupport:
|
||||
"""
|
||||
参数配置风险分类支持对象。
|
||||
|
||||
该对象负责从巡检结果提取风险集合、解析单项风险分类,以及执行
|
||||
风险排序和筛选逻辑,供浏览页分区和记录构建复用。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def extract_config_issue_keys(payload: dict[str, Any] | None, field_name: str) -> set[str]:
|
||||
"""
|
||||
从配置巡检结果中提取指定风险字段对应的配置键集合。
|
||||
|
||||
:param payload: `config doctor` JSON 负载
|
||||
:param field_name: 风险字段名
|
||||
:return: 配置键集合
|
||||
"""
|
||||
items = payload.get(field_name) if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return set()
|
||||
return {item.strip() for item in items if isinstance(item, str) and item.strip()}
|
||||
|
||||
def build_config_risk_sets(self, payload: dict[str, Any] | None) -> dict[str, set[str]]:
|
||||
"""
|
||||
构建配置风险分类集合。
|
||||
|
||||
:param payload: `config doctor` JSON 负载
|
||||
:return: 按风险分类聚合的配置键集合
|
||||
"""
|
||||
return {
|
||||
'mismatch': self.extract_config_issue_keys(payload, 'mismatchKeys'),
|
||||
'missing-cache': self.extract_config_issue_keys(payload, 'missingInCache'),
|
||||
'orphan-cache': self.extract_config_issue_keys(payload, 'orphanInCache'),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def resolve_config_risk_key(config_key: str, risk_sets: dict[str, set[str]]) -> str:
|
||||
"""
|
||||
解析单个配置项的风险分类。
|
||||
|
||||
:param config_key: 配置键
|
||||
:param risk_sets: 风险集合
|
||||
:return: 风险分类键
|
||||
"""
|
||||
normalized_key = str(config_key).strip()
|
||||
if normalized_key in risk_sets.get('mismatch', set()):
|
||||
return 'mismatch'
|
||||
if normalized_key in risk_sets.get('missing-cache', set()):
|
||||
return 'missing-cache'
|
||||
if normalized_key in risk_sets.get('orphan-cache', set()):
|
||||
return 'orphan-cache'
|
||||
return 'normal'
|
||||
|
||||
def build_config_sort_key(
|
||||
self,
|
||||
config_row: dict[str, Any],
|
||||
risk_sets: dict[str, set[str]],
|
||||
) -> tuple[int, str]:
|
||||
"""
|
||||
构建配置列表排序键。
|
||||
|
||||
:param config_row: 配置项行数据
|
||||
:param risk_sets: 风险集合
|
||||
:return: 排序键
|
||||
"""
|
||||
config_key = str(config_row.get('configKey', '') or '').strip()
|
||||
risk_key = self.resolve_config_risk_key(config_key, risk_sets)
|
||||
return (CONFIG_RISK_PRIORITY.get(risk_key, 99), config_key)
|
||||
|
||||
def apply_config_filter(
|
||||
self,
|
||||
rows: list[dict[str, Any]],
|
||||
risk_sets: dict[str, set[str]],
|
||||
filter_key: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
按筛选键过滤配置行数据。
|
||||
|
||||
:param rows: 原始配置行列表
|
||||
:param risk_sets: 风险集合
|
||||
:param filter_key: 当前筛选键
|
||||
:return: 过滤后的配置行列表
|
||||
"""
|
||||
normalized_filter = str(filter_key).strip().lower()
|
||||
if normalized_filter == 'risky':
|
||||
return [
|
||||
row
|
||||
for row in rows
|
||||
if self.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) != 'normal'
|
||||
]
|
||||
if normalized_filter == 'mismatch':
|
||||
return [
|
||||
row
|
||||
for row in rows
|
||||
if self.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) == 'mismatch'
|
||||
]
|
||||
if normalized_filter == 'cache-drift':
|
||||
return [
|
||||
row
|
||||
for row in rows
|
||||
if self.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets)
|
||||
in {'missing-cache', 'orphan-cache'}
|
||||
]
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def apply_config_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('configKey', '') or '').strip().lower()
|
||||
or normalized_query in str(row.get('configName', '') or '').strip().lower()
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def append_orphan_cache_rows(rows: list[dict[str, Any]], orphan_keys: set[str]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
将仅存在于缓存侧的孤立配置补充为可浏览记录。
|
||||
|
||||
:param rows: 原始数据库配置行
|
||||
:param orphan_keys: 缓存孤立配置键集合
|
||||
:return: 补齐后的配置行列表
|
||||
"""
|
||||
merged_rows = list(rows)
|
||||
existing_keys = {str(row.get('configKey', '') or '').strip() for row in rows}
|
||||
merged_rows.extend(
|
||||
{
|
||||
'configId': '-',
|
||||
'configKey': orphan_key,
|
||||
'configName': '缓存孤立配置',
|
||||
'configType': '-',
|
||||
'configValue': '缓存侧残留,建议排查来源',
|
||||
}
|
||||
for orphan_key in sorted(orphan_keys - existing_keys)
|
||||
)
|
||||
return merged_rows
|
||||
|
||||
|
||||
class ConfigSectionBuilder:
|
||||
"""
|
||||
参数配置浏览页分区构建器。
|
||||
|
||||
该构建器负责构建参数配置页共享分区,以及单个配置项详情分区。
|
||||
|
||||
:param page_adapter: 参数配置浏览页适配器
|
||||
:param risk_support: 参数配置风险分类支持对象
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page_adapter: BaseBrowserAdapter,
|
||||
risk_support: ConfigRiskSupport,
|
||||
) -> None:
|
||||
"""
|
||||
初始化参数配置浏览页分区构建器。
|
||||
|
||||
:param page_adapter: 参数配置浏览页适配器
|
||||
:param risk_support: 参数配置风险分类支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.page_adapter = page_adapter
|
||||
self.risk_support = risk_support
|
||||
|
||||
def build_config_doctor_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建参数配置巡检共享分区。
|
||||
|
||||
:param payload: `config doctor` 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='不可用'
|
||||
),
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title='配置巡检',
|
||||
status='ok',
|
||||
lines=[
|
||||
'## 一致性状态',
|
||||
f'数据库配置: {payload.get("databaseCount", "-")} 项',
|
||||
f'缓存同步配置: {payload.get("cacheCount", "-")} 项',
|
||||
f'缓存缺失: {payload.get("missingInCacheCount", "-")} 项',
|
||||
f'缓存孤立: {payload.get("orphanInCacheCount", "-")} 项',
|
||||
f'值不一致: {payload.get("mismatchCount", "-")} 项',
|
||||
],
|
||||
)
|
||||
|
||||
def build_config_issue_samples_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建参数配置不一致样本共享分区。
|
||||
|
||||
:param payload: `config doctor` 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='不可用'),
|
||||
)
|
||||
|
||||
lines: list[str] = []
|
||||
missing_in_cache = payload.get('missingInCache')
|
||||
orphan_in_cache = payload.get('orphanInCache')
|
||||
mismatch_keys = payload.get('mismatchKeys')
|
||||
|
||||
if isinstance(missing_in_cache, list) and missing_in_cache:
|
||||
lines.extend(
|
||||
f'缓存缺失示例:{SHELL_TEXT_FORMATTER.truncate_text(item, 64)}'
|
||||
for item in missing_in_cache[:5]
|
||||
if isinstance(item, str)
|
||||
)
|
||||
if isinstance(orphan_in_cache, list) and orphan_in_cache:
|
||||
lines.extend(
|
||||
f'缓存孤立示例:{SHELL_TEXT_FORMATTER.truncate_text(item, 64)}'
|
||||
for item in orphan_in_cache[:5]
|
||||
if isinstance(item, str)
|
||||
)
|
||||
if isinstance(mismatch_keys, list) and mismatch_keys:
|
||||
lines.extend(
|
||||
f'值不一致示例:{SHELL_TEXT_FORMATTER.truncate_text(item, 64)}'
|
||||
for item in mismatch_keys[:5]
|
||||
if isinstance(item, str)
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
int(payload.get('missingInCacheCount', 0))
|
||||
+ int(payload.get('orphanInCacheCount', 0))
|
||||
+ int(payload.get('mismatchCount', 0))
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title='异常样本',
|
||||
status='fail' if issue_count > 0 else 'ok',
|
||||
lines=lines
|
||||
if lines
|
||||
else TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label='异常样本',
|
||||
empty_value='0 条',
|
||||
detail='当前未发现配置异常样本',
|
||||
),
|
||||
)
|
||||
|
||||
def build_config_set_entry_section(self, rows: list[dict[str, Any]]) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建配置变更入口共享分区。
|
||||
|
||||
:param rows: 当前配置行列表
|
||||
:return: 分区快照
|
||||
"""
|
||||
sample_key = ''
|
||||
sample_value = ''
|
||||
for row in rows:
|
||||
config_key = str(row.get('configKey', '') or '').strip()
|
||||
config_value = str(row.get('configValue', '') or '').strip()
|
||||
if config_key:
|
||||
sample_key = config_key
|
||||
sample_value = config_value or 'new-value'
|
||||
break
|
||||
if not sample_key:
|
||||
sample_key = 'demo.config.key'
|
||||
sample_value = 'new-value'
|
||||
return DetailSectionSnapshot(
|
||||
title='配置变更入口',
|
||||
status='info',
|
||||
lines=TUI_COPY.build_command_hint_lines(
|
||||
scenario='准备修复高风险配置、回填缓存缺失项或校正数据库与缓存值不一致时,应先在终端中明确目标键和值,再执行配置变更命令。',
|
||||
command=TUI_COPY.build_cli_command_hint('config', 'set', sample_key, sample_value, '--output=text'),
|
||||
guide='建议先在当前页面核对配置键、来源和现值;变更完成后再回到工作台执行参数缓存刷新,确认巡检结果恢复正常。',
|
||||
),
|
||||
)
|
||||
|
||||
def build_high_risk_config_section(
|
||||
self,
|
||||
rows: list[dict[str, Any]],
|
||||
risk_sets: dict[str, set[str]],
|
||||
) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建高风险配置共享分区。
|
||||
|
||||
:param rows: 配置行列表
|
||||
:param risk_sets: 风险集合
|
||||
:return: 分区快照
|
||||
"""
|
||||
risky_rows = [
|
||||
row
|
||||
for row in sorted(
|
||||
rows, key=lambda config_row: self.risk_support.build_config_sort_key(config_row, risk_sets)
|
||||
)
|
||||
if self.risk_support.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) != 'normal'
|
||||
]
|
||||
if not risky_rows:
|
||||
return DetailSectionSnapshot(
|
||||
title='高风险配置',
|
||||
status='ok',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label='高风险配置',
|
||||
empty_value='0 项',
|
||||
detail='当前巡检结果中没有需要优先处理的高风险配置',
|
||||
),
|
||||
)
|
||||
lines: list[str] = []
|
||||
for row in risky_rows[:8]:
|
||||
config_key = str(row.get('configKey', '-') or '-')
|
||||
config_name = str(row.get('configName', '-') or '-')
|
||||
risk_key = self.risk_support.resolve_config_risk_key(config_key, risk_sets)
|
||||
lines.append(
|
||||
f'[{CONFIG_RISK_LABELS.get(risk_key, "风险")}] '
|
||||
f'{SHELL_TEXT_FORMATTER.truncate_text(config_key, 40)} · {SHELL_TEXT_FORMATTER.truncate_text(config_name, 20)}'
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title='高风险配置',
|
||||
status='warn' if risky_rows else 'ok',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def build_configs_overview_section(
|
||||
self,
|
||||
rows: list[dict[str, Any]],
|
||||
filtered_rows: list[dict[str, Any]],
|
||||
risk_sets: dict[str, set[str]],
|
||||
filter_label: str,
|
||||
) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建参数配置页总览判断共享分区。
|
||||
|
||||
:param rows: 原始配置行列表
|
||||
:param filtered_rows: 当前筛选后的配置行列表
|
||||
:param risk_sets: 风险集合
|
||||
:param filter_label: 当前筛选标签
|
||||
:return: 分区快照
|
||||
"""
|
||||
mismatch_count = len(risk_sets.get('mismatch', set()))
|
||||
drift_count = len(risk_sets.get('missing-cache', set())) + len(risk_sets.get('orphan-cache', set()))
|
||||
risky_count = mismatch_count + drift_count
|
||||
status = 'ok'
|
||||
conclusion = '当前配置一致性正常,可继续抽查单项详情与同步状态'
|
||||
if mismatch_count > 0:
|
||||
status = 'warn'
|
||||
conclusion = '存在值不一致配置,优先核对数据库与缓存是否同步'
|
||||
elif drift_count > 0:
|
||||
status = 'warn'
|
||||
conclusion = '存在缓存漂移配置,建议优先确认缺失项与孤立项来源'
|
||||
|
||||
return DetailSectionSnapshot(
|
||||
title='总览判断',
|
||||
status=status,
|
||||
lines=[
|
||||
'## 当前结论',
|
||||
conclusion,
|
||||
'',
|
||||
'## 核心指标',
|
||||
f'当前筛选: {filter_label}',
|
||||
f'已加载配置: {len(rows)} 项',
|
||||
f'当前匹配: {len(filtered_rows)} 项',
|
||||
f'高风险配置: {risky_count} 项',
|
||||
f'值不一致: {mismatch_count} 项',
|
||||
f'缓存漂移: {drift_count} 项',
|
||||
'',
|
||||
'## 建议入口',
|
||||
'优先关注:高风险配置 / 值不一致 / 缓存漂移',
|
||||
],
|
||||
)
|
||||
|
||||
def build_config_consistency_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建单个配置的一致性分区。
|
||||
|
||||
:param payload: `config get` 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='不可用'
|
||||
),
|
||||
)
|
||||
lines = [
|
||||
'## 当前配置',
|
||||
f'键名: {payload.get("key", "-")}',
|
||||
f'读取来源: {payload.get("source", "-")}',
|
||||
]
|
||||
source = str(payload.get('source', '-') or '-').strip().lower()
|
||||
section_status = 'ok'
|
||||
if source == 'both':
|
||||
lines.append(f'数据库与缓存一致: {"是" if payload.get("inSync", False) else "否"}')
|
||||
if not payload.get('inSync', False):
|
||||
section_status = 'fail'
|
||||
elif source == 'database':
|
||||
lines.append('缓存状态: 缺失')
|
||||
section_status = 'warn'
|
||||
elif source == 'cache':
|
||||
lines.append('数据库状态: 缺失')
|
||||
section_status = 'warn'
|
||||
return DetailSectionSnapshot(
|
||||
title='同步状态',
|
||||
status=section_status,
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def build_config_source_section(
|
||||
self,
|
||||
title: str,
|
||||
config_item: dict[str, Any] | None,
|
||||
*,
|
||||
missing_text: str,
|
||||
) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建配置来源详情分区。
|
||||
|
||||
:param title: 分区标题
|
||||
:param config_item: 配置来源详情
|
||||
:param missing_text: 缺失时提示
|
||||
:return: 分区快照
|
||||
"""
|
||||
if not isinstance(config_item, dict):
|
||||
return DetailSectionSnapshot(
|
||||
title=title,
|
||||
status='info',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label=title,
|
||||
empty_value='未找到',
|
||||
detail=missing_text,
|
||||
),
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title=title,
|
||||
status='ok',
|
||||
lines=[
|
||||
'## 基础信息',
|
||||
f'配置 ID: {config_item.get("configId", "-")}',
|
||||
f'键名: {SHELL_TEXT_FORMATTER.truncate_text(config_item.get("configKey", "-"), 48)}',
|
||||
f'名称: {SHELL_TEXT_FORMATTER.truncate_text(config_item.get("configName", "-"), 32)}',
|
||||
f'键值: {SHELL_TEXT_FORMATTER.truncate_text(config_item.get("configValue", "-"), 72)}',
|
||||
f'内置标记: {config_item.get("configType", "-")}',
|
||||
'',
|
||||
'## 备注',
|
||||
SHELL_TEXT_FORMATTER.truncate_text(config_item.get('remark', '-') or '-', 88),
|
||||
],
|
||||
)
|
||||
|
||||
def load_config_detail_sections(
|
||||
self,
|
||||
config_row: dict[str, Any],
|
||||
env: str,
|
||||
) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
按需加载单个配置项详情分区。
|
||||
|
||||
:param config_row: 配置项行数据
|
||||
:param env: 当前运行环境
|
||||
:return: 详情分区列表
|
||||
"""
|
||||
config_key = str(config_row.get('configKey', '-') or '-')
|
||||
detail_payload = NESTED_CLI_SUPPORT.run(
|
||||
'config',
|
||||
'get',
|
||||
config_key,
|
||||
f'--env={env}',
|
||||
'--source=both',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
database_payload = detail_payload.get('database') if isinstance(detail_payload, dict) else None
|
||||
cache_payload = detail_payload.get('cache') if isinstance(detail_payload, dict) else None
|
||||
return [
|
||||
self.build_config_consistency_section(detail_payload),
|
||||
self.build_config_source_section('数据库配置', database_payload, missing_text='数据库中未找到该配置项'),
|
||||
self.build_config_source_section('缓存配置', cache_payload, missing_text='缓存中未找到该配置项'),
|
||||
]
|
||||
|
||||
|
||||
class ConfigRecordBuilder:
|
||||
"""
|
||||
参数配置浏览记录构建器。
|
||||
|
||||
该构建器负责构建配置浏览记录与失败兜底记录。
|
||||
|
||||
:param page_adapter: 参数配置浏览页适配器
|
||||
:param section_builder: 参数配置浏览页分区构建器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page_adapter: BaseBrowserAdapter,
|
||||
section_builder: ConfigSectionBuilder,
|
||||
) -> None:
|
||||
"""
|
||||
初始化参数配置浏览记录构建器。
|
||||
|
||||
:param page_adapter: 参数配置浏览页适配器
|
||||
:param section_builder: 参数配置浏览页分区构建器
|
||||
:return: None
|
||||
"""
|
||||
self.page_adapter = page_adapter
|
||||
self.section_builder = section_builder
|
||||
|
||||
def build_record(
|
||||
self,
|
||||
config_row: dict[str, Any],
|
||||
env: str,
|
||||
*,
|
||||
risk_key: str,
|
||||
) -> BrowserRecordSnapshot:
|
||||
"""
|
||||
构建单条参数配置浏览记录。
|
||||
|
||||
:param config_row: 配置项行数据
|
||||
:param env: 当前运行环境
|
||||
:param risk_key: 当前配置项风险键
|
||||
:return: 浏览记录快照
|
||||
"""
|
||||
config_id = config_row.get('configId', '-')
|
||||
config_key = str(config_row.get('configKey', '-') or '-')
|
||||
config_name = str(config_row.get('configName', '-') or '-')
|
||||
config_type = str(config_row.get('configType', '-') or '-')
|
||||
config_value = str(config_row.get('configValue', '-') or '-')
|
||||
risk_label = CONFIG_RISK_LABELS.get(risk_key, '正常')
|
||||
return BrowserRecordSnapshot(
|
||||
key=f'config:{config_key}',
|
||||
title=SHELL_TEXT_FORMATTER.truncate_text(config_key, 44),
|
||||
status=CONFIG_RISK_STATUSES.get(risk_key, 'ok'),
|
||||
summary=f'{risk_label} · {SHELL_TEXT_FORMATTER.truncate_text(config_name, 18)} · 值 {SHELL_TEXT_FORMATTER.truncate_text(config_value, 24)}',
|
||||
metadata_lines=[
|
||||
'## 配置身份',
|
||||
f'配置 ID: {config_id}',
|
||||
f'配置名称: {SHELL_TEXT_FORMATTER.truncate_text(config_name, 40)}',
|
||||
f'系统内置: {config_type}',
|
||||
f'风险分类: {risk_label}',
|
||||
'',
|
||||
'## 当前值',
|
||||
SHELL_TEXT_FORMATTER.truncate_text(config_value, 88),
|
||||
],
|
||||
detail_sections=[],
|
||||
detail_loader=lambda config_row=config_row, env=env: self.section_builder.load_config_detail_sections(
|
||||
config_row,
|
||||
env,
|
||||
),
|
||||
)
|
||||
|
||||
def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot:
|
||||
"""
|
||||
构建配置页失败兜底记录。
|
||||
|
||||
:param payload: 失败结果负载
|
||||
:return: 浏览记录快照
|
||||
"""
|
||||
return self.page_adapter.build_failure_record(
|
||||
key='config:unavailable',
|
||||
subject='参数配置',
|
||||
section_subject='配置列表',
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
class ConfigsBrowserAdapter(BaseBrowserAdapter):
|
||||
"""
|
||||
参数配置浏览页适配器。
|
||||
|
||||
该适配器负责采集配置巡检、配置列表和单项配置详情,并统一完成
|
||||
风险分类、筛选排序、共享分区构建与浏览记录装配。
|
||||
|
||||
:param risk_support: 参数配置风险分类支持对象
|
||||
:param section_builder: 参数配置浏览页分区构建器
|
||||
:param record_builder: 参数配置浏览记录构建器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
risk_support: ConfigRiskSupport | None = None,
|
||||
section_builder: ConfigSectionBuilder | None = None,
|
||||
record_builder: ConfigRecordBuilder | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化参数配置浏览页适配器。
|
||||
|
||||
:param risk_support: 参数配置风险分类支持对象
|
||||
:param section_builder: 参数配置浏览页分区构建器
|
||||
:param record_builder: 参数配置浏览记录构建器
|
||||
:return: None
|
||||
"""
|
||||
super().__init__(
|
||||
page_title='参数配置',
|
||||
search_view_key='configs',
|
||||
filter_options=CONFIG_FILTER_OPTIONS,
|
||||
)
|
||||
self.risk_support = risk_support or ConfigRiskSupport()
|
||||
self.section_builder = section_builder or ConfigSectionBuilder(self, self.risk_support)
|
||||
self.record_builder = record_builder or ConfigRecordBuilder(self, 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)
|
||||
doctor_payload = NESTED_CLI_SUPPORT.run(
|
||||
'config',
|
||||
'doctor',
|
||||
f'--env={env}',
|
||||
'--sample-limit=5',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
list_payload = NESTED_CLI_SUPPORT.run(
|
||||
'config',
|
||||
'list',
|
||||
f'--env={env}',
|
||||
'--paged',
|
||||
'--page-size=8',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
risk_sets = self.risk_support.build_config_risk_sets(doctor_payload)
|
||||
|
||||
if not isinstance(list_payload, dict) or not list_payload.get('ok', False):
|
||||
return BrowserPageSnapshot(
|
||||
title='参数配置',
|
||||
subtitle=TUI_COPY.build_unavailable_subtitle(
|
||||
'配置',
|
||||
SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(list_payload), 72),
|
||||
),
|
||||
records=[self.record_builder.build_failure_record(list_payload)],
|
||||
shared_sections=[
|
||||
self.section_builder.build_configs_overview_section([], [], risk_sets, active_filter_label),
|
||||
self.section_builder.build_config_doctor_section(doctor_payload),
|
||||
self.section_builder.build_high_risk_config_section([], risk_sets),
|
||||
self.section_builder.build_config_issue_samples_section(doctor_payload),
|
||||
self.section_builder.build_config_set_entry_section([]),
|
||||
],
|
||||
filters=list(self.filter_options),
|
||||
active_filter_key=active_filter,
|
||||
search=search_context,
|
||||
)
|
||||
|
||||
rows = self.risk_support.append_orphan_cache_rows(
|
||||
self.extract_page_rows(list_payload),
|
||||
risk_sets.get('orphan-cache', set()),
|
||||
)
|
||||
sorted_rows = sorted(
|
||||
rows, key=lambda config_row: self.risk_support.build_config_sort_key(config_row, risk_sets)
|
||||
)
|
||||
filtered_rows = self.risk_support.apply_config_query(
|
||||
self.risk_support.apply_config_filter(sorted_rows, risk_sets, active_filter),
|
||||
query,
|
||||
)
|
||||
records = [
|
||||
self.record_builder.build_record(
|
||||
config_row,
|
||||
env,
|
||||
risk_key=self.risk_support.resolve_config_risk_key(
|
||||
str(config_row.get('configKey', '') or ''),
|
||||
risk_sets,
|
||||
),
|
||||
)
|
||||
for config_row in filtered_rows[:12]
|
||||
]
|
||||
if not records:
|
||||
records = [
|
||||
self.build_empty_record(
|
||||
key='config:none',
|
||||
subject='配置项',
|
||||
empty_label='配置项',
|
||||
has_source_rows=bool(sorted_rows),
|
||||
filtered_summary='当前筛选条件下没有匹配配置项',
|
||||
empty_summary='当前环境没有可浏览的参数配置项',
|
||||
filtered_empty_value='暂无数据',
|
||||
empty_empty_value='暂无配置',
|
||||
filtered_detail='当前筛选条件下没有匹配配置项',
|
||||
empty_detail='当前环境没有可浏览的参数配置项',
|
||||
)
|
||||
]
|
||||
|
||||
subtitle = TUI_DIAGNOSTIC_SERVICE.build_configs_diagnostic_subtitle(
|
||||
active_filter_label,
|
||||
len(filtered_rows),
|
||||
len(risk_sets.get('mismatch', set())),
|
||||
len(risk_sets.get('missing-cache', set())) + len(risk_sets.get('orphan-cache', set())),
|
||||
)
|
||||
if isinstance(doctor_payload, dict) and not doctor_payload.get('ok', False):
|
||||
subtitle = (
|
||||
f'{subtitle} | {SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(doctor_payload), 72)}'
|
||||
)
|
||||
shared_sections = [
|
||||
self.section_builder.build_configs_overview_section(rows, filtered_rows, risk_sets, active_filter_label),
|
||||
self.section_builder.build_config_doctor_section(doctor_payload),
|
||||
self.section_builder.build_high_risk_config_section(rows, risk_sets),
|
||||
self.section_builder.build_config_issue_samples_section(doctor_payload),
|
||||
self.section_builder.build_config_set_entry_section(rows),
|
||||
]
|
||||
return BrowserPageSnapshot(
|
||||
title='参数配置',
|
||||
subtitle=subtitle,
|
||||
records=records,
|
||||
shared_sections=shared_sections,
|
||||
filters=list(self.filter_options),
|
||||
active_filter_key=active_filter,
|
||||
search=search_context,
|
||||
)
|
||||
|
||||
|
||||
CONFIGS_BROWSER_ADAPTER = ConfigsBrowserAdapter()
|
||||
@@ -0,0 +1,372 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cli.tui.adapters.base import BaseDetailAdapter
|
||||
from cli.tui.adapters.models import (
|
||||
TUI_ADAPTER_MODEL_RENDERER,
|
||||
DetailPageSnapshot,
|
||||
DetailSectionSnapshot,
|
||||
)
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
|
||||
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CryptoDetailSourcePayloads:
|
||||
"""
|
||||
传输加密详情页原始数据源快照。
|
||||
|
||||
:param validate_payload: `crypto validate` 结果
|
||||
:param public_payload: `crypto export-public` 结果
|
||||
"""
|
||||
|
||||
validate_payload: dict[str, Any] | None
|
||||
public_payload: dict[str, Any] | None
|
||||
|
||||
|
||||
class CryptoDetailSnapshotCollector:
|
||||
"""
|
||||
传输加密详情页数据采集器。
|
||||
|
||||
该对象负责拉取传输加密详情页所需的 CLI 原始结果,
|
||||
让 `CryptoDetailAdapter` 保持详情页编排职责。
|
||||
"""
|
||||
|
||||
def collect(self, env: str) -> CryptoDetailSourcePayloads:
|
||||
"""
|
||||
采集传输加密详情页所需原始结果。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:return: 传输加密详情页原始数据源快照
|
||||
"""
|
||||
return CryptoDetailSourcePayloads(
|
||||
validate_payload=NESTED_CLI_SUPPORT.run(
|
||||
'crypto',
|
||||
'validate',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
public_payload=NESTED_CLI_SUPPORT.run(
|
||||
'crypto',
|
||||
'export-public',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
)
|
||||
|
||||
|
||||
class CryptoSectionBuilder:
|
||||
"""
|
||||
传输加密详情分区构建器。
|
||||
|
||||
该构建器负责将传输加密相关 CLI 结果负载转换为 TUI 详情页分区,
|
||||
使详情页适配器本体只保留采集与编排职责。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_validate_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建传输加密运行校验分区。
|
||||
|
||||
:param payload: `crypto validate` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return DetailSectionSnapshot(
|
||||
title='运行校验',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
payload, empty_label='运行校验', empty_value='不可用'
|
||||
),
|
||||
)
|
||||
lines = [
|
||||
'## 校验结果',
|
||||
f'状态: {"通过" if payload.get("ok", False) else "失败"}',
|
||||
f'说明: {SHELL_TEXT_FORMATTER.truncate_text(TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload), 72)}',
|
||||
]
|
||||
if payload.get('error'):
|
||||
lines.append(f'错误: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("error", "-"), 72)}')
|
||||
return DetailSectionSnapshot(
|
||||
title='运行校验',
|
||||
status='ok' if payload.get('ok', False) else 'fail',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_public_identity_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建公钥身份分区。
|
||||
|
||||
:param payload: `crypto export-public` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
public_key_payload = payload.get('publicKey') if isinstance(payload, dict) else None
|
||||
if not isinstance(public_key_payload, 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'KID: {public_key_payload.get("kid", "-")}',
|
||||
f'算法: {public_key_payload.get("alg", "-")}',
|
||||
f'信封版本: {public_key_payload.get("envelopeVersion", "-")}',
|
||||
f'过期时间: {public_key_payload.get("expireAt", "-")}',
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_supported_kids_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建兼容版本分区。
|
||||
|
||||
:param payload: `crypto export-public` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
public_key_payload = payload.get('publicKey') if isinstance(payload, dict) else None
|
||||
if not isinstance(public_key_payload, dict):
|
||||
return DetailSectionSnapshot(
|
||||
title='兼容版本',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
payload, empty_label='兼容版本', empty_value='不可用'
|
||||
),
|
||||
)
|
||||
supported_kids = public_key_payload.get('supportedKids')
|
||||
lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label='兼容版本',
|
||||
empty_value='0 个',
|
||||
detail='当前公钥未声明兼容版本 KID',
|
||||
suggestion='如需兼容旧版本客户端,可检查 supportedKids 配置',
|
||||
)
|
||||
if isinstance(supported_kids, list) and supported_kids:
|
||||
lines = ['## 兼容 KID', *[f'支持版本: {kid}' for kid in supported_kids[:10]]]
|
||||
return DetailSectionSnapshot(
|
||||
title='兼容版本',
|
||||
status='ok',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def build_public_preview_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建公钥预览分区。
|
||||
|
||||
:param payload: `crypto export-public` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
public_key_payload = payload.get('publicKey') if isinstance(payload, dict) else None
|
||||
if not isinstance(public_key_payload, dict):
|
||||
return DetailSectionSnapshot(
|
||||
title='公钥预览',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
payload, empty_label='公钥预览', empty_value='不可用'
|
||||
),
|
||||
)
|
||||
public_key = str(public_key_payload.get('publicKey', '') or '').splitlines()
|
||||
if not public_key:
|
||||
return DetailSectionSnapshot(
|
||||
title='公钥预览',
|
||||
status='info',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label='公钥预览',
|
||||
empty_value='无内容',
|
||||
detail='当前公钥内容为空,无法生成预览',
|
||||
suggestion='可检查公钥导出结果,或重新生成公钥后再刷新',
|
||||
),
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title='公钥预览',
|
||||
status='info',
|
||||
lines=['## 预览内容', *[SHELL_TEXT_FORMATTER.truncate_text(line, 88) for line in public_key[:6]]],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_overview_section(
|
||||
validate_payload: dict[str, Any] | None,
|
||||
public_payload: dict[str, Any] | None,
|
||||
) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建加密页总览判断分区。
|
||||
|
||||
:param validate_payload: `crypto validate` JSON 负载
|
||||
:param public_payload: `crypto export-public` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
validate_ok = bool(isinstance(validate_payload, dict) and validate_payload.get('ok', False))
|
||||
public_ok = bool(isinstance(public_payload, dict) and public_payload.get('ok', False))
|
||||
public_key_payload = public_payload.get('publicKey') if isinstance(public_payload, dict) else None
|
||||
supported_kids = public_key_payload.get('supportedKids') if isinstance(public_key_payload, dict) else None
|
||||
supported_count = len(supported_kids) if isinstance(supported_kids, list) else 0
|
||||
|
||||
status = 'ok'
|
||||
conclusion = '传输加密基线正常,可继续查看公钥身份、兼容版本与预演入口'
|
||||
if not validate_ok:
|
||||
status = 'fail'
|
||||
conclusion = '运行校验失败,优先确认传输加密配置与环境变量'
|
||||
elif not public_ok or not isinstance(public_key_payload, dict):
|
||||
status = 'warn'
|
||||
conclusion = '公钥导出结果异常,建议先确认当前 KID 与公钥内容是否可用'
|
||||
|
||||
return DetailSectionSnapshot(
|
||||
title='总览判断',
|
||||
status=status,
|
||||
lines=[
|
||||
'## 当前结论',
|
||||
conclusion,
|
||||
'',
|
||||
'## 核心指标',
|
||||
f'运行校验: {"通过" if validate_ok else "失败"}',
|
||||
f'当前 KID: {public_key_payload.get("kid", "-") if isinstance(public_key_payload, dict) else "-"}',
|
||||
f'算法: {public_key_payload.get("alg", "-") if isinstance(public_key_payload, dict) else "-"}',
|
||||
f'兼容版本数: {supported_count}',
|
||||
'',
|
||||
'## 建议入口',
|
||||
'优先关注:运行校验 / 公钥身份 / 兼容版本 / 轮换预演入口',
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_rotation_entry_section(env: str) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建加密密钥轮换预演入口分区。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title='轮换预演入口',
|
||||
status='warn',
|
||||
lines=TUI_COPY.build_command_hint_lines(
|
||||
scenario='准备轮换传输加密密钥时,应先做 dry-run,确认新 KID、密钥长度和 guard 提示后再执行正式轮换。',
|
||||
command=TUI_COPY.build_cli_command_hint(
|
||||
'crypto',
|
||||
'rotate',
|
||||
f'--env={env}',
|
||||
'--dry-run',
|
||||
'--output=json',
|
||||
),
|
||||
guide='当前页面先提供预演入口;正式轮换仍应遵循危险命令确认流程,避免直接在未知环境落地变更。',
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_keygen_entry_section(env: str) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建加密密钥生成入口分区。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title='密钥生成入口',
|
||||
status='warn',
|
||||
lines=TUI_COPY.build_command_hint_lines(
|
||||
scenario='准备生成新的传输加密密钥对时,应在当前终端直接查看生成结果,并立即核对 KID、密钥长度和环境变量补丁。',
|
||||
command=TUI_COPY.build_cli_command_hint(
|
||||
'crypto',
|
||||
'keygen',
|
||||
f'--env={env}',
|
||||
'--output=text',
|
||||
),
|
||||
guide='当前入口会在终端中直接输出新公钥、私钥和 env patch keys,后续再决定是否进入轮换流程。',
|
||||
),
|
||||
)
|
||||
|
||||
def build_sections(
|
||||
self,
|
||||
*,
|
||||
validate_payload: dict[str, Any] | None,
|
||||
public_payload: dict[str, Any] | None,
|
||||
env: str,
|
||||
) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
构建传输加密页全部详情分区。
|
||||
|
||||
:param validate_payload: `crypto validate` 结果负载
|
||||
:param public_payload: `crypto export-public` 结果负载
|
||||
:param env: 当前运行环境
|
||||
:return: 详情分区列表
|
||||
"""
|
||||
return [
|
||||
self.build_overview_section(validate_payload, public_payload),
|
||||
self.build_validate_section(validate_payload),
|
||||
self.build_public_identity_section(public_payload),
|
||||
self.build_supported_kids_section(public_payload),
|
||||
self.build_public_preview_section(public_payload),
|
||||
self.build_keygen_entry_section(env),
|
||||
self.build_rotation_entry_section(env),
|
||||
]
|
||||
|
||||
|
||||
class CryptoDetailAdapter(BaseDetailAdapter):
|
||||
"""
|
||||
传输加密详情页适配器。
|
||||
|
||||
该适配器负责采集传输加密相关 CLI 结果,并组装为 TUI 详情页快照。
|
||||
页面私有的解析逻辑、分区构建逻辑和快照采集流程统一收口在该类中。
|
||||
|
||||
:param section_builder: 传输加密详情分区构建器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
section_builder: CryptoSectionBuilder | None = None,
|
||||
snapshot_collector: CryptoDetailSnapshotCollector | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化传输加密详情页适配器。
|
||||
|
||||
:param section_builder: 传输加密详情分区构建器
|
||||
:param snapshot_collector: 传输加密详情页数据采集器
|
||||
:return: None
|
||||
"""
|
||||
super().__init__(
|
||||
page_title='传输加密',
|
||||
search_view_key='crypto',
|
||||
default_suggestions=[
|
||||
'总览判断',
|
||||
'运行校验',
|
||||
'公钥身份',
|
||||
'兼容版本',
|
||||
'公钥预览',
|
||||
'密钥生成入口',
|
||||
'轮换预演入口',
|
||||
],
|
||||
)
|
||||
self.section_builder = section_builder or CryptoSectionBuilder()
|
||||
self.snapshot_collector = snapshot_collector or CryptoDetailSnapshotCollector()
|
||||
|
||||
def collect_snapshot(self, env: str, query: str = '') -> DetailPageSnapshot:
|
||||
"""
|
||||
采集传输加密状态页只读快照。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:param query: 当前搜索词
|
||||
:return: 页面快照
|
||||
"""
|
||||
source_payloads = self.snapshot_collector.collect(env)
|
||||
sections = self.section_builder.build_sections(
|
||||
validate_payload=source_payloads.validate_payload,
|
||||
public_payload=source_payloads.public_payload,
|
||||
env=env,
|
||||
)
|
||||
return DetailPageSnapshot(
|
||||
title='传输加密',
|
||||
subtitle=TUI_DIAGNOSTIC_SERVICE.build_crypto_diagnostic_subtitle(
|
||||
source_payloads.validate_payload,
|
||||
source_payloads.public_payload,
|
||||
),
|
||||
sections=self.filter_sections(sections, query),
|
||||
search=self.resolve_search_context(query),
|
||||
)
|
||||
|
||||
|
||||
CRYPTO_DETAIL_ADAPTER = CryptoDetailAdapter()
|
||||
@@ -0,0 +1,474 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cli.tui.adapters.base import BaseDetailAdapter
|
||||
from cli.tui.adapters.models import (
|
||||
TUI_ADAPTER_MODEL_RENDERER,
|
||||
DetailPageSnapshot,
|
||||
DetailSectionSnapshot,
|
||||
)
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
|
||||
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DatabaseDetailSourcePayloads:
|
||||
"""
|
||||
数据库详情页原始数据源快照。
|
||||
|
||||
:param revision_payload: `db current` 结果
|
||||
:param check_payload: `db check` 结果
|
||||
:param heads_payload: `db heads` 结果
|
||||
:param history_payload: `db history` 结果
|
||||
:param config_payload: `app config` 结果
|
||||
"""
|
||||
|
||||
revision_payload: dict[str, Any] | None
|
||||
check_payload: dict[str, Any] | None
|
||||
heads_payload: dict[str, Any] | None
|
||||
history_payload: dict[str, Any] | None
|
||||
config_payload: dict[str, Any] | None
|
||||
|
||||
|
||||
class DatabaseDetailSnapshotCollector:
|
||||
"""
|
||||
数据库详情页数据采集器。
|
||||
|
||||
该对象负责拉取数据库详情页所需的多路 CLI 原始结果,
|
||||
让 `DatabaseDetailAdapter` 保持详情页编排职责。
|
||||
"""
|
||||
|
||||
def collect(self, env: str) -> DatabaseDetailSourcePayloads:
|
||||
"""
|
||||
采集数据库详情页所需原始结果。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:return: 数据库详情页原始数据源快照
|
||||
"""
|
||||
return DatabaseDetailSourcePayloads(
|
||||
revision_payload=NESTED_CLI_SUPPORT.run(
|
||||
'db',
|
||||
'current',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
check_payload=NESTED_CLI_SUPPORT.run(
|
||||
'db',
|
||||
'check',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
heads_payload=NESTED_CLI_SUPPORT.run(
|
||||
'db',
|
||||
'heads',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
history_payload=NESTED_CLI_SUPPORT.run(
|
||||
'db',
|
||||
'history',
|
||||
f'--env={env}',
|
||||
'--limit=8',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
config_payload=NESTED_CLI_SUPPORT.run(
|
||||
'app',
|
||||
'config',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
)
|
||||
|
||||
|
||||
class DatabaseSectionBuilder:
|
||||
"""
|
||||
数据库详情分区构建器。
|
||||
|
||||
该构建器负责将数据库相关 CLI 结果负载转换为 TUI 详情页分区,
|
||||
使详情页适配器本体只保留采集与编排职责。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def extract_revision_items(payload: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
从 Alembic 结果中提取修订版本列表。
|
||||
|
||||
:param payload: `db heads` 或 `db history` JSON 负载
|
||||
:return: 修订版本列表
|
||||
"""
|
||||
items = payload.get('items') if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
return [item for item in items if isinstance(item, dict)]
|
||||
|
||||
@staticmethod
|
||||
def build_revision_item_lines(item: dict[str, Any], *, index: int, item_label: str) -> list[str]:
|
||||
"""
|
||||
构建单条 Alembic 修订版本详情文本。
|
||||
|
||||
:param item: 修订版本字典
|
||||
:param index: 当前索引
|
||||
:param item_label: 条目标识名称
|
||||
:return: 文本行列表
|
||||
"""
|
||||
down_revisions = item.get('downRevisions')
|
||||
branch_labels = item.get('branchLabels')
|
||||
depends_on = item.get('dependsOn')
|
||||
return [
|
||||
f'## {item_label} {index:02d} · {item.get("revision", "-")}',
|
||||
f'> 下游版本: {",".join(down_revisions) if isinstance(down_revisions, list) and down_revisions else "-"}',
|
||||
f'> 分支标签: {",".join(branch_labels) if isinstance(branch_labels, list) and branch_labels else "-"}',
|
||||
f'> 依赖版本: {",".join(depends_on) if isinstance(depends_on, list) and depends_on else "-"}',
|
||||
f'> 说明: {SHELL_TEXT_FORMATTER.truncate_text(item.get("doc", "-"), 72)}',
|
||||
f'> 文件: {SHELL_TEXT_FORMATTER.truncate_text(item.get("path", "-"), 72)}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_revision_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建数据库迁移状态分区。
|
||||
|
||||
:param payload: `db current` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
current_revision = payload.get('currentRevision', '-') if isinstance(payload, dict) else '-'
|
||||
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=str(current_revision or '-')
|
||||
),
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title='迁移版本',
|
||||
status='ok',
|
||||
lines=[
|
||||
'## 当前版本',
|
||||
f'迁移版本: {current_revision}',
|
||||
'',
|
||||
'## 状态说明',
|
||||
f'结果消息: {TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload)}',
|
||||
],
|
||||
)
|
||||
|
||||
def build_overview_section(
|
||||
self,
|
||||
revision_payload: dict[str, Any] | None,
|
||||
check_payload: dict[str, Any] | None,
|
||||
heads_payload: dict[str, Any] | None,
|
||||
history_payload: dict[str, Any] | None,
|
||||
) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建数据库页总览判断分区。
|
||||
|
||||
:param revision_payload: `db current` JSON 负载
|
||||
:param check_payload: `db check` JSON 负载
|
||||
:param heads_payload: `db heads` JSON 负载
|
||||
:param history_payload: `db history` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
revision_ok = bool(isinstance(revision_payload, dict) and revision_payload.get('ok', False))
|
||||
check_ok = bool(isinstance(check_payload, dict) and check_payload.get('ok', False))
|
||||
heads_ok = bool(isinstance(heads_payload, dict) and heads_payload.get('ok', False))
|
||||
current_revision = revision_payload.get('currentRevision', '-') if isinstance(revision_payload, dict) else '-'
|
||||
head_items = self.extract_revision_items(heads_payload)
|
||||
history_items = self.extract_revision_items(history_payload)
|
||||
|
||||
status = 'ok'
|
||||
conclusion = '数据库基线正常,可继续查看连接状态、Heads 与版本链路'
|
||||
if not revision_ok or not check_ok:
|
||||
status = 'fail'
|
||||
conclusion = '数据库存在基础异常,优先确认迁移版本与连通性检查结果'
|
||||
elif not heads_ok or len(head_items) != 1:
|
||||
status = 'warn'
|
||||
conclusion = '数据库存在迁移分叉风险,优先确认 Heads 与版本链路是否一致'
|
||||
|
||||
return DetailSectionSnapshot(
|
||||
title='总览判断',
|
||||
status=status,
|
||||
lines=[
|
||||
'## 当前结论',
|
||||
conclusion,
|
||||
'',
|
||||
'## 核心指标',
|
||||
f'当前 revision: {current_revision}',
|
||||
f'连通性检查: {"正常" if check_ok else "异常"}',
|
||||
f'Heads 数量: {len(head_items)}',
|
||||
f'版本数量: {history_payload.get("totalCount", len(history_items)) if isinstance(history_payload, dict) else len(history_items)}',
|
||||
'',
|
||||
'## 建议入口',
|
||||
'优先关注:迁移版本 / 连通性检查 / Heads 状态 / 升级入口',
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_profile_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建数据库连接配置分区。
|
||||
|
||||
:param payload: `app config` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
config = payload.get('config') if isinstance(payload, dict) else None
|
||||
if not isinstance(config, dict):
|
||||
return DetailSectionSnapshot(
|
||||
title='连接信息',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
payload, empty_label='连接信息', empty_value='不可用'
|
||||
),
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title='连接信息',
|
||||
status='ok',
|
||||
lines=[
|
||||
'## 数据库连接',
|
||||
f'数据库类型: {config.get("dbType", "-")}',
|
||||
f'连接地址: {config.get("dbHost", "-")}:{config.get("dbPort", "-")}',
|
||||
f'数据库名: {config.get("dbDatabase", "-")}',
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_check_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建数据库连通性检查分区。
|
||||
|
||||
:param payload: `db check` 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='异常'
|
||||
),
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title='连通性检查',
|
||||
status='ok',
|
||||
lines=[
|
||||
'## 当前状态',
|
||||
'数据库连接: 正常',
|
||||
'',
|
||||
'## 结果摘要',
|
||||
f'结果消息: {TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload)}',
|
||||
],
|
||||
)
|
||||
|
||||
def build_heads_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建 Alembic heads 分区。
|
||||
|
||||
:param payload: `db heads` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
if not isinstance(payload, dict) or not payload.get('ok', False):
|
||||
return DetailSectionSnapshot(
|
||||
title='Heads 状态',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
payload, empty_label='Heads', empty_value='不可用'
|
||||
),
|
||||
)
|
||||
items = self.extract_revision_items(payload)
|
||||
status = 'ok' if len(items) == 1 else 'warn'
|
||||
lines = [
|
||||
'## Heads 概况',
|
||||
f'Heads 数量: {len(items)}',
|
||||
f'结果消息: {TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload)}',
|
||||
]
|
||||
if not items:
|
||||
lines.extend(['', '## Heads 列表', '> 当前仓库未返回可用 heads'])
|
||||
else:
|
||||
lines.extend(['', '## Heads 列表'])
|
||||
for index, item in enumerate(items[:4], start=1):
|
||||
lines.extend(self.build_revision_item_lines(item, index=index, item_label='Head'))
|
||||
lines.append('')
|
||||
if len(lines) > 1 and lines[-1] == '':
|
||||
lines.pop()
|
||||
return DetailSectionSnapshot(
|
||||
title='Heads 状态',
|
||||
status=status,
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def build_history_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建 Alembic 历史版本分区。
|
||||
|
||||
:param payload: `db history` 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='不可用'
|
||||
),
|
||||
)
|
||||
items = self.extract_revision_items(payload)
|
||||
lines = [
|
||||
'## 历史概况',
|
||||
f'返回数量: {payload.get("count", len(items))}',
|
||||
f'总版本数: {payload.get("totalCount", len(items))}',
|
||||
f'查询上限: {payload.get("limit", len(items))}',
|
||||
'',
|
||||
'## 历史列表',
|
||||
]
|
||||
if not items:
|
||||
lines.append('> 当前仓库未返回可展示的历史版本')
|
||||
else:
|
||||
for index, item in enumerate(items[:6], start=1):
|
||||
lines.extend(self.build_revision_item_lines(item, index=index, item_label='版本'))
|
||||
lines.append('')
|
||||
if lines[-1] == '':
|
||||
lines.pop()
|
||||
return DetailSectionSnapshot(
|
||||
title='历史版本',
|
||||
status='ok',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_upgrade_entry_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建数据库升级向导入口分区。
|
||||
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title='升级入口',
|
||||
status='info',
|
||||
lines=TUI_COPY.build_command_hint_lines(
|
||||
scenario='准备执行数据库升级、校验迁移路径或在生产前做升级预演时,应先通过向导确认目标版本与 dry-run 结果。',
|
||||
command=TUI_COPY.build_cli_command_hint('wizard', 'db-upgrade', '--output=text'),
|
||||
guide='向导会继续询问目标环境和目标 revision,并在真正升级前先展示预览信息。',
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_init_entry_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建数据库初始化预演入口分区。
|
||||
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title='初始化预演入口',
|
||||
status='info',
|
||||
lines=TUI_COPY.build_command_hint_lines(
|
||||
scenario='准备初始化新库、重建本地环境,或先确认初始化命令会执行到哪些迁移版本时,应先执行 dry-run 预演。',
|
||||
command=TUI_COPY.build_cli_command_hint('db', 'init', '--dry-run', '--output=text'),
|
||||
guide='预演只返回命令摘要与执行目录;确认无误后,再通过外部终端或向导执行真实初始化流程。',
|
||||
),
|
||||
)
|
||||
|
||||
def build_sections(
|
||||
self,
|
||||
*,
|
||||
revision_payload: dict[str, Any] | None,
|
||||
check_payload: dict[str, Any] | None,
|
||||
heads_payload: dict[str, Any] | None,
|
||||
history_payload: dict[str, Any] | None,
|
||||
config_payload: dict[str, Any] | None,
|
||||
) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
构建数据库页全部详情分区。
|
||||
|
||||
:param revision_payload: `db current` 结果负载
|
||||
:param check_payload: `db check` 结果负载
|
||||
:param heads_payload: `db heads` 结果负载
|
||||
:param history_payload: `db history` 结果负载
|
||||
:param config_payload: `app config` 结果负载
|
||||
:return: 详情分区列表
|
||||
"""
|
||||
return [
|
||||
self.build_overview_section(revision_payload, check_payload, heads_payload, history_payload),
|
||||
self.build_revision_section(revision_payload),
|
||||
self.build_profile_section(config_payload),
|
||||
self.build_check_section(check_payload),
|
||||
self.build_heads_section(heads_payload),
|
||||
self.build_history_section(history_payload),
|
||||
self.build_init_entry_section(),
|
||||
self.build_upgrade_entry_section(),
|
||||
]
|
||||
|
||||
|
||||
class DatabaseDetailAdapter(BaseDetailAdapter):
|
||||
"""
|
||||
数据库详情页适配器。
|
||||
|
||||
该适配器负责采集数据库相关 CLI 结果,并组装为 TUI 详情页快照。
|
||||
页面私有的解析逻辑、分区构建逻辑和快照采集流程统一收口在该类中。
|
||||
|
||||
:param section_builder: 数据库详情分区构建器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
section_builder: DatabaseSectionBuilder | None = None,
|
||||
snapshot_collector: DatabaseDetailSnapshotCollector | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化数据库详情页适配器。
|
||||
|
||||
:param section_builder: 数据库详情分区构建器
|
||||
:param snapshot_collector: 数据库详情页数据采集器
|
||||
:return: None
|
||||
"""
|
||||
super().__init__(
|
||||
page_title='数据库',
|
||||
search_view_key='database',
|
||||
default_suggestions=[
|
||||
'总览判断',
|
||||
'迁移版本',
|
||||
'连接信息',
|
||||
'连通性检查',
|
||||
'Heads 状态',
|
||||
'历史版本',
|
||||
'初始化预演入口',
|
||||
'升级入口',
|
||||
],
|
||||
)
|
||||
self.section_builder = section_builder or DatabaseSectionBuilder()
|
||||
self.snapshot_collector = snapshot_collector or DatabaseDetailSnapshotCollector()
|
||||
|
||||
def collect_snapshot(self, env: str, query: str = '') -> DetailPageSnapshot:
|
||||
"""
|
||||
采集数据库状态页只读快照。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:param query: 当前搜索词
|
||||
:return: 页面快照
|
||||
"""
|
||||
source_payloads = self.snapshot_collector.collect(env)
|
||||
sections = self.section_builder.build_sections(
|
||||
revision_payload=source_payloads.revision_payload,
|
||||
check_payload=source_payloads.check_payload,
|
||||
heads_payload=source_payloads.heads_payload,
|
||||
history_payload=source_payloads.history_payload,
|
||||
config_payload=source_payloads.config_payload,
|
||||
)
|
||||
return DetailPageSnapshot(
|
||||
title='数据库',
|
||||
subtitle=TUI_DIAGNOSTIC_SERVICE.build_database_diagnostic_subtitle(
|
||||
source_payloads.revision_payload,
|
||||
source_payloads.check_payload,
|
||||
source_payloads.heads_payload,
|
||||
),
|
||||
sections=self.filter_sections(sections, query),
|
||||
search=self.resolve_search_context(query),
|
||||
)
|
||||
|
||||
|
||||
DATABASE_DETAIL_ADAPTER = DatabaseDetailAdapter()
|
||||
@@ -0,0 +1,807 @@
|
||||
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.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
class GenRenderingSupport:
|
||||
"""
|
||||
代码生成浏览页渲染支持对象。
|
||||
|
||||
该对象负责布尔语义渲染与常见文本辅助逻辑,供分区和记录构建复用。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def render_yes_no(value: object) -> str:
|
||||
"""
|
||||
将常见布尔语义值转换为“是/否”。
|
||||
|
||||
:param value: 原始值
|
||||
:return: 中文布尔文本
|
||||
"""
|
||||
normalized = str(value).strip().lower()
|
||||
return '是' if normalized in {'1', 'y', 'yes', 'true'} else '否'
|
||||
|
||||
|
||||
class GenSectionBuilder:
|
||||
"""
|
||||
代码生成浏览页分区构建器。
|
||||
|
||||
该构建器负责构建代码生成页共享分区,以及单条业务表详情分区。
|
||||
|
||||
:param page_adapter: 代码生成浏览页适配器
|
||||
:param rendering: 代码生成浏览页渲染支持对象
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page_adapter: BaseBrowserAdapter,
|
||||
rendering: GenRenderingSupport,
|
||||
) -> None:
|
||||
"""
|
||||
初始化代码生成浏览页分区构建器。
|
||||
|
||||
:param page_adapter: 代码生成浏览页适配器
|
||||
:param rendering: 代码生成浏览页渲染支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.page_adapter = page_adapter
|
||||
self.rendering = rendering
|
||||
|
||||
def build_gen_focus_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建业务表概览分区。
|
||||
|
||||
:param payload: `gen detail` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
detail = payload.get('detail') if isinstance(payload, dict) else None
|
||||
info = detail.get('info') if isinstance(detail, dict) else None
|
||||
if not isinstance(info, 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: {payload.get("tableId", "-")}',
|
||||
f'表名: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("tableName", "-"), 40)}',
|
||||
],
|
||||
)
|
||||
|
||||
def build_gen_generation_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建代码生成配置分区。
|
||||
|
||||
:param payload: `gen detail` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
detail = payload.get('detail') if isinstance(payload, dict) else None
|
||||
info = detail.get('info') if isinstance(detail, dict) else None
|
||||
if not isinstance(info, 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'生成类名: {SHELL_TEXT_FORMATTER.truncate_text(info.get("className", "-"), 32)}',
|
||||
f'所属模块: {SHELL_TEXT_FORMATTER.truncate_text(info.get("moduleName", "-"), 24)}',
|
||||
'',
|
||||
'## 业务信息',
|
||||
f'业务标识: {SHELL_TEXT_FORMATTER.truncate_text(info.get("businessName", "-"), 24)}',
|
||||
f'功能名称: {SHELL_TEXT_FORMATTER.truncate_text(info.get("functionName", "-"), 24)}',
|
||||
],
|
||||
)
|
||||
|
||||
def build_gen_column_summary_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建字段概览分区。
|
||||
|
||||
:param payload: `gen detail` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
detail = payload.get('detail') if isinstance(payload, dict) else None
|
||||
rows = detail.get('rows') if isinstance(detail, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return DetailSectionSnapshot(
|
||||
title='字段摘要',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
payload, empty_label='字段摘要', empty_value='不可用'
|
||||
),
|
||||
)
|
||||
row_dicts = [row for row in rows if isinstance(row, dict)]
|
||||
primary_key_count = sum(1 for row in row_dicts if self.rendering.render_yes_no(row.get('isPk', '-')) == '是')
|
||||
required_count = sum(1 for row in row_dicts if self.rendering.render_yes_no(row.get('isRequired', '-')) == '是')
|
||||
return DetailSectionSnapshot(
|
||||
title='字段摘要',
|
||||
status='ok' if payload.get('ok', False) else 'fail',
|
||||
lines=[
|
||||
'## 字段规模',
|
||||
f'字段总数: {payload.get("columnCount", "-")}',
|
||||
f'主键字段: {primary_key_count}',
|
||||
f'必填字段: {required_count}',
|
||||
],
|
||||
)
|
||||
|
||||
def build_gen_columns_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建业务表字段分区。
|
||||
|
||||
:param payload: `gen detail` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
detail = payload.get('detail') if isinstance(payload, dict) else None
|
||||
rows = detail.get('rows') if isinstance(detail, dict) else None
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return DetailSectionSnapshot(
|
||||
title='字段列表',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='字段', empty_value='无'),
|
||||
)
|
||||
lines: list[str] = []
|
||||
for row in rows[:12]:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
lines.extend(
|
||||
[
|
||||
f'## {SHELL_TEXT_FORMATTER.truncate_text(row.get("columnName", "-"), 24)} · {SHELL_TEXT_FORMATTER.truncate_text(row.get("columnType", "-"), 20)}',
|
||||
(
|
||||
f'> 主键: {self.rendering.render_yes_no(row.get("isPk", "-"))} | '
|
||||
f'必填: {self.rendering.render_yes_no(row.get("isRequired", "-"))} | '
|
||||
f'查询: {row.get("queryType", "-")}'
|
||||
),
|
||||
f'> 说明: {SHELL_TEXT_FORMATTER.truncate_text(row.get("columnComment", "-"), 40)}',
|
||||
'',
|
||||
]
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title='字段列表',
|
||||
status='ok' if payload.get('ok', False) else 'fail',
|
||||
lines=(lines[:-1] if lines and lines[-1] == '' else lines)
|
||||
or TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label='字段信息',
|
||||
empty_value='0 列',
|
||||
detail='当前业务表没有可展示的字段信息',
|
||||
),
|
||||
)
|
||||
|
||||
def build_gen_precheck_section(
|
||||
self,
|
||||
detail_payload: dict[str, Any] | None,
|
||||
preview_payload: dict[str, Any] | None,
|
||||
) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建代码生成前校验分区。
|
||||
|
||||
:param detail_payload: `gen detail` JSON 负载
|
||||
:param preview_payload: `gen preview` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
detail = detail_payload.get('detail') if isinstance(detail_payload, dict) else None
|
||||
info = detail.get('info') if isinstance(detail, dict) else None
|
||||
rows = detail.get('rows') if isinstance(detail, dict) and isinstance(detail.get('rows'), list) else []
|
||||
preview = preview_payload.get('preview') if isinstance(preview_payload, dict) else None
|
||||
preview_templates = preview if isinstance(preview, dict) else {}
|
||||
row_dicts = [row for row in rows if isinstance(row, dict)]
|
||||
|
||||
has_class_name = bool(isinstance(info, dict) and str(info.get('className', '') or '').strip())
|
||||
has_module_name = bool(isinstance(info, dict) and str(info.get('moduleName', '') or '').strip())
|
||||
has_business_name = bool(isinstance(info, dict) and str(info.get('businessName', '') or '').strip())
|
||||
has_primary_key = any(self.rendering.render_yes_no(row.get('isPk', '-')) == '是' for row in row_dicts)
|
||||
column_count = len(row_dicts)
|
||||
template_count = len(preview_templates)
|
||||
|
||||
risk_items: list[str] = []
|
||||
if not has_class_name:
|
||||
risk_items.append('生成类名缺失')
|
||||
if not has_module_name:
|
||||
risk_items.append('所属模块缺失')
|
||||
if not has_business_name:
|
||||
risk_items.append('业务标识缺失')
|
||||
if column_count <= 0:
|
||||
risk_items.append('字段列表为空')
|
||||
if not has_primary_key:
|
||||
risk_items.append('未识别到主键字段')
|
||||
if template_count <= 0:
|
||||
risk_items.append('未生成可预览模板')
|
||||
|
||||
status = 'ok' if not risk_items else 'warn'
|
||||
return DetailSectionSnapshot(
|
||||
title='生成前校验',
|
||||
status=status,
|
||||
lines=[
|
||||
'## 校验结果',
|
||||
f'生成类名: {"通过" if has_class_name else "缺失"}',
|
||||
f'所属模块: {"通过" if has_module_name else "缺失"}',
|
||||
f'业务标识: {"通过" if has_business_name else "缺失"}',
|
||||
f'字段数量: {column_count}',
|
||||
f'主键字段: {"通过" if has_primary_key else "缺失"}',
|
||||
f'模板预览: {template_count} 份',
|
||||
'',
|
||||
'## 风险提示',
|
||||
*(risk_items if risk_items else ['当前未发现阻断生成的明显风险']),
|
||||
],
|
||||
)
|
||||
|
||||
def build_gen_sync_precheck_section(
|
||||
self,
|
||||
table_name: str,
|
||||
payload: dict[str, Any] | None,
|
||||
) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建数据库表结构同步前检查分区。
|
||||
|
||||
:param table_name: 业务表名称
|
||||
:param payload: `gen db-list --table-name=...` 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)
|
||||
matched_row = next(
|
||||
(row for row in rows if str(row.get('tableName', '') or '').strip().lower() == table_name.strip().lower()),
|
||||
None,
|
||||
)
|
||||
if matched_row is None:
|
||||
return DetailSectionSnapshot(
|
||||
title='同步预检查',
|
||||
status='warn',
|
||||
lines=[
|
||||
'## 当前状态',
|
||||
f'目标业务表: {table_name}',
|
||||
'数据库物理表: 未匹配',
|
||||
'',
|
||||
'## 建议操作',
|
||||
'当前数据库中未找到同名物理表,执行同步前应先确认表结构来源',
|
||||
],
|
||||
)
|
||||
|
||||
return DetailSectionSnapshot(
|
||||
title='同步预检查',
|
||||
status='ok',
|
||||
lines=[
|
||||
'## 当前状态',
|
||||
f'目标业务表: {table_name}',
|
||||
f'数据库物理表: {matched_row.get("tableName", "-")}',
|
||||
f'表注释: {SHELL_TEXT_FORMATTER.truncate_text(matched_row.get("tableComment", "-"), 56)}',
|
||||
'',
|
||||
'## 建议操作',
|
||||
'已匹配到同名物理表,可继续使用同步数据库表结构动作',
|
||||
],
|
||||
)
|
||||
|
||||
def build_gen_preview_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建代码预览分区。
|
||||
|
||||
:param payload: `gen preview` 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='不可用'
|
||||
),
|
||||
)
|
||||
preview_payload = payload.get('preview') if isinstance(payload.get('preview'), dict) else {}
|
||||
lines = [
|
||||
'## 模板规模',
|
||||
f'模板数量: {payload.get("templateCount", len(preview_payload))}',
|
||||
]
|
||||
if not preview_payload:
|
||||
lines.extend(
|
||||
[
|
||||
'',
|
||||
*TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label='模板样本',
|
||||
empty_value='0 份',
|
||||
detail='当前业务表没有可预览的模板输出',
|
||||
),
|
||||
]
|
||||
)
|
||||
return DetailSectionSnapshot(title='代码预览', status='info', lines=lines)
|
||||
|
||||
lines.extend(['', '## 模板样本'])
|
||||
for template_name, template_content in list(preview_payload.items())[:4]:
|
||||
preview_lines = str(template_content).splitlines()[:3]
|
||||
lines.append(f'## {SHELL_TEXT_FORMATTER.truncate_text(template_name, 48)}')
|
||||
lines.extend(f'> {SHELL_TEXT_FORMATTER.truncate_text(line, 72)}' for line in preview_lines)
|
||||
lines.append('')
|
||||
if lines[-1] == '':
|
||||
lines.pop()
|
||||
return DetailSectionSnapshot(
|
||||
title='代码预览',
|
||||
status='ok',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def build_gen_export_preview_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建代码导出预演分区。
|
||||
|
||||
:param payload: `gen export --dry-run` 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='不可用'
|
||||
),
|
||||
)
|
||||
|
||||
results = payload.get('results') if isinstance(payload.get('results'), list) else []
|
||||
lines = [
|
||||
'## 预演结果',
|
||||
f'执行模式: {payload.get("mode", "-")}',
|
||||
f'dry-run: {"是" if payload.get("dryRun", False) else "否"}',
|
||||
f'表数量: {len(payload.get("tableNames", [])) if isinstance(payload.get("tableNames"), list) else 0}',
|
||||
f'结果数量: {len(results)}',
|
||||
'',
|
||||
'## 摘要',
|
||||
f'结果摘要: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("message", "-"), 72)}',
|
||||
]
|
||||
if payload.get('outputFile'):
|
||||
lines.append(f'输出文件: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("outputFile", "-"), 72)}')
|
||||
if payload.get('genPath'):
|
||||
lines.append(f'输出目录: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("genPath", "-"), 72)}')
|
||||
if results:
|
||||
lines.extend(['', '## 结果样本'])
|
||||
for item in results[:4]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
lines.append(
|
||||
f'> {item.get("tableName", "-")} · {"成功" if item.get("ok", False) else "失败"} · '
|
||||
f'{SHELL_TEXT_FORMATTER.truncate_text(item.get("message", "-"), 48)}'
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title='导出预览',
|
||||
status='ok',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def build_importable_tables_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建数据库可导入表共享分区。
|
||||
|
||||
:param payload: `gen db-list` 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)
|
||||
lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label='可导入数据表',
|
||||
empty_value='0 张',
|
||||
detail='当前环境没有可导入的数据库表',
|
||||
)
|
||||
if rows:
|
||||
lines = [
|
||||
f'{SHELL_TEXT_FORMATTER.truncate_text(row.get("tableName", "-"), 40)} · {SHELL_TEXT_FORMATTER.truncate_text(row.get("tableComment", "-"), 48)}'
|
||||
for row in rows[:8]
|
||||
]
|
||||
return DetailSectionSnapshot(
|
||||
title='可导入数据表',
|
||||
status='ok',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_gen_export_entry_section(table_name: str) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建代码导出向导入口分区。
|
||||
|
||||
:param table_name: 当前业务表名称
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title='导出入口',
|
||||
status='info',
|
||||
lines=TUI_COPY.build_command_hint_lines(
|
||||
scenario=f'准备导出当前业务表 {table_name} 的生成结果时,应先通过向导确认目标表、输出目录和覆盖范围。',
|
||||
command=TUI_COPY.build_cli_command_hint('wizard', 'gen-export', '--output=text'),
|
||||
guide='进入向导后优先选择当前业务表,并在最终确认前检查导出目录和文件覆盖预览。',
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_gen_import_entry_section(importable_rows: list[dict[str, Any]]) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建物理表导入入口分区。
|
||||
|
||||
:param importable_rows: 可导入物理表列表
|
||||
:return: 分区快照
|
||||
"""
|
||||
suggested_table_names = [
|
||||
str(row.get('tableName', '') or '').strip()
|
||||
for row in importable_rows[:3]
|
||||
if str(row.get('tableName', '') or '').strip()
|
||||
]
|
||||
suggested_tables = ' '.join(suggested_table_names) if suggested_table_names else '<table_name>'
|
||||
return DetailSectionSnapshot(
|
||||
title='导入入口',
|
||||
status='info',
|
||||
lines=TUI_COPY.build_command_hint_lines(
|
||||
scenario='准备把数据库物理表纳入代码生成管理时,应先核对物理表名、注释和目标环境,再通过导入命令执行 dry-run 预演。',
|
||||
command=TUI_COPY.build_cli_command_hint(
|
||||
'gen', 'import-table', suggested_tables, '--dry-run', '--output=text'
|
||||
),
|
||||
guide='建议先从“可导入数据表”中确认目标表名,必要时一次只导入 1 到 3 张表,确认 dry-run 输出后再执行真实导入。',
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_gen_create_entry_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建建表 SQL 导入入口分区。
|
||||
|
||||
:return: 分区快照
|
||||
"""
|
||||
sql_template = 'CREATE TABLE demo_table (id bigint primary key);'
|
||||
return DetailSectionSnapshot(
|
||||
title='建表入口',
|
||||
status='info',
|
||||
lines=TUI_COPY.build_command_hint_lines(
|
||||
scenario='准备根据建表 SQL 直接创建物理表并导入代码生成配置时,应先使用单条 SQL 执行 dry-run 预演,确认语句可解析且只包含目标表。',
|
||||
command=TUI_COPY.build_cli_command_hint(
|
||||
'gen', 'create-table', '--dry-run', '--sql', sql_template, '--output=text'
|
||||
),
|
||||
guide='建议优先使用单条 CREATE TABLE 语句做预演;若 SQL 较长或来自文件,可改用 --sql-file 方式,但不要同时传入 --sql 与 --sql-file。',
|
||||
),
|
||||
)
|
||||
|
||||
def load_gen_detail_sections(
|
||||
self,
|
||||
table_row: dict[str, Any],
|
||||
env: str,
|
||||
) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
按需加载单条业务表详情分区。
|
||||
|
||||
:param table_row: 业务表列表行数据
|
||||
:param env: 当前运行环境
|
||||
:return: 详情分区列表
|
||||
"""
|
||||
table_id = table_row.get('tableId')
|
||||
detail_payload = NESTED_CLI_SUPPORT.run(
|
||||
'gen',
|
||||
'detail',
|
||||
str(table_id),
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
preview_payload = NESTED_CLI_SUPPORT.run(
|
||||
'gen',
|
||||
'preview',
|
||||
str(table_id),
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
table_name = str(table_row.get('tableName', '-') or '-')
|
||||
export_payload = NESTED_CLI_SUPPORT.run(
|
||||
'gen',
|
||||
'export',
|
||||
table_name,
|
||||
f'--env={env}',
|
||||
'--dry-run',
|
||||
'--mode=zip',
|
||||
'--output=json',
|
||||
'--yes',
|
||||
parse_json=True,
|
||||
).payload
|
||||
sync_check_payload = NESTED_CLI_SUPPORT.run(
|
||||
'gen',
|
||||
'db-list',
|
||||
f'--env={env}',
|
||||
f'--table-name={table_name}',
|
||||
'--paged',
|
||||
'--page-size=5',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
return [
|
||||
self.build_gen_focus_section(detail_payload),
|
||||
self.build_gen_generation_section(detail_payload),
|
||||
self.build_gen_column_summary_section(detail_payload),
|
||||
self.build_gen_precheck_section(detail_payload, preview_payload),
|
||||
self.build_gen_sync_precheck_section(table_name, sync_check_payload),
|
||||
self.build_gen_columns_section(detail_payload),
|
||||
self.build_gen_preview_section(preview_payload),
|
||||
self.build_gen_export_preview_section(export_payload),
|
||||
self.build_gen_export_entry_section(table_name),
|
||||
]
|
||||
|
||||
def build_gen_overview_section(
|
||||
self,
|
||||
gen_rows: list[dict[str, Any]],
|
||||
filtered_rows: list[dict[str, Any]],
|
||||
importable_rows: list[dict[str, Any]],
|
||||
) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建代码生成页总览判断共享分区。
|
||||
|
||||
:param gen_rows: 全量业务表行列表
|
||||
:param filtered_rows: 当前筛选后的业务表行列表
|
||||
:param importable_rows: 可导入物理表行列表
|
||||
:return: 分区快照
|
||||
"""
|
||||
incomplete_count = sum(
|
||||
1
|
||||
for row in gen_rows
|
||||
if not str(row.get('className', '') or '').strip()
|
||||
or not str(row.get('moduleName', '') or '').strip()
|
||||
or not str(row.get('businessName', '') or '').strip()
|
||||
)
|
||||
status = 'ok'
|
||||
conclusion = '当前生成配置可继续下钻,优先查看预检查、代码预览和导出预演'
|
||||
if incomplete_count > 0:
|
||||
status = 'warn'
|
||||
conclusion = '存在生成配置不完整的业务表,建议先处理生成前校验风险'
|
||||
elif not gen_rows and importable_rows:
|
||||
status = 'info'
|
||||
conclusion = '当前还没有业务表配置,可先从可导入物理表中选择目标'
|
||||
|
||||
return DetailSectionSnapshot(
|
||||
title='总览判断',
|
||||
status=status,
|
||||
lines=[
|
||||
'## 当前结论',
|
||||
conclusion,
|
||||
'',
|
||||
'## 核心指标',
|
||||
f'已加载业务表: {len(gen_rows)} 张',
|
||||
f'当前匹配: {len(filtered_rows)} 张',
|
||||
f'可导入物理表: {len(importable_rows)} 张',
|
||||
f'配置待补全: {incomplete_count} 张',
|
||||
'',
|
||||
'## 建议入口',
|
||||
'优先关注:生成前校验 / 同步预检查 / 代码预览 / 导出预览 / 导入入口 / 建表入口',
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class GenRecordBuilder:
|
||||
"""
|
||||
代码生成浏览记录构建器。
|
||||
|
||||
该对象负责构建代码生成页单条业务表浏览记录与失败兜底记录。
|
||||
|
||||
:param page_adapter: 代码生成浏览页适配器
|
||||
:param section_builder: 代码生成浏览页分区构建器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page_adapter: BaseBrowserAdapter,
|
||||
section_builder: GenSectionBuilder,
|
||||
) -> None:
|
||||
"""
|
||||
初始化代码生成浏览记录构建器。
|
||||
|
||||
:param page_adapter: 代码生成浏览页适配器
|
||||
:param section_builder: 代码生成浏览页分区构建器
|
||||
:return: None
|
||||
"""
|
||||
self.page_adapter = page_adapter
|
||||
self.section_builder = section_builder
|
||||
|
||||
def build_gen_record(self, table_row: dict[str, Any], env: str) -> BrowserRecordSnapshot:
|
||||
"""
|
||||
构建单条业务表浏览记录。
|
||||
|
||||
:param table_row: 业务表列表行数据
|
||||
:param env: 当前运行环境
|
||||
:return: 浏览记录快照
|
||||
"""
|
||||
table_id = table_row.get('tableId', '-')
|
||||
table_name = str(table_row.get('tableName', '-') or '-')
|
||||
class_name = str(table_row.get('className', '-') or '-')
|
||||
module_name = str(table_row.get('moduleName', '-') or '-')
|
||||
|
||||
return BrowserRecordSnapshot(
|
||||
key=f'gen:{table_id}',
|
||||
title=SHELL_TEXT_FORMATTER.truncate_text(table_name, 40),
|
||||
status='ok',
|
||||
summary=f'生成类 {SHELL_TEXT_FORMATTER.truncate_text(class_name, 24)} · 模块 {SHELL_TEXT_FORMATTER.truncate_text(module_name, 20)}',
|
||||
metadata_lines=[
|
||||
'## 表身份',
|
||||
f'表 ID: {table_id}',
|
||||
'',
|
||||
'## 生成信息',
|
||||
f'生成类名: {SHELL_TEXT_FORMATTER.truncate_text(class_name, 32)}',
|
||||
f'所属模块: {SHELL_TEXT_FORMATTER.truncate_text(module_name, 24)}',
|
||||
f'业务标识: {SHELL_TEXT_FORMATTER.truncate_text(table_row.get("businessName", "-"), 24)}',
|
||||
],
|
||||
detail_sections=[],
|
||||
detail_loader=lambda table_row=table_row, env=env: self.section_builder.load_gen_detail_sections(
|
||||
table_row, env
|
||||
),
|
||||
)
|
||||
|
||||
def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot:
|
||||
"""
|
||||
构建代码生成页失败兜底记录。
|
||||
|
||||
:param payload: 失败结果负载
|
||||
:return: 浏览记录快照
|
||||
"""
|
||||
return self.page_adapter.build_failure_record(
|
||||
key='gen:unavailable',
|
||||
subject='代码生成',
|
||||
section_subject='业务表列表',
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
class GenBrowserAdapter(BaseBrowserAdapter):
|
||||
"""
|
||||
代码生成浏览页适配器。
|
||||
|
||||
该适配器负责采集业务表列表、详情、预览与可导入表清单,并委托
|
||||
协作对象完成共享分区、详情分区和记录构建。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rendering: GenRenderingSupport | None = None,
|
||||
section_builder: GenSectionBuilder | None = None,
|
||||
record_builder: GenRecordBuilder | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化代码生成浏览页适配器。
|
||||
|
||||
:param rendering: 代码生成浏览页渲染支持对象
|
||||
:param section_builder: 代码生成浏览页分区构建器
|
||||
:param record_builder: 代码生成浏览记录构建器
|
||||
:return: None
|
||||
"""
|
||||
super().__init__(
|
||||
page_title='代码生成',
|
||||
search_view_key='gen',
|
||||
filter_options=(),
|
||||
)
|
||||
self.rendering = rendering or GenRenderingSupport()
|
||||
self.section_builder = section_builder or GenSectionBuilder(self, self.rendering)
|
||||
self.record_builder = record_builder or GenRecordBuilder(self, self.section_builder)
|
||||
|
||||
@staticmethod
|
||||
def apply_gen_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('tableName', '') or '').strip().lower()
|
||||
or normalized_query in str(row.get('className', '') or '').strip().lower()
|
||||
or normalized_query in str(row.get('moduleName', '') or '').strip().lower()
|
||||
]
|
||||
|
||||
def collect_snapshot(self, env: str, query: str = '') -> BrowserPageSnapshot:
|
||||
"""
|
||||
采集代码生成浏览页只读快照。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:param query: 当前搜索词
|
||||
:return: 浏览页快照
|
||||
"""
|
||||
search_context = self.resolve_search_context(query)
|
||||
gen_tables_payload = NESTED_CLI_SUPPORT.run(
|
||||
'gen',
|
||||
'list',
|
||||
f'--env={env}',
|
||||
'--paged',
|
||||
'--page-size=8',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
db_tables_payload = NESTED_CLI_SUPPORT.run(
|
||||
'gen',
|
||||
'db-list',
|
||||
f'--env={env}',
|
||||
'--paged',
|
||||
'--page-size=8',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload
|
||||
importable_rows = self.extract_page_rows(db_tables_payload)
|
||||
|
||||
if not isinstance(gen_tables_payload, dict) or not gen_tables_payload.get('ok', False):
|
||||
return BrowserPageSnapshot(
|
||||
title='代码生成',
|
||||
subtitle=TUI_COPY.build_unavailable_subtitle(
|
||||
'业务表',
|
||||
SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(gen_tables_payload), 72),
|
||||
),
|
||||
records=[self.record_builder.build_failure_record(gen_tables_payload)],
|
||||
shared_sections=[
|
||||
self.section_builder.build_gen_overview_section([], [], importable_rows),
|
||||
self.section_builder.build_importable_tables_section(db_tables_payload),
|
||||
self.section_builder.build_gen_import_entry_section(importable_rows),
|
||||
self.section_builder.build_gen_create_entry_section(),
|
||||
],
|
||||
search=search_context,
|
||||
)
|
||||
|
||||
rows = self.extract_page_rows(gen_tables_payload)
|
||||
filtered_rows = self.apply_gen_query(rows, query)
|
||||
records = [self.record_builder.build_gen_record(table_row, env) for table_row in filtered_rows[:8]]
|
||||
if not records:
|
||||
records = [
|
||||
self.build_empty_record(
|
||||
key='gen:none',
|
||||
subject='业务表',
|
||||
empty_label='业务表',
|
||||
has_source_rows=bool(rows),
|
||||
filtered_summary='当前搜索条件下没有匹配业务表',
|
||||
empty_summary='当前环境中还没有纳入代码生成的业务表',
|
||||
filtered_empty_value='暂无匹配',
|
||||
empty_empty_value='暂无配置',
|
||||
filtered_detail='当前搜索条件下没有匹配业务表',
|
||||
empty_detail='当前环境中尚未配置代码生成业务表',
|
||||
)
|
||||
]
|
||||
|
||||
return BrowserPageSnapshot(
|
||||
title='代码生成',
|
||||
subtitle=TUI_DIAGNOSTIC_SERVICE.build_gen_diagnostic_subtitle(
|
||||
len(filtered_rows),
|
||||
len(importable_rows),
|
||||
),
|
||||
records=records,
|
||||
shared_sections=[
|
||||
self.section_builder.build_gen_overview_section(rows, filtered_rows, importable_rows),
|
||||
self.section_builder.build_importable_tables_section(db_tables_payload),
|
||||
self.section_builder.build_gen_import_entry_section(importable_rows),
|
||||
self.section_builder.build_gen_create_entry_section(),
|
||||
],
|
||||
search=search_context,
|
||||
)
|
||||
|
||||
|
||||
GEN_BROWSER_ADAPTER = GenBrowserAdapter()
|
||||
@@ -0,0 +1,988 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
|
||||
from cli.tui.keymaps import TUI_KEYMAP_REGISTRY
|
||||
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
|
||||
|
||||
DEPENDENCY_CHECK_TOTAL = 3
|
||||
DASHBOARD_PANEL_MAX_LINES = 8
|
||||
DASHBOARD_PANEL_LINE_MAX_LENGTH = 38
|
||||
DASHBOARD_PANEL_MORE_HINT = TUI_COPY.build_more_detail_hint()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DashboardPanelSnapshot:
|
||||
"""
|
||||
TUI 首页单个面板快照。
|
||||
|
||||
:param title: 面板标题
|
||||
:param status: 面板状态
|
||||
:param lines: 面板正文文本行
|
||||
"""
|
||||
|
||||
title: str
|
||||
status: str
|
||||
lines: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DashboardMetricSnapshot:
|
||||
"""
|
||||
TUI 首页单个指标卡快照。
|
||||
|
||||
:param title: 指标标题
|
||||
:param value: 指标主值
|
||||
:param status: 指标状态
|
||||
:param hint: 指标补充说明
|
||||
"""
|
||||
|
||||
title: str
|
||||
value: str
|
||||
status: str
|
||||
hint: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DashboardSnapshot:
|
||||
"""
|
||||
TUI 首页聚合快照。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:param panels: 面板列表
|
||||
"""
|
||||
|
||||
env: str
|
||||
panels: list[DashboardPanelSnapshot]
|
||||
metrics: list[DashboardMetricSnapshot] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DashboardSourcePayloads:
|
||||
"""
|
||||
TUI 首页聚合数据源快照。
|
||||
|
||||
:param app_env_payload: 应用环境结果
|
||||
:param app_routes_payload: 应用路由结果
|
||||
:param doctor_payload: 应用检查结果
|
||||
:param database_payload: 数据库版本结果
|
||||
:param cache_payload: 缓存统计结果
|
||||
:param deps_payload: 依赖检查结果
|
||||
:param server_payload: 服务器摘要结果
|
||||
"""
|
||||
|
||||
app_env_payload: dict[str, Any] | None
|
||||
app_routes_payload: dict[str, Any] | None
|
||||
doctor_payload: dict[str, Any] | None
|
||||
database_payload: dict[str, Any] | None
|
||||
cache_payload: dict[str, Any] | None
|
||||
deps_payload: dict[str, Any] | None
|
||||
server_payload: dict[str, Any] | None
|
||||
|
||||
|
||||
class DashboardSnapshotCollector:
|
||||
"""
|
||||
TUI 首页聚合数据采集器。
|
||||
|
||||
该对象负责从多路 CLI 命令收集 dashboard 所需原始结果,
|
||||
让 `DashboardAdapter` 保持首页装配职责,而不继续承载所有采集细节。
|
||||
"""
|
||||
|
||||
def collect(self, env: str) -> DashboardSourcePayloads:
|
||||
"""
|
||||
采集首页聚合所需的多路原始结果。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:return: 首页聚合数据源快照
|
||||
"""
|
||||
return DashboardSourcePayloads(
|
||||
app_env_payload=NESTED_CLI_SUPPORT.run(
|
||||
'app',
|
||||
'env',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
app_routes_payload=NESTED_CLI_SUPPORT.run(
|
||||
'app',
|
||||
'routes',
|
||||
f'--env={env}',
|
||||
'--group-by=tag',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
doctor_payload=NESTED_CLI_SUPPORT.run(
|
||||
'app',
|
||||
'doctor',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
database_payload=NESTED_CLI_SUPPORT.run(
|
||||
'db',
|
||||
'current',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
cache_payload=NESTED_CLI_SUPPORT.run(
|
||||
'cache',
|
||||
'stats',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
deps_payload=NESTED_CLI_SUPPORT.run(
|
||||
'ops',
|
||||
'deps',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
server_payload=NESTED_CLI_SUPPORT.run(
|
||||
'ops',
|
||||
'server-info',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
)
|
||||
|
||||
|
||||
class DashboardFormattingSupport:
|
||||
"""
|
||||
首页仪表盘格式化支持对象。
|
||||
|
||||
该对象负责统一处理摘要消息提取、状态优先级、布尔文本、信号条和
|
||||
面板空态/失败态文案,供面板与指标构建对象共享。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def truncate_dashboard_line(line: str) -> str:
|
||||
"""
|
||||
截断首页面板单行文本,并保留结构化前缀。
|
||||
|
||||
:param line: 原始文本行
|
||||
:return: 截断后的文本行
|
||||
"""
|
||||
stripped = str(line).strip()
|
||||
if not stripped:
|
||||
return ''
|
||||
if stripped.startswith('## '):
|
||||
return f'## {SHELL_TEXT_FORMATTER.truncate_text(stripped[3:].strip(), DASHBOARD_PANEL_LINE_MAX_LENGTH - 3)}'
|
||||
if stripped.startswith('> '):
|
||||
return f'> {SHELL_TEXT_FORMATTER.truncate_text(stripped[2:].strip(), DASHBOARD_PANEL_LINE_MAX_LENGTH - 2)}'
|
||||
return SHELL_TEXT_FORMATTER.truncate_text(stripped, DASHBOARD_PANEL_LINE_MAX_LENGTH)
|
||||
|
||||
@staticmethod
|
||||
def resolve_status_priority(status: str) -> int:
|
||||
"""
|
||||
解析状态优先级,值越小越需要优先关注。
|
||||
|
||||
:param status: 状态文本
|
||||
:return: 优先级
|
||||
"""
|
||||
return {
|
||||
'fail': 0,
|
||||
'warn': 1,
|
||||
'ok': 2,
|
||||
'info': 3,
|
||||
}.get(str(status).strip().lower(), 9)
|
||||
|
||||
@staticmethod
|
||||
def render_bool_flag(value: object) -> str:
|
||||
"""
|
||||
将布尔语义值标准化为文本。
|
||||
|
||||
:param value: 原始值
|
||||
:return: 规范化后的文本
|
||||
"""
|
||||
return '是' if bool(value) else '否'
|
||||
|
||||
@staticmethod
|
||||
def render_signal_bar(
|
||||
passed: int,
|
||||
total: int,
|
||||
*,
|
||||
width: int = 8,
|
||||
on: str = '#',
|
||||
off: str = '-',
|
||||
) -> str:
|
||||
"""
|
||||
将通过数量渲染为 ASCII 信号条。
|
||||
|
||||
:param passed: 已通过数量
|
||||
:param total: 总数量
|
||||
:param width: 信号条宽度
|
||||
:param on: 点亮字符
|
||||
:param off: 熄灭字符
|
||||
:return: ASCII 信号条
|
||||
"""
|
||||
if total <= 0:
|
||||
return f'[{off * width}]'
|
||||
safe_passed = max(0, min(passed, total))
|
||||
filled = round((safe_passed / total) * width)
|
||||
return f'[{on * filled}{off * max(0, width - filled)}]'
|
||||
|
||||
@staticmethod
|
||||
def extract_payload_message(payload: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
提取结果负载中的摘要消息。
|
||||
|
||||
:param payload: 标准结果负载
|
||||
:return: 摘要文本
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return '-'
|
||||
if payload.get('message'):
|
||||
return str(payload.get('message'))
|
||||
if payload.get('error'):
|
||||
return SHELL_TEXT_FORMATTER.truncate_text(payload.get('error'), 120)
|
||||
return '-'
|
||||
|
||||
def build_failure_lines(
|
||||
self,
|
||||
payload: dict[str, Any] | None,
|
||||
*,
|
||||
summary_label: str,
|
||||
summary_value: str,
|
||||
suggestion: str = TUI_COPY.build_dashboard_failure_suggestion(),
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建首页面板统一失败态摘要。
|
||||
|
||||
:param payload: 标准结果负载
|
||||
:param summary_label: 主字段名称
|
||||
:param summary_value: 主字段值
|
||||
:param suggestion: 建议操作
|
||||
:return: 面板正文
|
||||
"""
|
||||
return [
|
||||
'## 当前状态',
|
||||
f'{summary_label}: {summary_value}',
|
||||
'',
|
||||
'## 错误摘要',
|
||||
f'> {self.extract_payload_message(payload)}',
|
||||
'',
|
||||
'## 建议操作',
|
||||
suggestion,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_empty_lines(
|
||||
*,
|
||||
summary_label: str,
|
||||
summary_value: str,
|
||||
detail: str,
|
||||
suggestion: str = TUI_COPY.build_dashboard_empty_suggestion(),
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建首页面板统一空态摘要。
|
||||
|
||||
:param summary_label: 主字段名称
|
||||
:param summary_value: 主字段值
|
||||
:param detail: 空态说明
|
||||
:param suggestion: 建议操作
|
||||
:return: 面板正文
|
||||
"""
|
||||
return [
|
||||
'## 当前状态',
|
||||
f'{summary_label}: {summary_value}',
|
||||
'',
|
||||
'## 说明',
|
||||
detail,
|
||||
'',
|
||||
'## 建议操作',
|
||||
suggestion,
|
||||
]
|
||||
|
||||
|
||||
class DashboardPanelCompressor:
|
||||
"""
|
||||
首页面板压缩器。
|
||||
|
||||
该对象负责将原始多行面板压缩为适合首页展示的统一密度摘要卡。
|
||||
|
||||
:param formatting: 首页仪表盘格式化支持对象
|
||||
"""
|
||||
|
||||
def __init__(self, formatting: DashboardFormattingSupport) -> None:
|
||||
"""
|
||||
初始化首页面板压缩器。
|
||||
|
||||
:param formatting: 首页仪表盘格式化支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.formatting = formatting
|
||||
|
||||
def compact_panel_lines(self, lines: list[str]) -> list[str]:
|
||||
"""
|
||||
将首页面板正文压缩为统一长度的摘要卡内容。
|
||||
|
||||
:param lines: 原始正文文本行
|
||||
:return: 压缩后的摘要行列表
|
||||
"""
|
||||
compact_lines: list[str] = []
|
||||
for raw_line in lines:
|
||||
compact_line = self.formatting.truncate_dashboard_line(raw_line)
|
||||
if not compact_line:
|
||||
if compact_lines and compact_lines[-1] != '':
|
||||
compact_lines.append('')
|
||||
continue
|
||||
compact_lines.append(compact_line)
|
||||
|
||||
while compact_lines and compact_lines[0] == '':
|
||||
compact_lines.pop(0)
|
||||
while compact_lines and compact_lines[-1] == '':
|
||||
compact_lines.pop()
|
||||
|
||||
if len(compact_lines) <= DASHBOARD_PANEL_MAX_LINES:
|
||||
return compact_lines
|
||||
|
||||
summarized_lines = compact_lines[: DASHBOARD_PANEL_MAX_LINES - 1]
|
||||
while summarized_lines and summarized_lines[-1] == '':
|
||||
summarized_lines.pop()
|
||||
summarized_lines.append(DASHBOARD_PANEL_MORE_HINT)
|
||||
return summarized_lines
|
||||
|
||||
def compact_panels(self, panels: list[DashboardPanelSnapshot]) -> list[DashboardPanelSnapshot]:
|
||||
"""
|
||||
将首页所有面板压缩为统一密度的摘要卡。
|
||||
|
||||
:param panels: 原始面板列表
|
||||
:return: 压缩后的面板列表
|
||||
"""
|
||||
return [
|
||||
DashboardPanelSnapshot(
|
||||
title=panel.title,
|
||||
status=panel.status,
|
||||
lines=self.compact_panel_lines(panel.lines),
|
||||
)
|
||||
for panel in panels
|
||||
]
|
||||
|
||||
|
||||
class DashboardPanelBuilder:
|
||||
"""
|
||||
首页面板构建器。
|
||||
|
||||
该对象负责根据各个 CLI 结果负载构建首页展示面板,并统一风险热区与
|
||||
建议入口逻辑。
|
||||
|
||||
:param formatting: 首页仪表盘格式化支持对象
|
||||
"""
|
||||
|
||||
def __init__(self, formatting: DashboardFormattingSupport) -> None:
|
||||
"""
|
||||
初始化首页面板构建器。
|
||||
|
||||
:param formatting: 首页仪表盘格式化支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.formatting = formatting
|
||||
|
||||
def build_app_env_panel(
|
||||
self,
|
||||
env: str,
|
||||
env_payload: dict[str, Any] | None,
|
||||
routes_payload: dict[str, Any] | None,
|
||||
) -> DashboardPanelSnapshot:
|
||||
"""
|
||||
构建应用环境总览面板。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:param env_payload: `app env` JSON 负载
|
||||
:param routes_payload: `app routes` JSON 负载
|
||||
:return: 面板快照
|
||||
"""
|
||||
runtime = env_payload.get('runtime') if isinstance(env_payload, dict) else None
|
||||
route_count = routes_payload.get('count', 0) if isinstance(routes_payload, dict) else 0
|
||||
grouped_routes = routes_payload.get('groupedRoutes') if isinstance(routes_payload, dict) else None
|
||||
group_count = len(grouped_routes) if isinstance(grouped_routes, dict) else 0
|
||||
if not isinstance(runtime, dict):
|
||||
return DashboardPanelSnapshot(
|
||||
title='应用摘要',
|
||||
status='fail',
|
||||
lines=self.formatting.build_failure_lines(
|
||||
env_payload,
|
||||
summary_label='环境',
|
||||
summary_value=env,
|
||||
suggestion=TUI_COPY.build_dashboard_page_suggestion('应用', '检查环境解析结果'),
|
||||
),
|
||||
)
|
||||
return DashboardPanelSnapshot(
|
||||
title='应用摘要',
|
||||
status='ok' if env_payload.get('ok', False) else 'fail',
|
||||
lines=[
|
||||
'## 环境映射',
|
||||
f'当前环境: {env} | CLI 目标环境: {runtime.get("cliEnv", "-")}',
|
||||
f'配置文件环境: {runtime.get("configEnv", "-")}',
|
||||
'## 路由摘要',
|
||||
f'注册路由: {route_count} 条 | 标签分组: {group_count} 个',
|
||||
'## 环境文件',
|
||||
f'已加载环境文件: {runtime.get("envFile", "-")}',
|
||||
f'环境文件存在: {self.formatting.render_bool_flag(runtime.get("envFileExists", False))}',
|
||||
],
|
||||
)
|
||||
|
||||
def build_health_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot:
|
||||
"""
|
||||
构建健康检查面板。
|
||||
|
||||
:param payload: `app doctor` JSON 负载
|
||||
:return: 面板快照
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return DashboardPanelSnapshot(
|
||||
title='系统摘要',
|
||||
status='fail',
|
||||
lines=self.formatting.build_failure_lines(
|
||||
payload,
|
||||
summary_label='巡检结果',
|
||||
summary_value='不可用',
|
||||
suggestion=TUI_COPY.build_dashboard_page_suggestion('运维/健康检查', '查看原因'),
|
||||
),
|
||||
)
|
||||
|
||||
database = payload.get('database') if isinstance(payload.get('database'), dict) else {}
|
||||
redis = payload.get('redis') if isinstance(payload.get('redis'), dict) else {}
|
||||
crypto = payload.get('crypto') if isinstance(payload.get('crypto'), dict) else {}
|
||||
return DashboardPanelSnapshot(
|
||||
title='系统摘要',
|
||||
status='ok' if payload.get('ok', False) else 'fail',
|
||||
lines=[
|
||||
'## 总体态势',
|
||||
f'整体结果: {"通过" if payload.get("ok", False) else "异常"}',
|
||||
'',
|
||||
'## 依赖检查',
|
||||
f'数据库: {"正常" if database.get("ok", False) else "异常"}',
|
||||
f'> {self.formatting.extract_payload_message(database)}',
|
||||
f'Redis: {"正常" if redis.get("ok", False) else "异常"}',
|
||||
f'> {self.formatting.extract_payload_message(redis)}',
|
||||
f'加密组件: {"正常" if crypto.get("ok", False) else "异常"}',
|
||||
f'> {self.formatting.extract_payload_message(crypto)}',
|
||||
],
|
||||
)
|
||||
|
||||
def build_recommended_entry_panel(
|
||||
self,
|
||||
doctor_payload: dict[str, Any] | None,
|
||||
database_payload: dict[str, Any] | None,
|
||||
cache_payload: dict[str, Any] | None,
|
||||
) -> DashboardPanelSnapshot:
|
||||
"""
|
||||
构建首页建议入口面板。
|
||||
|
||||
:param doctor_payload: 健康检查负载
|
||||
:param database_payload: 数据库状态负载
|
||||
:param cache_payload: 缓存状态负载
|
||||
:return: 面板快照
|
||||
"""
|
||||
database_ok = (
|
||||
bool(doctor_payload.get('database', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False
|
||||
)
|
||||
redis_ok = bool(doctor_payload.get('redis', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False
|
||||
crypto_ok = (
|
||||
bool(doctor_payload.get('crypto', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False
|
||||
)
|
||||
db_revision = str(database_payload.get('currentRevision', '-') if isinstance(database_payload, dict) else '-')
|
||||
cache_size = cache_payload.get('dbSize', '-') if isinstance(cache_payload, dict) else '-'
|
||||
|
||||
if not database_ok:
|
||||
status = 'fail'
|
||||
lines = [
|
||||
'## 首选入口',
|
||||
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("database").upper()}] 数据库',
|
||||
f'> {TUI_DIAGNOSTIC_SERVICE.build_database_focus_hint()}',
|
||||
'',
|
||||
'## 次级入口',
|
||||
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("ops").upper()}] 运维',
|
||||
f'> {TUI_DIAGNOSTIC_SERVICE.build_ops_focus_hint()}',
|
||||
'',
|
||||
'## 当前判断',
|
||||
f'迁移版本: {db_revision}',
|
||||
'> 数据库异常会优先阻断后续业务巡检',
|
||||
]
|
||||
elif not redis_ok:
|
||||
status = 'fail'
|
||||
lines = [
|
||||
'## 首选入口',
|
||||
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("cache").upper()}] 缓存',
|
||||
f'> {TUI_DIAGNOSTIC_SERVICE.build_cache_focus_hint()}',
|
||||
'',
|
||||
'## 次级入口',
|
||||
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("ops").upper()}] 运维',
|
||||
f'> {TUI_DIAGNOSTIC_SERVICE.build_ops_focus_hint()}',
|
||||
'',
|
||||
'## 当前判断',
|
||||
f'Redis 键数: {cache_size}',
|
||||
'> 缓存异常通常会影响配置读取和任务运行',
|
||||
]
|
||||
elif not crypto_ok:
|
||||
status = 'warn'
|
||||
lines = [
|
||||
'## 首选入口',
|
||||
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("crypto").upper()}] 加密',
|
||||
'> 先检查运行校验、公钥身份和兼容版本',
|
||||
'',
|
||||
'## 次级入口',
|
||||
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("ops").upper()}] 运维',
|
||||
'> 对照依赖版本,确认是否缺少加密相关运行依赖',
|
||||
'',
|
||||
'## 当前判断',
|
||||
'> 加密异常不会立即阻断全部能力,但会影响安全链路',
|
||||
]
|
||||
else:
|
||||
status = 'ok'
|
||||
lines = [
|
||||
'## 首选入口',
|
||||
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("jobs").upper()}] 任务 · {TUI_DIAGNOSTIC_SERVICE.build_jobs_focus_hint()}',
|
||||
'## 次级入口',
|
||||
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("configs").upper()}] 参数配置 · {TUI_DIAGNOSTIC_SERVICE.build_configs_focus_hint()}',
|
||||
'## 扩展入口',
|
||||
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("gen").upper()}] 代码生成 · {TUI_DIAGNOSTIC_SERVICE.build_gen_focus_hint()}',
|
||||
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("app").upper()}] 应用 · 配置摘要 / 路由状态',
|
||||
]
|
||||
|
||||
return DashboardPanelSnapshot(
|
||||
title='建议摘要',
|
||||
status=status,
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def build_inspection_conclusion_panel(
|
||||
self,
|
||||
doctor_payload: dict[str, Any] | None,
|
||||
database_payload: dict[str, Any] | None,
|
||||
cache_payload: dict[str, Any] | None,
|
||||
) -> DashboardPanelSnapshot:
|
||||
"""
|
||||
构建首页巡检结论面板。
|
||||
|
||||
:param doctor_payload: 健康检查负载
|
||||
:param database_payload: 数据库状态负载
|
||||
:param cache_payload: 缓存状态负载
|
||||
:return: 面板快照
|
||||
"""
|
||||
database_ok = (
|
||||
bool(doctor_payload.get('database', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False
|
||||
)
|
||||
redis_ok = bool(doctor_payload.get('redis', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False
|
||||
crypto_ok = (
|
||||
bool(doctor_payload.get('crypto', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False
|
||||
)
|
||||
db_revision = database_payload.get('currentRevision', '-') if isinstance(database_payload, dict) else '-'
|
||||
cache_size = cache_payload.get('dbSize', '-') if isinstance(cache_payload, dict) else '-'
|
||||
|
||||
if not database_ok:
|
||||
status = 'fail'
|
||||
conclusion = '数据库存在异常,建议优先处理连接或迁移问题'
|
||||
next_step = '优先进入数据库页面,确认迁移版本和连接配置'
|
||||
elif not redis_ok:
|
||||
status = 'fail'
|
||||
conclusion = '缓存服务存在异常,建议优先检查 Redis 可用性'
|
||||
next_step = '优先进入缓存页面,确认连接数、键数量和状态说明'
|
||||
elif not crypto_ok:
|
||||
status = 'warn'
|
||||
conclusion = '加密组件状态异常,建议尽快核对运行依赖'
|
||||
next_step = '先查看系统摘要面板,再排查加密组件依赖'
|
||||
else:
|
||||
status = 'ok'
|
||||
conclusion = '当前核心依赖状态正常,可继续查看业务分区'
|
||||
next_step = '优先查看任务与参数配置页面,确认业务侧是否有风险'
|
||||
|
||||
return DashboardPanelSnapshot(
|
||||
title='总览判断',
|
||||
status=status,
|
||||
lines=[
|
||||
'## 当前结论',
|
||||
conclusion,
|
||||
'',
|
||||
'## 建议操作',
|
||||
next_step,
|
||||
'',
|
||||
'## 当前快照',
|
||||
f'迁移版本: {db_revision}',
|
||||
f'Redis 键数: {cache_size}',
|
||||
],
|
||||
)
|
||||
|
||||
def build_database_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot:
|
||||
"""
|
||||
构建数据库状态面板。
|
||||
|
||||
:param payload: `db current` JSON 负载
|
||||
:return: 面板快照
|
||||
"""
|
||||
current_revision = payload.get('currentRevision', '-') if isinstance(payload, dict) else '-'
|
||||
if not isinstance(payload, dict) or not payload.get('ok', False):
|
||||
return DashboardPanelSnapshot(
|
||||
title='数据库摘要',
|
||||
status='fail',
|
||||
lines=self.formatting.build_failure_lines(
|
||||
payload,
|
||||
summary_label='迁移版本',
|
||||
summary_value=str(current_revision or '-'),
|
||||
suggestion=TUI_COPY.build_dashboard_page_suggestion('数据库', '确认迁移版本与连接状态'),
|
||||
),
|
||||
)
|
||||
return DashboardPanelSnapshot(
|
||||
title='数据库摘要',
|
||||
status='ok',
|
||||
lines=[
|
||||
'## 迁移信息',
|
||||
f'迁移版本: {current_revision}',
|
||||
'',
|
||||
'## 状态说明',
|
||||
f'> {self.formatting.extract_payload_message(payload)}',
|
||||
],
|
||||
)
|
||||
|
||||
def build_cache_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot:
|
||||
"""
|
||||
构建缓存状态面板。
|
||||
|
||||
:param payload: `cache stats` JSON 负载
|
||||
:return: 面板快照
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return DashboardPanelSnapshot(
|
||||
title='缓存摘要',
|
||||
status='fail',
|
||||
lines=self.formatting.build_failure_lines(
|
||||
payload,
|
||||
summary_label='缓存状态',
|
||||
summary_value='不可用',
|
||||
suggestion=TUI_COPY.build_dashboard_page_suggestion('缓存', '查看 Redis 状态'),
|
||||
),
|
||||
)
|
||||
info = payload.get('info') if isinstance(payload.get('info'), dict) else {}
|
||||
cache_names = payload.get('cacheNames') if isinstance(payload.get('cacheNames'), list) else []
|
||||
return DashboardPanelSnapshot(
|
||||
title='缓存摘要',
|
||||
status='ok' if payload.get('ok', False) else 'fail',
|
||||
lines=[
|
||||
'## Redis 概况',
|
||||
f'当前键数: {payload.get("dbSize", "-")}',
|
||||
f'Redis 版本: {info.get("redis_version", "-")}',
|
||||
f'客户端连接数: {info.get("connected_clients", "-")}',
|
||||
'',
|
||||
'## 缓存资源',
|
||||
f'已登记缓存名: {len(cache_names)} 个',
|
||||
],
|
||||
)
|
||||
|
||||
def build_dependency_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot:
|
||||
"""
|
||||
构建首页依赖版本摘要面板。
|
||||
|
||||
:param payload: `ops deps` JSON 负载
|
||||
:return: 面板快照
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return DashboardPanelSnapshot(
|
||||
title='依赖版本',
|
||||
status='fail',
|
||||
lines=self.formatting.build_failure_lines(
|
||||
payload,
|
||||
summary_label='依赖检查',
|
||||
summary_value='不可用',
|
||||
suggestion=TUI_COPY.build_dashboard_page_suggestion('运维', '检查依赖详情'),
|
||||
),
|
||||
)
|
||||
packages = payload.get('packages') if isinstance(payload.get('packages'), dict) else {}
|
||||
missing_required = payload.get('missingRequired') if isinstance(payload.get('missingRequired'), list) else []
|
||||
lines = [
|
||||
'## 依赖结论',
|
||||
f'检查结果: {"通过" if payload.get("ok", False) else "异常"}',
|
||||
f'缺失核心依赖: {len(missing_required)} 个',
|
||||
'',
|
||||
'## 关键版本',
|
||||
]
|
||||
for package_name in ('python', 'fastapi', 'sqlalchemy', 'redis'):
|
||||
package_payload = packages.get(package_name)
|
||||
if not isinstance(package_payload, dict):
|
||||
continue
|
||||
installed = '已安装' if package_payload.get('installed', False) else '缺失'
|
||||
lines.append(f'{package_name}: {installed} · {package_payload.get("version", "-") or "-"}')
|
||||
return DashboardPanelSnapshot(
|
||||
title='依赖版本',
|
||||
status='ok' if payload.get('ok', False) else 'fail',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def build_server_info_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot:
|
||||
"""
|
||||
构建首页服务器概况面板。
|
||||
|
||||
:param payload: `ops server-info` JSON 负载
|
||||
:return: 面板快照
|
||||
"""
|
||||
server = payload.get('server') if isinstance(payload, dict) else None
|
||||
if not isinstance(server, dict):
|
||||
return DashboardPanelSnapshot(
|
||||
title='服务器摘要',
|
||||
status='fail',
|
||||
lines=self.formatting.build_failure_lines(
|
||||
payload,
|
||||
summary_label='服务器摘要',
|
||||
summary_value='不可用',
|
||||
suggestion=TUI_COPY.build_dashboard_page_suggestion('运维', '重新采集服务器摘要'),
|
||||
),
|
||||
)
|
||||
sys_info = server.get('sys') if isinstance(server.get('sys'), dict) else {}
|
||||
cpu_info = server.get('cpu') if isinstance(server.get('cpu'), dict) else {}
|
||||
mem_info = server.get('mem') if isinstance(server.get('mem'), dict) else {}
|
||||
return DashboardPanelSnapshot(
|
||||
title='服务器摘要',
|
||||
status='ok' if payload.get('ok', False) else 'fail',
|
||||
lines=[
|
||||
'## 主机状态',
|
||||
f'主机名: {sys_info.get("computerName", "-")}',
|
||||
f'主机 IP: {sys_info.get("computerIp", "-")}',
|
||||
'',
|
||||
'## 资源概况',
|
||||
f'CPU 使用率: {cpu_info.get("used", "-")}%',
|
||||
f'内存使用率: {mem_info.get("usage", "-")}%',
|
||||
],
|
||||
)
|
||||
|
||||
def build_risk_heatmap_panel(self, panels: list[DashboardPanelSnapshot]) -> DashboardPanelSnapshot:
|
||||
"""
|
||||
构建首页风险热区面板。
|
||||
|
||||
:param panels: 当前已构建的面板列表
|
||||
:return: 风险热区面板
|
||||
"""
|
||||
sorted_panels = sorted(
|
||||
panels, key=lambda item: (self.formatting.resolve_status_priority(item.status), item.title)
|
||||
)
|
||||
fail_panels = [panel for panel in sorted_panels if panel.status == 'fail']
|
||||
warn_panels = [panel for panel in sorted_panels if panel.status == 'warn']
|
||||
primary_targets = fail_panels[:3] or warn_panels[:3] or sorted_panels[:3]
|
||||
|
||||
lines = [
|
||||
'## 热区摘要',
|
||||
f'失败面板: {len(fail_panels)} 个',
|
||||
f'警告面板: {len(warn_panels)} 个',
|
||||
'',
|
||||
'## 优先关注',
|
||||
]
|
||||
if not primary_targets:
|
||||
lines.extend(
|
||||
self.formatting.build_empty_lines(
|
||||
summary_label='风险热区',
|
||||
summary_value='0 个',
|
||||
detail='当前没有需要优先处理的失败或警告面板',
|
||||
suggestion='可继续浏览任务、缓存、参数配置等业务页面',
|
||||
)
|
||||
)
|
||||
else:
|
||||
for index, panel in enumerate(primary_targets, start=1):
|
||||
lead_line = SHELL_TEXT_FORMATTER.truncate_text(
|
||||
panel.lines[1] if len(panel.lines) > 1 else panel.lines[0], 56
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
f'> HOT-{index:02d} [{panel.status.upper()}] {panel.title}',
|
||||
f'> {lead_line}',
|
||||
]
|
||||
)
|
||||
|
||||
status = 'fail' if fail_panels else 'warn' if warn_panels else 'ok'
|
||||
return DashboardPanelSnapshot(
|
||||
title='风险摘要',
|
||||
status=status,
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
def build_panels(
|
||||
self,
|
||||
*,
|
||||
env: str,
|
||||
app_env_payload: dict[str, Any] | None,
|
||||
app_routes_payload: dict[str, Any] | None,
|
||||
doctor_payload: dict[str, Any] | None,
|
||||
database_payload: dict[str, Any] | None,
|
||||
cache_payload: dict[str, Any] | None,
|
||||
deps_payload: dict[str, Any] | None,
|
||||
server_payload: dict[str, Any] | None,
|
||||
) -> list[DashboardPanelSnapshot]:
|
||||
"""
|
||||
构建首页全部原始面板。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:param app_env_payload: 应用环境负载
|
||||
:param app_routes_payload: 应用路由负载
|
||||
:param doctor_payload: 健康检查负载
|
||||
:param database_payload: 数据库状态负载
|
||||
:param cache_payload: 缓存状态负载
|
||||
:param deps_payload: 运维依赖负载
|
||||
:param server_payload: 服务器摘要负载
|
||||
:return: 原始面板列表
|
||||
"""
|
||||
panels = [
|
||||
self.build_app_env_panel(env, app_env_payload, app_routes_payload),
|
||||
self.build_health_panel(doctor_payload),
|
||||
self.build_inspection_conclusion_panel(doctor_payload, database_payload, cache_payload),
|
||||
self.build_recommended_entry_panel(doctor_payload, database_payload, cache_payload),
|
||||
self.build_database_panel(database_payload),
|
||||
self.build_cache_panel(cache_payload),
|
||||
self.build_dependency_panel(deps_payload),
|
||||
self.build_server_info_panel(server_payload),
|
||||
]
|
||||
panels.append(self.build_risk_heatmap_panel(panels))
|
||||
return panels
|
||||
|
||||
|
||||
class DashboardMetricBuilder:
|
||||
"""
|
||||
首页指标卡构建器。
|
||||
|
||||
该对象负责根据健康检查、数据库和缓存状态构建首页驾驶舱指标卡。
|
||||
|
||||
:param formatting: 首页仪表盘格式化支持对象
|
||||
"""
|
||||
|
||||
def __init__(self, formatting: DashboardFormattingSupport) -> None:
|
||||
"""
|
||||
初始化首页指标卡构建器。
|
||||
|
||||
:param formatting: 首页仪表盘格式化支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.formatting = formatting
|
||||
|
||||
def build_metrics(
|
||||
self,
|
||||
doctor_payload: dict[str, Any] | None,
|
||||
database_payload: dict[str, Any] | None,
|
||||
cache_payload: dict[str, Any] | None,
|
||||
) -> list[DashboardMetricSnapshot]:
|
||||
"""
|
||||
构建首页驾驶舱指标卡。
|
||||
|
||||
:param doctor_payload: 健康检查负载
|
||||
:param database_payload: 数据库状态负载
|
||||
:param cache_payload: 缓存状态负载
|
||||
:return: 指标卡列表
|
||||
"""
|
||||
database = doctor_payload.get('database') if isinstance(doctor_payload, dict) else None
|
||||
redis = doctor_payload.get('redis') if isinstance(doctor_payload, dict) else None
|
||||
crypto = doctor_payload.get('crypto') if isinstance(doctor_payload, dict) else None
|
||||
|
||||
dependency_results = [
|
||||
bool(database.get('ok', False)) if isinstance(database, dict) else False,
|
||||
bool(redis.get('ok', False)) if isinstance(redis, dict) else False,
|
||||
bool(crypto.get('ok', False)) if isinstance(crypto, dict) else False,
|
||||
]
|
||||
passed_dependencies = sum(1 for item in dependency_results if item)
|
||||
posture = '风险' if isinstance(doctor_payload, dict) and not doctor_payload.get('ok', False) else '稳定'
|
||||
posture_status = 'fail' if posture == '风险' else 'ok'
|
||||
current_revision = database_payload.get('currentRevision', '-') if isinstance(database_payload, dict) else '-'
|
||||
cache_size = cache_payload.get('dbSize', '-') if isinstance(cache_payload, dict) else '-'
|
||||
|
||||
metrics = [
|
||||
DashboardMetricSnapshot(
|
||||
title='当前态势',
|
||||
value=f'{self.formatting.render_signal_bar(passed_dependencies, DEPENDENCY_CHECK_TOTAL)} {posture}',
|
||||
status=posture_status,
|
||||
hint=f'依赖通过 {passed_dependencies}/{DEPENDENCY_CHECK_TOTAL},健康检查自动判定',
|
||||
),
|
||||
DashboardMetricSnapshot(
|
||||
title='依赖通过率',
|
||||
value=(
|
||||
f'{self.formatting.render_signal_bar(passed_dependencies, DEPENDENCY_CHECK_TOTAL)} '
|
||||
f'{passed_dependencies}/{DEPENDENCY_CHECK_TOTAL}'
|
||||
),
|
||||
status=(
|
||||
'ok'
|
||||
if passed_dependencies == DEPENDENCY_CHECK_TOTAL
|
||||
else 'warn'
|
||||
if passed_dependencies > 0
|
||||
else 'fail'
|
||||
),
|
||||
hint='数据库、Redis、加密组件',
|
||||
),
|
||||
DashboardMetricSnapshot(
|
||||
title='迁移版本',
|
||||
value=f'[{SHELL_TEXT_FORMATTER.truncate_text(current_revision, 16)}]',
|
||||
status='ok' if current_revision != '-' else 'warn',
|
||||
hint='当前数据库 revision 基线',
|
||||
),
|
||||
DashboardMetricSnapshot(
|
||||
title='Redis 键数',
|
||||
value=f'[KEYS] {cache_size}',
|
||||
status='info',
|
||||
hint='当前缓存库 key 数量观测值',
|
||||
),
|
||||
]
|
||||
return sorted(
|
||||
metrics,
|
||||
key=lambda item: self.formatting.resolve_status_priority(item.status),
|
||||
)
|
||||
|
||||
|
||||
class DashboardAdapter:
|
||||
"""
|
||||
TUI 首页巡检聚合适配器。
|
||||
|
||||
该适配器负责采集应用、健康检查、数据库、缓存与运维只读快照,
|
||||
并委托面板构建器、指标构建器和压缩器组装首页结果。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
formatting: DashboardFormattingSupport | None = None,
|
||||
panel_builder: DashboardPanelBuilder | None = None,
|
||||
metric_builder: DashboardMetricBuilder | None = None,
|
||||
panel_compressor: DashboardPanelCompressor | None = None,
|
||||
snapshot_collector: DashboardSnapshotCollector | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化首页巡检聚合适配器。
|
||||
|
||||
:param formatting: 首页仪表盘格式化支持对象
|
||||
:param panel_builder: 首页面板构建器
|
||||
:param metric_builder: 首页指标卡构建器
|
||||
:param panel_compressor: 首页面板压缩器
|
||||
:param snapshot_collector: 首页聚合数据采集器
|
||||
:return: None
|
||||
"""
|
||||
self.formatting = formatting or DashboardFormattingSupport()
|
||||
self.panel_builder = panel_builder or DashboardPanelBuilder(self.formatting)
|
||||
self.metric_builder = metric_builder or DashboardMetricBuilder(self.formatting)
|
||||
self.panel_compressor = panel_compressor or DashboardPanelCompressor(self.formatting)
|
||||
self.snapshot_collector = snapshot_collector or DashboardSnapshotCollector()
|
||||
|
||||
def collect_snapshot(self, env: str) -> DashboardSnapshot:
|
||||
"""
|
||||
采集 TUI 首页只读巡检快照。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:return: 首页聚合快照
|
||||
"""
|
||||
source_payloads = self.snapshot_collector.collect(env)
|
||||
panels = self.panel_builder.build_panels(
|
||||
env=env,
|
||||
app_env_payload=source_payloads.app_env_payload,
|
||||
app_routes_payload=source_payloads.app_routes_payload,
|
||||
doctor_payload=source_payloads.doctor_payload,
|
||||
database_payload=source_payloads.database_payload,
|
||||
cache_payload=source_payloads.cache_payload,
|
||||
deps_payload=source_payloads.deps_payload,
|
||||
server_payload=source_payloads.server_payload,
|
||||
)
|
||||
return DashboardSnapshot(
|
||||
env=env,
|
||||
metrics=self.metric_builder.build_metrics(
|
||||
source_payloads.doctor_payload,
|
||||
source_payloads.database_payload,
|
||||
source_payloads.cache_payload,
|
||||
),
|
||||
panels=self.panel_compressor.compact_panels(panels),
|
||||
)
|
||||
|
||||
|
||||
DASHBOARD_ADAPTER = DashboardAdapter()
|
||||
@@ -0,0 +1,747 @@
|
||||
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()
|
||||
@@ -0,0 +1,227 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.search import PageFilterOption, PageSearchContext
|
||||
from cli.utils import SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DetailSectionSnapshot:
|
||||
"""
|
||||
TUI 详情页单个分区快照。
|
||||
|
||||
:param title: 分区标题
|
||||
:param status: 分区状态
|
||||
:param lines: 分区正文文本行
|
||||
"""
|
||||
|
||||
title: str
|
||||
status: str
|
||||
lines: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DetailPageSnapshot:
|
||||
"""
|
||||
TUI 详情页聚合快照。
|
||||
|
||||
:param title: 页面标题
|
||||
:param subtitle: 页面副标题
|
||||
:param sections: 页面分区列表
|
||||
:param search: 当前页面搜索上下文
|
||||
"""
|
||||
|
||||
title: str
|
||||
subtitle: str
|
||||
sections: list[DetailSectionSnapshot]
|
||||
search: PageSearchContext | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserRecordSnapshot:
|
||||
"""
|
||||
TUI 浏览页单条记录快照。
|
||||
|
||||
:param key: 记录唯一标识
|
||||
:param title: 记录标题
|
||||
:param status: 记录状态
|
||||
:param summary: 记录摘要
|
||||
:param metadata_lines: 记录元信息文本行
|
||||
:param detail_sections: 记录联动详情分区列表
|
||||
"""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
status: str
|
||||
summary: str
|
||||
metadata_lines: list[str]
|
||||
detail_sections: list[DetailSectionSnapshot]
|
||||
detail_loader: Callable[[], list[DetailSectionSnapshot]] | None = None
|
||||
_cached_detail_sections: tuple[DetailSectionSnapshot, ...] | None = field(
|
||||
default=None,
|
||||
init=False,
|
||||
repr=False,
|
||||
compare=False,
|
||||
)
|
||||
|
||||
def resolve_detail_sections(self) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
获取当前记录详情分区,必要时按需加载并缓存。
|
||||
|
||||
:return: 详情分区列表
|
||||
"""
|
||||
if self.detail_loader is None:
|
||||
return self.detail_sections
|
||||
if self._cached_detail_sections is None:
|
||||
object.__setattr__(self, '_cached_detail_sections', tuple(self.detail_loader()))
|
||||
return list(self._cached_detail_sections)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserPageSnapshot:
|
||||
"""
|
||||
TUI 浏览页聚合快照。
|
||||
|
||||
:param title: 页面标题
|
||||
:param subtitle: 页面副标题
|
||||
:param records: 可浏览记录列表
|
||||
:param shared_sections: 全局共享分区列表
|
||||
:param filters: 页面可用筛选项
|
||||
:param active_filter_key: 当前激活筛选键
|
||||
:param search: 当前页面搜索上下文
|
||||
"""
|
||||
|
||||
title: str
|
||||
subtitle: str
|
||||
records: list[BrowserRecordSnapshot]
|
||||
shared_sections: list[DetailSectionSnapshot]
|
||||
filters: list[PageFilterOption] = field(default_factory=list)
|
||||
active_filter_key: str | None = None
|
||||
search: PageSearchContext | None = None
|
||||
|
||||
|
||||
class TuiAdapterModelRenderService:
|
||||
"""
|
||||
TUI 适配层模型渲染服务。
|
||||
|
||||
该对象集中封装结果负载摘要提取,以及空态、加载态、失败态文本行
|
||||
的统一构建逻辑,避免这些模块级辅助函数继续散落扩张。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def extract_payload_message(payload: dict[str, object] | None) -> str:
|
||||
"""
|
||||
提取结果负载中的可读消息。
|
||||
|
||||
:param payload: 标准结果负载
|
||||
:return: 摘要消息文本
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return '-'
|
||||
if payload.get('message'):
|
||||
return SHELL_TEXT_FORMATTER.truncate_text(payload.get('message', '-'), 120)
|
||||
if payload.get('error'):
|
||||
return SHELL_TEXT_FORMATTER.truncate_text(payload.get('error', '-'), 120)
|
||||
return '-'
|
||||
|
||||
@staticmethod
|
||||
def build_empty_lines(
|
||||
*,
|
||||
empty_label: str,
|
||||
empty_value: str,
|
||||
detail: str,
|
||||
suggestion: str = TUI_COPY.build_empty_state_suggestion(),
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建统一的空态文本行。
|
||||
|
||||
:param empty_label: 空态主字段名称
|
||||
:param empty_value: 空态主字段值
|
||||
:param detail: 空态说明
|
||||
:param suggestion: 建议操作
|
||||
:return: 空态文本行列表
|
||||
"""
|
||||
return [
|
||||
TUI_COPY.build_state_section_title('status'),
|
||||
f'{empty_label}: {empty_value}',
|
||||
'',
|
||||
TUI_COPY.build_state_section_title('detail'),
|
||||
detail,
|
||||
'',
|
||||
TUI_COPY.build_state_section_title('suggestion'),
|
||||
suggestion,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_loading_lines(
|
||||
*,
|
||||
loading_label: str,
|
||||
loading_value: str,
|
||||
detail: str,
|
||||
suggestion: str = TUI_COPY.build_loading_state_suggestion(),
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建统一的加载中文本行。
|
||||
|
||||
:param loading_label: 加载态主字段名称
|
||||
:param loading_value: 加载态主字段值
|
||||
:param detail: 加载说明
|
||||
:param suggestion: 建议操作
|
||||
:return: 加载态文本行列表
|
||||
"""
|
||||
return [
|
||||
TUI_COPY.build_state_section_title('status'),
|
||||
f'{loading_label}: {loading_value}',
|
||||
'',
|
||||
TUI_COPY.build_state_section_title('detail'),
|
||||
detail,
|
||||
'',
|
||||
TUI_COPY.build_state_section_title('suggestion'),
|
||||
suggestion,
|
||||
]
|
||||
|
||||
def build_failure_lines(
|
||||
self,
|
||||
payload: dict[str, object] | None,
|
||||
*,
|
||||
empty_label: str,
|
||||
empty_value: str,
|
||||
) -> list[str]:
|
||||
"""
|
||||
为失败状态构建统一的可读文本行。
|
||||
|
||||
:param payload: 标准结果负载
|
||||
:param empty_label: 主字段标签
|
||||
:param empty_value: 主字段兜底值
|
||||
:return: 失败状态文本行列表
|
||||
"""
|
||||
lines = [
|
||||
TUI_COPY.build_state_section_title('status'),
|
||||
f'{empty_label}: {empty_value}',
|
||||
'',
|
||||
TUI_COPY.build_state_section_title('error'),
|
||||
]
|
||||
if not isinstance(payload, dict):
|
||||
lines.append('结果消息: -')
|
||||
lines.extend(
|
||||
['', TUI_COPY.build_state_section_title('suggestion'), TUI_COPY.build_failure_state_suggestion()]
|
||||
)
|
||||
return lines
|
||||
lines.append(f'结果消息: {self.extract_payload_message(payload)}')
|
||||
detail_lines: list[str] = []
|
||||
if payload.get('hint'):
|
||||
detail_lines.append(f'建议提示: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("hint", "-"), 120)}')
|
||||
if payload.get('error'):
|
||||
detail_lines.append(f'错误信息: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("error", "-"), 120)}')
|
||||
if payload.get('stderr'):
|
||||
detail_lines.append(f'标准错误: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("stderr", "-"), 120)}')
|
||||
if payload.get('stdout'):
|
||||
detail_lines.append(f'标准输出: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("stdout", "-"), 120)}')
|
||||
if detail_lines:
|
||||
lines.extend(['', TUI_COPY.build_state_section_title('diagnostic'), *detail_lines])
|
||||
lines.extend(['', TUI_COPY.build_state_section_title('suggestion'), TUI_COPY.build_failure_state_suggestion()])
|
||||
return lines
|
||||
|
||||
|
||||
TUI_ADAPTER_MODEL_RENDERER = TuiAdapterModelRenderService()
|
||||
@@ -0,0 +1,482 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cli.tui.adapters.base import BaseDetailAdapter
|
||||
from cli.tui.adapters.models import (
|
||||
TUI_ADAPTER_MODEL_RENDERER,
|
||||
DetailPageSnapshot,
|
||||
DetailSectionSnapshot,
|
||||
)
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
|
||||
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpsDetailSourcePayloads:
|
||||
"""
|
||||
运维详情页原始数据源快照。
|
||||
|
||||
:param health_payload: `ops health` 结果
|
||||
:param ping_db_payload: `ops ping-db` 结果
|
||||
:param ping_redis_payload: `ops ping-redis` 结果
|
||||
:param deps_payload: `ops deps` 结果
|
||||
:param server_payload: `ops server-info` 结果
|
||||
"""
|
||||
|
||||
health_payload: dict[str, Any] | None
|
||||
ping_db_payload: dict[str, Any] | None
|
||||
ping_redis_payload: dict[str, Any] | None
|
||||
deps_payload: dict[str, Any] | None
|
||||
server_payload: dict[str, Any] | None
|
||||
|
||||
|
||||
class OpsDetailSnapshotCollector:
|
||||
"""
|
||||
运维详情页数据采集器。
|
||||
|
||||
该对象负责拉取运维详情页所需的多路 CLI 原始结果,
|
||||
让 `OpsDetailAdapter` 保持详情页编排职责。
|
||||
"""
|
||||
|
||||
def collect(self, env: str) -> OpsDetailSourcePayloads:
|
||||
"""
|
||||
采集运维详情页所需原始结果。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:return: 运维详情页原始数据源快照
|
||||
"""
|
||||
return OpsDetailSourcePayloads(
|
||||
health_payload=NESTED_CLI_SUPPORT.run(
|
||||
'ops',
|
||||
'health',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
ping_db_payload=NESTED_CLI_SUPPORT.run(
|
||||
'ops',
|
||||
'ping-db',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
ping_redis_payload=NESTED_CLI_SUPPORT.run(
|
||||
'ops',
|
||||
'ping-redis',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
deps_payload=NESTED_CLI_SUPPORT.run(
|
||||
'ops',
|
||||
'deps',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
server_payload=NESTED_CLI_SUPPORT.run(
|
||||
'ops',
|
||||
'server-info',
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
parse_json=True,
|
||||
).payload,
|
||||
)
|
||||
|
||||
|
||||
class OpsSectionBuilder:
|
||||
"""
|
||||
运维详情分区构建器。
|
||||
|
||||
该构建器负责将运维相关 CLI 结果负载转换为 TUI 详情页分区,
|
||||
使详情页适配器本体只保留采集与编排职责。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_health_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建运维健康检查分区。
|
||||
|
||||
:param payload: `ops health` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return DetailSectionSnapshot(
|
||||
title='健康检查',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
payload, empty_label='健康检查', empty_value='不可用'
|
||||
),
|
||||
)
|
||||
database = payload.get('database') if isinstance(payload.get('database'), dict) else {}
|
||||
redis = payload.get('redis') if isinstance(payload.get('redis'), dict) else {}
|
||||
return DetailSectionSnapshot(
|
||||
title='健康检查',
|
||||
status='ok' if payload.get('ok', False) else 'fail',
|
||||
lines=[
|
||||
'## 基础连通性',
|
||||
f'环境: {payload.get("env", "-")}',
|
||||
f'数据库: {"正常" if database.get("ok", False) else "异常"}',
|
||||
f'> {SHELL_TEXT_FORMATTER.truncate_text(database.get("message", "-"), 64)}',
|
||||
f'Redis: {"正常" if redis.get("ok", False) else "异常"}',
|
||||
f'> {SHELL_TEXT_FORMATTER.truncate_text(redis.get("message", "-"), 64)}',
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_ping_db_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建数据库探活结果分区。
|
||||
|
||||
:param payload: `ops ping-db` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
if not isinstance(payload, 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'数据库连接: {"正常" if payload.get("ok", False) else "异常"}',
|
||||
f'结果摘要: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("message", "-"), 72)}',
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_ping_redis_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建 Redis 探活结果分区。
|
||||
|
||||
:param payload: `ops ping-redis` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return DetailSectionSnapshot(
|
||||
title='Redis 探活',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
payload, empty_label='Redis 探活', empty_value='不可用'
|
||||
),
|
||||
)
|
||||
return DetailSectionSnapshot(
|
||||
title='Redis 探活',
|
||||
status='ok' if payload.get('ok', False) else 'fail',
|
||||
lines=[
|
||||
'## 探活结果',
|
||||
f'Redis 连接: {"正常" if payload.get("ok", False) else "异常"}',
|
||||
f'结果摘要: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("message", "-"), 72)}',
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_dependency_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建运维依赖版本分区。
|
||||
|
||||
:param payload: `ops deps` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return DetailSectionSnapshot(
|
||||
title='依赖版本',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='依赖', empty_value='不可用'),
|
||||
)
|
||||
packages = payload.get('packages') if isinstance(payload.get('packages'), dict) else {}
|
||||
missing_required = payload.get('missingRequired') if isinstance(payload.get('missingRequired'), list) else []
|
||||
lines = [
|
||||
'## 总体结论',
|
||||
f'检查结果: {"通过" if payload.get("ok", False) else "异常"}',
|
||||
f'缺失核心依赖: {len(missing_required)} 个',
|
||||
f'> {SHELL_TEXT_FORMATTER.truncate_text(payload.get("message", "-"), 72)}',
|
||||
'',
|
||||
'## 关键版本',
|
||||
]
|
||||
for package_name in ('python', 'fastapi', 'sqlalchemy', 'redis', 'typer', 'alembic'):
|
||||
package_payload = packages.get(package_name)
|
||||
if not isinstance(package_payload, dict):
|
||||
continue
|
||||
installed = '已安装' if package_payload.get('installed', False) else '缺失'
|
||||
lines.append(f'> {package_name} · {installed} · {package_payload.get("version", "-") or "-"}')
|
||||
if missing_required:
|
||||
lines.extend(['', '## 缺失项'])
|
||||
lines.extend(f'> {item}' for item in missing_required[:8])
|
||||
return DetailSectionSnapshot(
|
||||
title='依赖版本',
|
||||
status='ok' if payload.get('ok', False) else 'fail',
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_server_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建服务器运行概况分区。
|
||||
|
||||
:param payload: `ops server-info` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
server = payload.get('server') if isinstance(payload, dict) else None
|
||||
if not isinstance(server, dict):
|
||||
return DetailSectionSnapshot(
|
||||
title='服务器摘要',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
payload, empty_label='服务器信息', empty_value='不可用'
|
||||
),
|
||||
)
|
||||
|
||||
sys_info = server.get('sys') if isinstance(server.get('sys'), dict) else {}
|
||||
cpu_info = server.get('cpu') if isinstance(server.get('cpu'), dict) else {}
|
||||
mem_info = server.get('mem') if isinstance(server.get('mem'), dict) else {}
|
||||
py_info = server.get('py') if isinstance(server.get('py'), dict) else {}
|
||||
return DetailSectionSnapshot(
|
||||
title='服务器摘要',
|
||||
status='ok' if payload.get('ok', False) else 'fail',
|
||||
lines=[
|
||||
'## 主机信息',
|
||||
f'主机名: {sys_info.get("computerName", "-")}',
|
||||
f'主机 IP: {sys_info.get("computerIp", "-")}',
|
||||
f'操作系统: {SHELL_TEXT_FORMATTER.truncate_text(sys_info.get("osName", "-"), 56)}',
|
||||
f'系统架构: {sys_info.get("osArch", "-")}',
|
||||
'',
|
||||
'## 资源负载',
|
||||
f'CPU 核心: {cpu_info.get("cpuNum", "-")}',
|
||||
f'CPU 使用率: {cpu_info.get("used", "-")}%',
|
||||
f'内存总量: {mem_info.get("total", "-")}',
|
||||
f'内存使用率: {mem_info.get("usage", "-")}%',
|
||||
'',
|
||||
'## Python 进程',
|
||||
f'版本: {py_info.get("version", "-")}',
|
||||
f'运行时长: {py_info.get("runTime", "-")}',
|
||||
f'进程内存: {py_info.get("used", "-")} / {py_info.get("total", "-")}',
|
||||
],
|
||||
)
|
||||
|
||||
def build_disk_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建服务器磁盘样本分区。
|
||||
|
||||
:param payload: `ops server-info` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
server = payload.get('server') if isinstance(payload, dict) else None
|
||||
if not isinstance(server, dict):
|
||||
return DetailSectionSnapshot(
|
||||
title='磁盘样本',
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
payload, empty_label='磁盘样本', empty_value='不可用'
|
||||
),
|
||||
)
|
||||
sys_files = server.get('sysFiles') if isinstance(server.get('sysFiles'), list) else []
|
||||
lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label='磁盘样本',
|
||||
empty_value='0 个',
|
||||
detail='当前服务器信息中没有返回磁盘分区样本',
|
||||
suggestion=TUI_COPY.build_refresh_page_suggestion('运维', '重新采集服务器信息'),
|
||||
)
|
||||
section_status = 'info'
|
||||
if sys_files:
|
||||
section_status = 'ok'
|
||||
lines = []
|
||||
for item in sys_files[:8]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
lines.extend(
|
||||
[
|
||||
f'## {SHELL_TEXT_FORMATTER.truncate_text(item.get("dirName", "-"), 36)}',
|
||||
(
|
||||
f'> 已用 {item.get("used", "-")} / 总量 {item.get("total", "-")} '
|
||||
f'| 使用率 {item.get("usage", "-")}'
|
||||
),
|
||||
f'> 可用 {item.get("free", "-")}',
|
||||
'',
|
||||
]
|
||||
)
|
||||
if lines and lines[-1] == '':
|
||||
lines.pop()
|
||||
return DetailSectionSnapshot(
|
||||
title='磁盘样本',
|
||||
status=section_status,
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_prod_check_entry_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建生产巡检向导入口分区。
|
||||
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title='生产巡检入口',
|
||||
status='info',
|
||||
lines=TUI_COPY.build_command_hint_lines(
|
||||
scenario='准备做生产环境巡检或上线前复核时,应先通过向导统一检查数据库、缓存和运行配置。',
|
||||
command=TUI_COPY.build_cli_command_hint('wizard', 'prod-check', '--output=text'),
|
||||
guide='向导会继续询问目标环境并输出预览结果,确认后再执行完整巡检。',
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_overview_section(
|
||||
health_payload: dict[str, Any] | None,
|
||||
deps_payload: dict[str, Any] | None,
|
||||
server_payload: dict[str, Any] | None,
|
||||
) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建运维页总览判断分区。
|
||||
|
||||
:param health_payload: `ops health` JSON 负载
|
||||
:param deps_payload: `ops deps` JSON 负载
|
||||
:param server_payload: `ops server-info` JSON 负载
|
||||
:return: 分区快照
|
||||
"""
|
||||
health_ok = bool(isinstance(health_payload, dict) and health_payload.get('ok', False))
|
||||
deps_ok = bool(isinstance(deps_payload, dict) and deps_payload.get('ok', False))
|
||||
server_ok = bool(isinstance(server_payload, dict) and server_payload.get('ok', False))
|
||||
missing_required = deps_payload.get('missingRequired') if isinstance(deps_payload, dict) else None
|
||||
missing_count = len(missing_required) if isinstance(missing_required, list) else 0
|
||||
server = (
|
||||
server_payload.get('server')
|
||||
if isinstance(server_payload, dict) and isinstance(server_payload.get('server'), dict)
|
||||
else {}
|
||||
)
|
||||
cpu_info = server.get('cpu') if isinstance(server.get('cpu'), dict) else {}
|
||||
mem_info = server.get('mem') if isinstance(server.get('mem'), dict) else {}
|
||||
|
||||
status = 'ok'
|
||||
conclusion = '基础探活与运行依赖正常,可继续查看服务器资源与磁盘样本'
|
||||
if not health_ok:
|
||||
status = 'fail'
|
||||
conclusion = '运维探活存在异常,优先处理数据库或 Redis 连通性问题'
|
||||
elif not deps_ok or missing_count > 0:
|
||||
status = 'warn'
|
||||
conclusion = '运行依赖存在缺口,建议优先补齐核心依赖并确认版本兼容性'
|
||||
elif not server_ok:
|
||||
status = 'warn'
|
||||
conclusion = '服务器信息采集异常,建议重新采集资源与磁盘状态'
|
||||
|
||||
return DetailSectionSnapshot(
|
||||
title='总览判断',
|
||||
status=status,
|
||||
lines=[
|
||||
'## 当前结论',
|
||||
conclusion,
|
||||
'',
|
||||
'## 核心指标',
|
||||
f'探活状态: {"正常" if health_ok else "异常"}',
|
||||
f'缺失核心依赖: {missing_count} 个',
|
||||
f'CPU 使用率: {cpu_info.get("used", "-")}%',
|
||||
f'内存使用率: {mem_info.get("usage", "-")}%',
|
||||
'',
|
||||
'## 建议入口',
|
||||
'优先关注:健康检查 / 依赖版本 / 服务器摘要 / 生产巡检入口',
|
||||
],
|
||||
)
|
||||
|
||||
def build_sections(
|
||||
self,
|
||||
*,
|
||||
health_payload: dict[str, Any] | None,
|
||||
ping_db_payload: dict[str, Any] | None,
|
||||
ping_redis_payload: dict[str, Any] | None,
|
||||
deps_payload: dict[str, Any] | None,
|
||||
server_payload: dict[str, Any] | None,
|
||||
) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
构建运维页全部详情分区。
|
||||
|
||||
:param health_payload: `ops health` 结果负载
|
||||
:param ping_db_payload: `ops ping-db` 结果负载
|
||||
:param ping_redis_payload: `ops ping-redis` 结果负载
|
||||
:param deps_payload: `ops deps` 结果负载
|
||||
:param server_payload: `ops server-info` 结果负载
|
||||
:return: 详情分区列表
|
||||
"""
|
||||
return [
|
||||
self.build_overview_section(health_payload, deps_payload, server_payload),
|
||||
self.build_health_section(health_payload),
|
||||
self.build_ping_db_section(ping_db_payload),
|
||||
self.build_ping_redis_section(ping_redis_payload),
|
||||
self.build_dependency_section(deps_payload),
|
||||
self.build_server_section(server_payload),
|
||||
self.build_disk_section(server_payload),
|
||||
self.build_prod_check_entry_section(),
|
||||
]
|
||||
|
||||
|
||||
class OpsDetailAdapter(BaseDetailAdapter):
|
||||
"""
|
||||
运维详情页适配器。
|
||||
|
||||
该适配器负责采集运维相关 CLI 结果,并委托分区构建器组装为
|
||||
TUI 详情页快照。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
section_builder: OpsSectionBuilder | None = None,
|
||||
snapshot_collector: OpsDetailSnapshotCollector | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化运维详情页适配器。
|
||||
|
||||
:param section_builder: 运维详情分区构建器
|
||||
:param snapshot_collector: 运维详情页数据采集器
|
||||
:return: None
|
||||
"""
|
||||
super().__init__(
|
||||
page_title='运维',
|
||||
search_view_key='ops',
|
||||
default_suggestions=[
|
||||
'总览判断',
|
||||
'健康检查',
|
||||
'数据库探活',
|
||||
'Redis 探活',
|
||||
'依赖版本',
|
||||
'服务器摘要',
|
||||
'磁盘样本',
|
||||
'生产巡检入口',
|
||||
],
|
||||
)
|
||||
self.section_builder = section_builder or OpsSectionBuilder()
|
||||
self.snapshot_collector = snapshot_collector or OpsDetailSnapshotCollector()
|
||||
|
||||
def collect_snapshot(self, env: str, query: str = '') -> DetailPageSnapshot:
|
||||
"""
|
||||
采集运维状态页只读快照。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:param query: 当前搜索词
|
||||
:return: 页面快照
|
||||
"""
|
||||
source_payloads = self.snapshot_collector.collect(env)
|
||||
sections = self.section_builder.build_sections(
|
||||
health_payload=source_payloads.health_payload,
|
||||
ping_db_payload=source_payloads.ping_db_payload,
|
||||
ping_redis_payload=source_payloads.ping_redis_payload,
|
||||
deps_payload=source_payloads.deps_payload,
|
||||
server_payload=source_payloads.server_payload,
|
||||
)
|
||||
return DetailPageSnapshot(
|
||||
title='运维',
|
||||
subtitle=TUI_DIAGNOSTIC_SERVICE.build_ops_diagnostic_subtitle(
|
||||
source_payloads.health_payload,
|
||||
source_payloads.deps_payload,
|
||||
source_payloads.server_payload,
|
||||
),
|
||||
sections=self.filter_sections(sections, query),
|
||||
search=self.resolve_search_context(query),
|
||||
)
|
||||
|
||||
|
||||
OPS_DETAIL_ADAPTER = OpsDetailAdapter()
|
||||
Reference in New Issue
Block a user