初始提交

This commit is contained in:
焦龙言
2026-07-24 09:43:10 +08:00
commit b5b4721456
793 changed files with 116305 additions and 0 deletions
@@ -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()