调整部分前端布局和后端增删改逻辑
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from cli.tui.commands import TUI_COMMAND_REGISTRATION
|
||||
|
||||
__all__ = ['TUI_COMMAND_REGISTRATION']
|
||||
@@ -0,0 +1,10 @@
|
||||
from cli.tui.actions.bootstrap import TUI_ACTION_EXECUTION_SERVICE, TUI_ACTION_PRESENTATION_SERVICE, TUI_ACTION_REGISTRY
|
||||
from cli.tui.actions.models import TuiActionResult, TuiActionSpec
|
||||
|
||||
__all__ = [
|
||||
'TUI_ACTION_EXECUTION_SERVICE',
|
||||
'TUI_ACTION_PRESENTATION_SERVICE',
|
||||
'TUI_ACTION_REGISTRY',
|
||||
'TuiActionResult',
|
||||
'TuiActionSpec',
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cli.tui.actions.builders import TuiActionSpecFactory
|
||||
from cli.tui.actions.factories.cache import CacheActionTemplateFactory
|
||||
from cli.tui.actions.factories.gen import GenActionTemplateFactory
|
||||
from cli.tui.actions.factories.jobs import JobActionTemplateFactory
|
||||
from cli.tui.actions.factories.static import StaticActionTemplateFactory
|
||||
from cli.tui.actions.registry import TuiActionRegistry, TuiActionSlotResolver
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiActionRegistryBuilder:
|
||||
"""
|
||||
TUI 动作注册表构建器。
|
||||
|
||||
该对象负责装配各领域动作模板工厂,最终输出浏览页和详情页共用的
|
||||
动作注册表,避免模板常量继续散落在模块底部。
|
||||
|
||||
:param jobs: 任务页动作模板工厂
|
||||
:param cache: 缓存页动作模板工厂
|
||||
:param gen: 代码生成页动作模板工厂
|
||||
:param static: 静态页面动作模板工厂
|
||||
:param spec_factory: 动作规格构建器
|
||||
"""
|
||||
|
||||
jobs: JobActionTemplateFactory
|
||||
cache: CacheActionTemplateFactory
|
||||
gen: GenActionTemplateFactory
|
||||
static: StaticActionTemplateFactory
|
||||
spec_factory: TuiActionSpecFactory
|
||||
|
||||
def build(self) -> TuiActionRegistry:
|
||||
"""
|
||||
构建 TUI 动作注册表。
|
||||
|
||||
:return: 动作注册表
|
||||
"""
|
||||
return TuiActionRegistry(
|
||||
browser_resolvers={
|
||||
'jobs': TuiActionSlotResolver(
|
||||
slot_templates={
|
||||
'primary': self.jobs.create_run_once_template(),
|
||||
'secondary': self.jobs.create_toggle_template(),
|
||||
'global': self.jobs.create_sync_template(),
|
||||
},
|
||||
spec_factory=self.spec_factory,
|
||||
),
|
||||
'configs': TuiActionSlotResolver(
|
||||
slot_templates={
|
||||
'global': self.static.create_config_sync_template(),
|
||||
},
|
||||
spec_factory=self.spec_factory,
|
||||
),
|
||||
'cache': TuiActionSlotResolver(
|
||||
slot_templates={
|
||||
'global': self.cache.create_clear_wizard_template(),
|
||||
'utility': self.cache.create_warmup_template(),
|
||||
},
|
||||
spec_factory=self.spec_factory,
|
||||
),
|
||||
'gen': TuiActionSlotResolver(
|
||||
slot_templates={
|
||||
'primary': self.gen.create_export_wizard_template(),
|
||||
'secondary': self.gen.create_import_wizard_template(),
|
||||
'global': self.gen.create_export_dry_run_template(),
|
||||
'utility': self.gen.create_sync_db_template(),
|
||||
},
|
||||
spec_factory=self.spec_factory,
|
||||
),
|
||||
},
|
||||
detail_resolvers={
|
||||
'app': TuiActionSlotResolver(
|
||||
slot_templates={
|
||||
'primary': self.static.create_app_run_template(),
|
||||
'global': self.static.create_app_run_wizard_template(),
|
||||
'utility': self.static.create_completion_install_template(),
|
||||
},
|
||||
spec_factory=self.spec_factory,
|
||||
),
|
||||
'database': TuiActionSlotResolver(
|
||||
slot_templates={
|
||||
'global': self.static.create_db_upgrade_wizard_template(),
|
||||
'utility': self.static.create_db_init_dry_run_template(),
|
||||
},
|
||||
spec_factory=self.spec_factory,
|
||||
),
|
||||
'ops': TuiActionSlotResolver(
|
||||
slot_templates={
|
||||
'primary': self.static.create_ops_ping_db_template(),
|
||||
'secondary': self.static.create_ops_ping_redis_template(),
|
||||
'global': self.static.create_prod_check_wizard_template(),
|
||||
},
|
||||
spec_factory=self.spec_factory,
|
||||
),
|
||||
'crypto': TuiActionSlotResolver(
|
||||
slot_templates={
|
||||
'primary': self.static.create_crypto_keygen_template(),
|
||||
'global': self.static.create_crypto_rotate_dry_run_template(),
|
||||
},
|
||||
spec_factory=self.spec_factory,
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
from cli.tui.actions.assembly import TuiActionRegistryBuilder
|
||||
from cli.tui.actions.builders import TuiActionSpecFactory, TuiActionTemplateSupport
|
||||
from cli.tui.actions.execution import TuiActionExecutionService
|
||||
from cli.tui.actions.factories.cache import CacheActionTemplateFactory
|
||||
from cli.tui.actions.factories.gen import GenActionTemplateFactory
|
||||
from cli.tui.actions.factories.jobs import JobActionTemplateFactory
|
||||
from cli.tui.actions.factories.static import StaticActionTemplateFactory
|
||||
from cli.tui.actions.presentation import TuiActionPresentationService
|
||||
from cli.tui.capabilities import TUI_CAPABILITY_REGISTRY
|
||||
|
||||
_ACTION_SPEC_FACTORY = TuiActionSpecFactory()
|
||||
TUI_ACTION_EXECUTION_SERVICE = TuiActionExecutionService()
|
||||
|
||||
_ACTION_TEMPLATE_SUPPORT = TuiActionTemplateSupport(spec_factory=_ACTION_SPEC_FACTORY)
|
||||
_JOB_ACTION_TEMPLATE_FACTORY = JobActionTemplateFactory(support=_ACTION_TEMPLATE_SUPPORT)
|
||||
_CACHE_ACTION_TEMPLATE_FACTORY = CacheActionTemplateFactory(support=_ACTION_TEMPLATE_SUPPORT)
|
||||
_GEN_ACTION_TEMPLATE_FACTORY = GenActionTemplateFactory(support=_ACTION_TEMPLATE_SUPPORT)
|
||||
_STATIC_ACTION_TEMPLATE_FACTORY = StaticActionTemplateFactory(support=_ACTION_TEMPLATE_SUPPORT)
|
||||
_ACTION_REGISTRY_BUILDER = TuiActionRegistryBuilder(
|
||||
jobs=_JOB_ACTION_TEMPLATE_FACTORY,
|
||||
cache=_CACHE_ACTION_TEMPLATE_FACTORY,
|
||||
gen=_GEN_ACTION_TEMPLATE_FACTORY,
|
||||
static=_STATIC_ACTION_TEMPLATE_FACTORY,
|
||||
spec_factory=_ACTION_SPEC_FACTORY,
|
||||
)
|
||||
TUI_ACTION_REGISTRY = _ACTION_REGISTRY_BUILDER.build()
|
||||
|
||||
TUI_ACTION_PRESENTATION_SERVICE = TuiActionPresentationService(
|
||||
capability_registry=TUI_CAPABILITY_REGISTRY,
|
||||
action_registry=TUI_ACTION_REGISTRY,
|
||||
)
|
||||
@@ -0,0 +1,329 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cli.tui.actions.models import (
|
||||
ActionCommandBuilder,
|
||||
ActionExecutionMode,
|
||||
ActionSummaryBuilder,
|
||||
ActionTextBuilder,
|
||||
TuiActionSpec,
|
||||
)
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cli.tui.adapters.models import BrowserRecordSnapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiActionSpecFactory:
|
||||
"""
|
||||
TUI 动作规格构建器。
|
||||
|
||||
该对象负责统一构建动作预览文本与 `TuiActionSpec`,
|
||||
避免各动作函数继续手工拼接重复的预览和规格对象。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def parse_record_suffix(record: BrowserRecordSnapshot, prefix: str) -> str:
|
||||
"""
|
||||
从记录键中提取指定前缀后的标识。
|
||||
|
||||
:param record: 浏览记录
|
||||
:param prefix: 键名前缀
|
||||
:return: 后缀标识
|
||||
"""
|
||||
raw_key = str(record.key).strip()
|
||||
token_prefix = f'{prefix}:'
|
||||
if not raw_key.startswith(token_prefix):
|
||||
return ''
|
||||
return raw_key[len(token_prefix) :].strip()
|
||||
|
||||
def build_command_preview_lines(
|
||||
self,
|
||||
*,
|
||||
env: str,
|
||||
summary_lines: list[str],
|
||||
command_args: tuple[str, ...],
|
||||
append_yes: bool = True,
|
||||
consequence_text: str = TUI_COPY.build_action_consequence_text('preview'),
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建统一的动作预览文本。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:param summary_lines: 业务摘要文本
|
||||
:param command_args: 对应 CLI 命令参数
|
||||
:param consequence_text: 结果影响说明
|
||||
:return: 预览文本
|
||||
"""
|
||||
nested_cli_arguments = [
|
||||
*command_args,
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
]
|
||||
if append_yes:
|
||||
nested_cli_arguments.append('--yes')
|
||||
command = SHELL_TEXT_FORMATTER.format_shell_command(
|
||||
NESTED_CLI_SUPPORT.build_nested_cli_command(*nested_cli_arguments)
|
||||
)
|
||||
return [
|
||||
TUI_COPY.build_action_preview_title('summary'),
|
||||
*summary_lines,
|
||||
'',
|
||||
TUI_COPY.build_action_preview_title('env'),
|
||||
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('target_env'), env),
|
||||
'',
|
||||
TUI_COPY.build_action_preview_title('command'),
|
||||
command,
|
||||
'',
|
||||
TUI_COPY.build_action_preview_title('consequence'),
|
||||
consequence_text,
|
||||
]
|
||||
|
||||
def build_external_command_preview_lines(
|
||||
self,
|
||||
*,
|
||||
env: str,
|
||||
summary_lines: list[str],
|
||||
command_args: tuple[str, ...],
|
||||
append_yes: bool = True,
|
||||
consequence_text: str = TUI_COPY.build_action_consequence_text('wizard'),
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建外部交互命令的预览文本。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:param summary_lines: 业务摘要文本
|
||||
:param command_args: 对应 CLI 命令参数
|
||||
:param consequence_text: 结果影响说明
|
||||
:return: 预览文本
|
||||
"""
|
||||
return self.build_command_preview_lines(
|
||||
env=env,
|
||||
summary_lines=summary_lines,
|
||||
command_args=command_args,
|
||||
append_yes=append_yes,
|
||||
consequence_text=consequence_text,
|
||||
)
|
||||
|
||||
def build_nested_json_action(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
label: str,
|
||||
command_args: tuple[str, ...],
|
||||
env: str,
|
||||
summary_lines: list[str],
|
||||
preview_title: str | None = None,
|
||||
consequence_text: str = TUI_COPY.build_action_consequence_text('preview'),
|
||||
append_yes: bool = True,
|
||||
refresh_view: bool = True,
|
||||
) -> TuiActionSpec:
|
||||
"""
|
||||
构建标准 nested JSON 动作。
|
||||
|
||||
:param action_id: 动作唯一标识
|
||||
:param label: 动作显示名称
|
||||
:param command_args: 对应 CLI 命令参数
|
||||
:param env: 当前运行环境
|
||||
:param summary_lines: 业务摘要文本
|
||||
:param preview_title: 预览标题
|
||||
:param consequence_text: 结果影响说明
|
||||
:param refresh_view: 执行后是否刷新视图
|
||||
:return: 动作定义
|
||||
"""
|
||||
return TuiActionSpec(
|
||||
action_id=action_id,
|
||||
label=label,
|
||||
command_args=command_args,
|
||||
preview_title=preview_title or label,
|
||||
preview_lines=self.build_command_preview_lines(
|
||||
env=env,
|
||||
summary_lines=summary_lines,
|
||||
command_args=command_args,
|
||||
append_yes=append_yes,
|
||||
consequence_text=consequence_text,
|
||||
),
|
||||
append_yes=append_yes,
|
||||
refresh_view=refresh_view,
|
||||
)
|
||||
|
||||
def build_external_action(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
label: str,
|
||||
command_args: tuple[str, ...],
|
||||
env: str,
|
||||
summary_lines: list[str],
|
||||
preview_title: str | None = None,
|
||||
consequence_text: str = TUI_COPY.build_action_consequence_text('wizard'),
|
||||
append_yes: bool = True,
|
||||
refresh_view: bool = True,
|
||||
) -> TuiActionSpec:
|
||||
"""
|
||||
构建标准外部交互动作。
|
||||
|
||||
:param action_id: 动作唯一标识
|
||||
:param label: 动作显示名称
|
||||
:param command_args: 对应 CLI 命令参数
|
||||
:param env: 当前运行环境
|
||||
:param summary_lines: 业务摘要文本
|
||||
:param preview_title: 预览标题
|
||||
:param consequence_text: 结果影响说明
|
||||
:param refresh_view: 执行后是否刷新视图
|
||||
:return: 动作定义
|
||||
"""
|
||||
return TuiActionSpec(
|
||||
action_id=action_id,
|
||||
label=label,
|
||||
command_args=command_args,
|
||||
preview_title=preview_title or label,
|
||||
preview_lines=self.build_external_command_preview_lines(
|
||||
env=env,
|
||||
summary_lines=summary_lines,
|
||||
command_args=command_args,
|
||||
append_yes=append_yes,
|
||||
consequence_text=consequence_text,
|
||||
),
|
||||
execution_mode='external',
|
||||
append_yes=append_yes,
|
||||
refresh_view=refresh_view,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiActionTemplate:
|
||||
"""
|
||||
TUI 动作模板。
|
||||
|
||||
该模板将命令参数构建、摘要构建与执行模式收敛为统一对象,
|
||||
使动作注册层可以直接声明“某个槽位对应哪类动作”。
|
||||
|
||||
:param action_id: 动作唯一标识
|
||||
:param label: 动作显示名称
|
||||
:param command_builder: 命令参数构建器
|
||||
:param summary_builder: 预览摘要构建器
|
||||
:param action_id_builder: 动态动作标识构建器
|
||||
:param label_builder: 动态动作标题构建器
|
||||
:param preview_title: 预览标题
|
||||
:param consequence_text: 结果影响说明
|
||||
:param execution_mode: 执行模式
|
||||
:param append_yes: 是否在命令执行与预览中自动追加 `--yes`
|
||||
:param refresh_view: 执行后是否刷新视图
|
||||
:param preview_env_override: 预览中展示的环境名覆盖值
|
||||
"""
|
||||
|
||||
action_id: str
|
||||
label: str
|
||||
command_builder: ActionCommandBuilder
|
||||
summary_builder: ActionSummaryBuilder
|
||||
action_id_builder: ActionTextBuilder | None = None
|
||||
label_builder: ActionTextBuilder | None = None
|
||||
preview_title: str | None = None
|
||||
consequence_text: str = TUI_COPY.build_action_consequence_text('preview')
|
||||
execution_mode: ActionExecutionMode = 'nested_json'
|
||||
append_yes: bool = True
|
||||
refresh_view: bool = True
|
||||
preview_env_override: str | None = None
|
||||
|
||||
def build(
|
||||
self,
|
||||
*,
|
||||
record: BrowserRecordSnapshot | None,
|
||||
env: str,
|
||||
spec_factory: TuiActionSpecFactory,
|
||||
) -> TuiActionSpec | None:
|
||||
"""
|
||||
根据当前页面上下文构建动作定义。
|
||||
|
||||
:param record: 当前选中记录
|
||||
:param env: 当前运行环境
|
||||
:param spec_factory: 动作规格构建器
|
||||
:return: 动作定义
|
||||
"""
|
||||
command_args = self.command_builder(record, env)
|
||||
if command_args is None:
|
||||
return None
|
||||
preview_env = self.preview_env_override or env
|
||||
summary_lines = self.summary_builder(record, env)
|
||||
action_id = self.action_id_builder(record, env) if self.action_id_builder is not None else self.action_id
|
||||
label = self.label_builder(record, env) if self.label_builder is not None else self.label
|
||||
if self.execution_mode == 'external':
|
||||
return spec_factory.build_external_action(
|
||||
action_id=action_id,
|
||||
label=label,
|
||||
command_args=command_args,
|
||||
env=preview_env,
|
||||
summary_lines=summary_lines,
|
||||
preview_title=self.preview_title,
|
||||
consequence_text=self.consequence_text,
|
||||
append_yes=self.append_yes,
|
||||
refresh_view=self.refresh_view,
|
||||
)
|
||||
return spec_factory.build_nested_json_action(
|
||||
action_id=action_id,
|
||||
label=label,
|
||||
command_args=command_args,
|
||||
env=preview_env,
|
||||
summary_lines=summary_lines,
|
||||
preview_title=self.preview_title,
|
||||
consequence_text=self.consequence_text,
|
||||
append_yes=self.append_yes,
|
||||
refresh_view=self.refresh_view,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiActionTemplateSupport:
|
||||
"""
|
||||
TUI 动作模板共享构建支持。
|
||||
|
||||
该对象集中封装多个动作模板都会复用的记录解析与摘要拼装逻辑,
|
||||
避免这些基础能力继续散落在模块级函数中。
|
||||
|
||||
:param spec_factory: 动作规格构建器
|
||||
"""
|
||||
|
||||
spec_factory: TuiActionSpecFactory
|
||||
|
||||
def build_scope_purpose_summary(self, scope: str, purpose: str) -> list[str]:
|
||||
"""
|
||||
构建通用的作用范围与目的摘要。
|
||||
|
||||
:param scope: 作用范围说明
|
||||
:param purpose: 目的说明
|
||||
:return: 预览摘要
|
||||
"""
|
||||
return [
|
||||
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('scope'), scope),
|
||||
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('purpose'), purpose),
|
||||
]
|
||||
|
||||
def extract_job_id(self, record: BrowserRecordSnapshot | None) -> str | None:
|
||||
"""
|
||||
从任务记录中提取任务 ID。
|
||||
|
||||
:param record: 当前记录
|
||||
:return: 任务 ID
|
||||
"""
|
||||
if record is None:
|
||||
return None
|
||||
job_id = self.spec_factory.parse_record_suffix(record, 'job')
|
||||
return job_id if job_id.isdigit() else None
|
||||
|
||||
@staticmethod
|
||||
def require_record_title(record: BrowserRecordSnapshot | None) -> str | None:
|
||||
"""
|
||||
提取记录标题作为命令目标。
|
||||
|
||||
:param record: 当前记录
|
||||
:return: 标题文本
|
||||
"""
|
||||
if record is None:
|
||||
return None
|
||||
title = str(record.title).strip()
|
||||
return title or None
|
||||
@@ -0,0 +1,121 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cli.tui.actions.models import TuiActionResult, TuiActionSpec
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiActionExecutionService:
|
||||
"""
|
||||
TUI 动作执行服务。
|
||||
|
||||
该对象负责统一封装 nested JSON 动作、外部交互动作
|
||||
与结果文本行的收口逻辑。
|
||||
"""
|
||||
|
||||
def execute(self, spec: TuiActionSpec, env: str) -> TuiActionResult:
|
||||
"""
|
||||
执行指定 TUI 动作。
|
||||
|
||||
:param spec: 动作定义
|
||||
:param env: 当前运行环境
|
||||
:return: 动作执行结果
|
||||
"""
|
||||
command_arguments = [
|
||||
*spec.command_args,
|
||||
f'--env={env}',
|
||||
'--output=json',
|
||||
]
|
||||
if spec.append_yes:
|
||||
command_arguments.append('--yes')
|
||||
payload = NESTED_CLI_SUPPORT.run(*command_arguments, parse_json=True).payload
|
||||
return TuiActionResult(spec=spec, payload=payload)
|
||||
|
||||
def execute_external(self, spec: TuiActionSpec) -> TuiActionResult:
|
||||
"""
|
||||
在当前终端中执行交互式 TUI 动作。
|
||||
|
||||
:param spec: 动作定义
|
||||
:return: 动作执行结果
|
||||
"""
|
||||
completed = NESTED_CLI_SUPPORT.run_live(*spec.command_args)
|
||||
if completed.returncode == 0:
|
||||
message = '外部交互命令已执行完成'
|
||||
else:
|
||||
message = f'外部交互命令执行失败,退出码 {completed.returncode}'
|
||||
return TuiActionResult(
|
||||
spec=spec,
|
||||
external_exit_code=completed.returncode,
|
||||
external_message=message,
|
||||
)
|
||||
|
||||
def build_result_lines(self, result: TuiActionResult) -> list[str]:
|
||||
"""
|
||||
构建动作结果详情文本。
|
||||
|
||||
:param result: 动作执行结果
|
||||
:return: 结果文本行
|
||||
"""
|
||||
payload = result.payload if isinstance(result.payload, dict) else {}
|
||||
lines = [
|
||||
TUI_COPY.build_action_result_message_line(
|
||||
TUI_COPY.build_action_result_field_label('name'),
|
||||
result.spec.label,
|
||||
),
|
||||
TUI_COPY.build_action_result_message_line(
|
||||
TUI_COPY.build_action_result_field_label('outcome'),
|
||||
TUI_COPY.build_action_result_field_label('success')
|
||||
if result.ok
|
||||
else TUI_COPY.build_action_result_field_label('fail'),
|
||||
),
|
||||
TUI_COPY.build_action_result_message_line(
|
||||
TUI_COPY.build_action_result_field_label('summary'),
|
||||
SHELL_TEXT_FORMATTER.truncate_text(result.message, 88),
|
||||
),
|
||||
]
|
||||
service_message = str(payload.get('serviceMessage', '') or '').strip()
|
||||
if service_message:
|
||||
lines.append(
|
||||
TUI_COPY.build_action_result_message_line(
|
||||
TUI_COPY.build_action_result_field_label('service'),
|
||||
SHELL_TEXT_FORMATTER.truncate_text(service_message, 88),
|
||||
)
|
||||
)
|
||||
if payload.get('hint'):
|
||||
lines.append(
|
||||
TUI_COPY.build_action_result_message_line(
|
||||
TUI_COPY.build_action_result_field_label('hint'),
|
||||
SHELL_TEXT_FORMATTER.truncate_text(str(payload.get('hint', '') or ''), 88),
|
||||
)
|
||||
)
|
||||
if payload.get('count') is not None:
|
||||
lines.append(
|
||||
TUI_COPY.build_action_result_message_line(
|
||||
TUI_COPY.build_action_result_field_label('count'),
|
||||
str(payload.get('count')),
|
||||
)
|
||||
)
|
||||
if payload.get('jobId') is not None:
|
||||
lines.append(
|
||||
TUI_COPY.build_action_result_message_line(
|
||||
TUI_COPY.build_action_result_field_label('job_id'),
|
||||
str(payload.get('jobId')),
|
||||
)
|
||||
)
|
||||
if result.external_exit_code is not None:
|
||||
lines.append(
|
||||
TUI_COPY.build_action_result_message_line(
|
||||
TUI_COPY.build_action_result_field_label('exit_code'),
|
||||
str(result.external_exit_code),
|
||||
)
|
||||
)
|
||||
operation_label = str(payload.get('operationLabel', '') or '').strip()
|
||||
if operation_label and operation_label != result.spec.label:
|
||||
lines.append(
|
||||
TUI_COPY.build_action_result_message_line(
|
||||
TUI_COPY.build_action_result_field_label('operation'),
|
||||
SHELL_TEXT_FORMATTER.truncate_text(operation_label, 64),
|
||||
)
|
||||
)
|
||||
return lines
|
||||
@@ -0,0 +1,83 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cli.tui.actions.builders import TuiActionTemplate, TuiActionTemplateSupport
|
||||
from cli.tui.adapters.models import BrowserRecordSnapshot
|
||||
from cli.tui.copy import TUI_COPY
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CacheActionTemplateFactory:
|
||||
"""
|
||||
缓存页动作模板工厂。
|
||||
|
||||
该对象负责生成缓存浏览页相关动作模板,统一缓存清理向导和预热动作
|
||||
的命令与预览摘要构建。
|
||||
|
||||
:param support: 动作模板共享构建支持
|
||||
"""
|
||||
|
||||
support: TuiActionTemplateSupport
|
||||
|
||||
def build_clear_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建缓存清理向导命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
cache_name = record.title if record is not None else ''
|
||||
return (
|
||||
'wizard',
|
||||
'cache-clear',
|
||||
'--output=text',
|
||||
f'--default-env={env}',
|
||||
'--default-mode=cache-name',
|
||||
f'--default-cache-name={cache_name}',
|
||||
'--default-dry-run',
|
||||
)
|
||||
|
||||
def build_clear_summary(self, record: BrowserRecordSnapshot | None, env: str) -> list[str]:
|
||||
"""
|
||||
构建缓存清理向导摘要。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 预览摘要
|
||||
"""
|
||||
del record, env
|
||||
return self.support.build_scope_purpose_summary(
|
||||
'当前环境缓存清理流程',
|
||||
'进入向导后确认缓存名、键前缀和 dry-run 范围',
|
||||
)
|
||||
|
||||
def create_warmup_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建缓存预热动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='cache-warmup',
|
||||
label=TUI_COPY.build_action_label('cache_warmup'),
|
||||
command_builder=lambda record, env: ('cache', 'warmup'),
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
TUI_COPY.build_action_scope_label('cache_warmup'),
|
||||
TUI_COPY.build_action_purpose_label('cache_warmup'),
|
||||
),
|
||||
)
|
||||
|
||||
def create_clear_wizard_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建缓存清理向导动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='wizard-cache-clear',
|
||||
label=TUI_COPY.build_action_label('cache_clear_wizard'),
|
||||
command_builder=self.build_clear_command,
|
||||
summary_builder=self.build_clear_summary,
|
||||
execution_mode='external',
|
||||
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
|
||||
)
|
||||
@@ -0,0 +1,254 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cli.tui.actions.builders import TuiActionTemplate, TuiActionTemplateSupport
|
||||
from cli.tui.adapters.models import BrowserRecordSnapshot
|
||||
from cli.tui.copy import TUI_COPY
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenActionTemplateFactory:
|
||||
"""
|
||||
代码生成页动作模板工厂。
|
||||
|
||||
该对象负责生成代码生成浏览页相关动作模板,统一记录标题提取、
|
||||
导出导入向导和表结构同步动作的构建逻辑。
|
||||
|
||||
:param support: 动作模板共享构建支持
|
||||
"""
|
||||
|
||||
support: TuiActionTemplateSupport
|
||||
|
||||
def build_export_wizard_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None:
|
||||
"""
|
||||
构建代码生成导出向导命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
table_name = self.support.require_record_title(record)
|
||||
if table_name is None:
|
||||
return None
|
||||
return (
|
||||
'wizard',
|
||||
'gen-export',
|
||||
'--output=text',
|
||||
f'--default-env={env}',
|
||||
f'--default-table-names={table_name}',
|
||||
'--default-mode=zip',
|
||||
'--default-dry-run',
|
||||
)
|
||||
|
||||
def build_import_wizard_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None:
|
||||
"""
|
||||
构建代码生成导入向导命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
table_name = self.support.require_record_title(record)
|
||||
if table_name is None:
|
||||
return None
|
||||
return (
|
||||
'wizard',
|
||||
'gen-import',
|
||||
'--output=text',
|
||||
f'--default-env={env}',
|
||||
f'--default-table-names={table_name}',
|
||||
'--default-dry-run',
|
||||
)
|
||||
|
||||
def build_export_dry_run_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None:
|
||||
"""
|
||||
构建代码生成导出 dry-run 命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del env
|
||||
table_name = self.support.require_record_title(record)
|
||||
if table_name is None:
|
||||
return None
|
||||
return ('gen', 'export', table_name, '--dry-run', '--mode=zip')
|
||||
|
||||
def build_sync_db_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None:
|
||||
"""
|
||||
构建代码生成表结构同步命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del env
|
||||
table_name = self.support.require_record_title(record)
|
||||
if table_name is None:
|
||||
return None
|
||||
return ('gen', 'sync-db', table_name)
|
||||
|
||||
@staticmethod
|
||||
def build_export_wizard_summary(record: BrowserRecordSnapshot | None, env: str) -> list[str]:
|
||||
"""
|
||||
构建代码生成导出向导摘要。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 预览摘要
|
||||
"""
|
||||
del env
|
||||
if record is None:
|
||||
return []
|
||||
return [
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('scope'), f'当前业务表 {record.title}'
|
||||
),
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('current_status'),
|
||||
record.summary,
|
||||
),
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('purpose'),
|
||||
'进入向导后选择业务表、输出目录和覆盖策略',
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_import_wizard_summary(record: BrowserRecordSnapshot | None, env: str) -> list[str]:
|
||||
"""
|
||||
构建代码生成导入向导摘要。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 预览摘要
|
||||
"""
|
||||
del env
|
||||
if record is None:
|
||||
return []
|
||||
return [
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('scope'), f'当前物理表 {record.title}'
|
||||
),
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('current_status'),
|
||||
record.summary,
|
||||
),
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('purpose'),
|
||||
'进入向导后确认物理表列表,并先执行 dry-run 导入预演',
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_export_dry_run_summary(record: BrowserRecordSnapshot | None, env: str) -> list[str]:
|
||||
"""
|
||||
构建代码生成导出预演摘要。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 预览摘要
|
||||
"""
|
||||
del env
|
||||
if record is None:
|
||||
return []
|
||||
return [
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('scope'),
|
||||
TUI_COPY.build_action_scope_label('gen_export_dry_run'),
|
||||
),
|
||||
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job'), record.title),
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('current_status'),
|
||||
record.summary,
|
||||
),
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('purpose'),
|
||||
TUI_COPY.build_action_purpose_label('gen_export_dry_run'),
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_sync_db_summary(record: BrowserRecordSnapshot | None, env: str) -> list[str]:
|
||||
"""
|
||||
构建代码生成表结构同步摘要。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 预览摘要
|
||||
"""
|
||||
del env
|
||||
if record is None:
|
||||
return []
|
||||
return [
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('scope'),
|
||||
TUI_COPY.build_action_scope_label('gen_sync_db'),
|
||||
),
|
||||
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job'), record.title),
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('current_status'),
|
||||
record.summary,
|
||||
),
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('purpose'),
|
||||
TUI_COPY.build_action_purpose_label('gen_sync_db'),
|
||||
),
|
||||
]
|
||||
|
||||
def create_export_wizard_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建代码生成导出向导动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='wizard-gen-export',
|
||||
label=TUI_COPY.build_action_label('gen_export_wizard'),
|
||||
command_builder=self.build_export_wizard_command,
|
||||
summary_builder=self.build_export_wizard_summary,
|
||||
execution_mode='external',
|
||||
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
|
||||
refresh_view=False,
|
||||
)
|
||||
|
||||
def create_import_wizard_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建代码生成导入向导动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='wizard-gen-import',
|
||||
label=TUI_COPY.build_action_label('gen_import_wizard'),
|
||||
command_builder=self.build_import_wizard_command,
|
||||
summary_builder=self.build_import_wizard_summary,
|
||||
execution_mode='external',
|
||||
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
|
||||
refresh_view=False,
|
||||
)
|
||||
|
||||
def create_export_dry_run_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建代码生成导出预演动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='gen-export-dry-run',
|
||||
label=TUI_COPY.build_action_label('gen_export_dry_run'),
|
||||
command_builder=self.build_export_dry_run_command,
|
||||
summary_builder=self.build_export_dry_run_summary,
|
||||
)
|
||||
|
||||
def create_sync_db_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建代码生成表结构同步动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='gen-sync-db',
|
||||
label=TUI_COPY.build_action_label('gen_sync_db'),
|
||||
command_builder=self.build_sync_db_command,
|
||||
summary_builder=self.build_sync_db_summary,
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cli.tui.actions.builders import TuiActionTemplate, TuiActionTemplateSupport
|
||||
from cli.tui.adapters.models import BrowserRecordSnapshot
|
||||
from cli.tui.copy import TUI_COPY
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobActionTemplateFactory:
|
||||
"""
|
||||
任务页动作模板工厂。
|
||||
|
||||
该对象负责生成任务浏览页相关动作模板,收口任务 ID 解析、
|
||||
暂停/恢复切换和摘要拼装逻辑。
|
||||
|
||||
:param support: 动作模板共享构建支持
|
||||
"""
|
||||
|
||||
support: TuiActionTemplateSupport
|
||||
|
||||
def build_run_once_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None:
|
||||
"""
|
||||
构建任务执行一次命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del env
|
||||
job_id = self.support.extract_job_id(record)
|
||||
if job_id is None:
|
||||
return None
|
||||
return ('job', 'run-once', job_id)
|
||||
|
||||
def build_run_once_summary(self, record: BrowserRecordSnapshot | None, env: str) -> list[str]:
|
||||
"""
|
||||
构建任务执行一次动作摘要。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 预览摘要
|
||||
"""
|
||||
del env
|
||||
if record is None:
|
||||
return []
|
||||
job_id = self.support.extract_job_id(record) or ''
|
||||
return [
|
||||
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job'), record.title),
|
||||
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job_id'), job_id),
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('current_status'),
|
||||
record.summary,
|
||||
),
|
||||
]
|
||||
|
||||
def build_toggle_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None:
|
||||
"""
|
||||
构建任务暂停或恢复命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del env
|
||||
if record is None:
|
||||
return None
|
||||
job_id = self.support.extract_job_id(record)
|
||||
if job_id is None:
|
||||
return None
|
||||
if record.status == 'warn':
|
||||
return ('job', 'resume', job_id)
|
||||
return ('job', 'pause', job_id)
|
||||
|
||||
def build_toggle_summary(self, record: BrowserRecordSnapshot | None, env: str) -> list[str]:
|
||||
"""
|
||||
构建任务暂停或恢复动作摘要。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 预览摘要
|
||||
"""
|
||||
del env
|
||||
if record is None:
|
||||
return []
|
||||
job_id = self.support.extract_job_id(record) or ''
|
||||
label = (
|
||||
TUI_COPY.build_action_label('job_resume')
|
||||
if record.status == 'warn'
|
||||
else TUI_COPY.build_action_label('job_pause')
|
||||
)
|
||||
return [
|
||||
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job'), record.title),
|
||||
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job_id'), job_id),
|
||||
TUI_COPY.build_labeled_value_line(
|
||||
TUI_COPY.build_action_preview_field_label('current_status'),
|
||||
record.summary,
|
||||
),
|
||||
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('target_action'), label),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_toggle_action_id(record: BrowserRecordSnapshot | None, env: str) -> str:
|
||||
"""
|
||||
构建任务暂停或恢复动作标识。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 动作标识
|
||||
"""
|
||||
del env
|
||||
if record is not None and record.status == 'warn':
|
||||
return 'job-resume'
|
||||
return 'job-pause'
|
||||
|
||||
@staticmethod
|
||||
def build_toggle_label(record: BrowserRecordSnapshot | None, env: str) -> str:
|
||||
"""
|
||||
构建任务暂停或恢复动作标题。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 动作标题
|
||||
"""
|
||||
del env
|
||||
if record is not None and record.status == 'warn':
|
||||
return TUI_COPY.build_action_label('job_resume')
|
||||
return TUI_COPY.build_action_label('job_pause')
|
||||
|
||||
def create_run_once_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建任务执行一次动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='job-run-once',
|
||||
label=TUI_COPY.build_action_label('job_run_once'),
|
||||
command_builder=self.build_run_once_command,
|
||||
summary_builder=self.build_run_once_summary,
|
||||
)
|
||||
|
||||
def create_toggle_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建任务暂停或恢复动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='job-toggle',
|
||||
label=TUI_COPY.build_action_label('job_pause'),
|
||||
command_builder=self.build_toggle_command,
|
||||
summary_builder=self.build_toggle_summary,
|
||||
action_id_builder=self.build_toggle_action_id,
|
||||
label_builder=self.build_toggle_label,
|
||||
)
|
||||
|
||||
def create_sync_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建任务同步动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='job-sync',
|
||||
label=TUI_COPY.build_action_label('job_sync'),
|
||||
command_builder=lambda record, env: ('job', 'sync'),
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
TUI_COPY.build_action_scope_label('job_sync'),
|
||||
TUI_COPY.build_action_purpose_label('job_sync'),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,385 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cli.tui.actions.builders import TuiActionTemplate, TuiActionTemplateSupport
|
||||
from cli.tui.adapters.models import BrowserRecordSnapshot
|
||||
from cli.tui.copy import TUI_COPY
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaticActionTemplateFactory:
|
||||
"""
|
||||
静态页面动作模板工厂。
|
||||
|
||||
该对象负责生成不依赖当前记录内容的页面动作模板,统一详情页和
|
||||
部分浏览页的固定动作定义。
|
||||
|
||||
:param support: 动作模板共享构建支持
|
||||
"""
|
||||
|
||||
support: TuiActionTemplateSupport
|
||||
|
||||
@staticmethod
|
||||
def build_job_sync_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建任务同步命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record, env
|
||||
return ('job', 'sync')
|
||||
|
||||
@staticmethod
|
||||
def build_config_sync_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建配置同步命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record, env
|
||||
return ('config', 'sync-cache')
|
||||
|
||||
@staticmethod
|
||||
def build_cache_warmup_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建缓存预热命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record, env
|
||||
return ('cache', 'warmup')
|
||||
|
||||
@staticmethod
|
||||
def build_db_upgrade_wizard_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建数据库升级向导命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record
|
||||
return (
|
||||
'wizard',
|
||||
'db-upgrade',
|
||||
'--output=text',
|
||||
f'--default-env={env}',
|
||||
'--default-revision=head',
|
||||
'--default-dry-run',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_db_init_dry_run_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建数据库初始化预演命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record, env
|
||||
return ('db', 'init', '--dry-run')
|
||||
|
||||
@staticmethod
|
||||
def build_app_run_wizard_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建应用启动向导命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record, env
|
||||
return ('wizard', 'app-run')
|
||||
|
||||
@staticmethod
|
||||
def build_completion_install_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建补全安装命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record, env
|
||||
return ('completion', 'install', '--activate')
|
||||
|
||||
@staticmethod
|
||||
def build_prod_check_wizard_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建生产巡检向导命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record
|
||||
return (
|
||||
'wizard',
|
||||
'prod-check',
|
||||
'--output=text',
|
||||
f'--default-env={env}',
|
||||
'--default-include-config',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_crypto_rotate_dry_run_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建加密轮换预演命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record, env
|
||||
return ('crypto', 'rotate', '--dry-run')
|
||||
|
||||
@staticmethod
|
||||
def build_app_run_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建应用直接启动命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record
|
||||
return ('app', 'run', f'--env={env}')
|
||||
|
||||
@staticmethod
|
||||
def build_crypto_keygen_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建加密密钥生成命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record
|
||||
return ('crypto', 'keygen', f'--env={env}', '--output=text')
|
||||
|
||||
@staticmethod
|
||||
def build_ops_ping_db_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建数据库探活命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record, env
|
||||
return ('ops', 'ping-db')
|
||||
|
||||
@staticmethod
|
||||
def build_ops_ping_redis_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
|
||||
"""
|
||||
构建 Redis 探活命令参数。
|
||||
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:return: 命令参数
|
||||
"""
|
||||
del record, env
|
||||
return ('ops', 'ping-redis')
|
||||
|
||||
def create_config_sync_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建配置同步动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='config-sync-cache',
|
||||
label=TUI_COPY.build_action_label('config_sync'),
|
||||
command_builder=self.build_config_sync_command,
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
TUI_COPY.build_action_scope_label('config_sync'),
|
||||
TUI_COPY.build_action_purpose_label('config_sync'),
|
||||
),
|
||||
)
|
||||
|
||||
def create_db_upgrade_wizard_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建数据库升级向导动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='wizard-db-upgrade',
|
||||
label=TUI_COPY.build_action_label('db_upgrade_wizard'),
|
||||
command_builder=self.build_db_upgrade_wizard_command,
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
'当前环境数据库升级流程',
|
||||
'进入向导后确认目标 revision、环境和 dry-run 预演',
|
||||
),
|
||||
execution_mode='external',
|
||||
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
|
||||
)
|
||||
|
||||
def create_db_init_dry_run_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建数据库初始化预演动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='db-init-dry-run',
|
||||
label=TUI_COPY.build_action_label('db_init_dry_run'),
|
||||
command_builder=self.build_db_init_dry_run_command,
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
TUI_COPY.build_action_scope_label('db_init_dry_run'),
|
||||
TUI_COPY.build_action_purpose_label('db_init_dry_run'),
|
||||
),
|
||||
append_yes=False,
|
||||
)
|
||||
|
||||
def create_app_run_wizard_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建应用启动向导动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='wizard-app-run',
|
||||
label=TUI_COPY.build_action_label('app_run_wizard'),
|
||||
command_builder=self.build_app_run_wizard_command,
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
'当前环境应用启动流程',
|
||||
'进入向导后确认环境,并决定是否先执行启动前检查',
|
||||
),
|
||||
execution_mode='external',
|
||||
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
|
||||
refresh_view=False,
|
||||
)
|
||||
|
||||
def create_completion_install_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建补全安装动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='completion-install',
|
||||
label=TUI_COPY.build_action_label('completion_install'),
|
||||
command_builder=self.build_completion_install_command,
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
TUI_COPY.build_action_scope_label('completion_install'),
|
||||
TUI_COPY.build_action_purpose_label('completion_install'),
|
||||
),
|
||||
execution_mode='external',
|
||||
consequence_text=TUI_COPY.build_action_consequence_text('external'),
|
||||
refresh_view=False,
|
||||
preview_env_override='-',
|
||||
)
|
||||
|
||||
def create_prod_check_wizard_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建生产巡检向导动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='wizard-prod-check',
|
||||
label=TUI_COPY.build_action_label('prod_check_wizard'),
|
||||
command_builder=self.build_prod_check_wizard_command,
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
'当前环境生产巡检流程',
|
||||
'进入向导后统一检查数据库、缓存和运行配置',
|
||||
),
|
||||
execution_mode='external',
|
||||
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
|
||||
)
|
||||
|
||||
def create_crypto_rotate_dry_run_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建加密轮换预演动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='crypto-rotate-dry-run',
|
||||
label=TUI_COPY.build_action_label('crypto_rotate_dry_run'),
|
||||
command_builder=self.build_crypto_rotate_dry_run_command,
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
'当前环境传输加密轮换预演',
|
||||
'仅生成轮换辅助结果,不直接写入新密钥配置',
|
||||
),
|
||||
append_yes=False,
|
||||
)
|
||||
|
||||
def create_app_run_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建应用直接启动动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='app-run',
|
||||
label=TUI_COPY.build_action_label('app_run'),
|
||||
command_builder=self.build_app_run_command,
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
'当前环境应用直接启动流程',
|
||||
'确认后会在当前终端直接启动当前 FastAPI 应用,并持续占用终端会话',
|
||||
),
|
||||
execution_mode='external',
|
||||
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
|
||||
refresh_view=False,
|
||||
)
|
||||
|
||||
def create_crypto_keygen_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建密钥生成动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='crypto-keygen',
|
||||
label=TUI_COPY.build_action_label('crypto_keygen'),
|
||||
command_builder=self.build_crypto_keygen_command,
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
'当前环境新密钥生成流程',
|
||||
'在终端中直接输出新密钥材料和环境变量补丁建议',
|
||||
),
|
||||
execution_mode='external',
|
||||
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
|
||||
refresh_view=False,
|
||||
)
|
||||
|
||||
def create_ops_ping_db_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建数据库探活动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='ops-ping-db',
|
||||
label=TUI_COPY.build_action_label('ops_ping_db'),
|
||||
command_builder=self.build_ops_ping_db_command,
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
TUI_COPY.build_action_scope_label('ops_ping_db'),
|
||||
TUI_COPY.build_action_purpose_label('ops_ping_db'),
|
||||
),
|
||||
append_yes=False,
|
||||
)
|
||||
|
||||
def create_ops_ping_redis_template(self) -> TuiActionTemplate:
|
||||
"""
|
||||
创建 Redis 探活动作模板。
|
||||
|
||||
:return: 动作模板
|
||||
"""
|
||||
return TuiActionTemplate(
|
||||
action_id='ops-ping-redis',
|
||||
label=TUI_COPY.build_action_label('ops_ping_redis'),
|
||||
command_builder=self.build_ops_ping_redis_command,
|
||||
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
|
||||
TUI_COPY.build_action_scope_label('ops_ping_redis'),
|
||||
TUI_COPY.build_action_purpose_label('ops_ping_redis'),
|
||||
),
|
||||
append_yes=False,
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from cli.tui.adapters.models import TUI_ADAPTER_MODEL_RENDERER, BrowserRecordSnapshot
|
||||
|
||||
ActionSlot = Literal['primary', 'secondary', 'global', 'utility']
|
||||
ActionExecutionMode = Literal['nested_json', 'external']
|
||||
ActionCommandBuilder = Callable[[BrowserRecordSnapshot | None, str], tuple[str, ...] | None]
|
||||
ActionSummaryBuilder = Callable[[BrowserRecordSnapshot | None, str], list[str]]
|
||||
ActionTextBuilder = Callable[[BrowserRecordSnapshot | None, str], str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiActionSpec:
|
||||
"""
|
||||
TUI 低风险动作定义。
|
||||
|
||||
:param action_id: 动作唯一标识
|
||||
:param label: 动作显示名称
|
||||
:param command_args: 对应 CLI 命令参数
|
||||
:param preview_title: 预览弹窗标题
|
||||
:param preview_lines: 预览摘要文本
|
||||
:param append_yes: 执行 nested JSON 动作时是否自动追加 `--yes`
|
||||
:param refresh_view: 执行完成后是否刷新当前页面
|
||||
"""
|
||||
|
||||
action_id: str
|
||||
label: str
|
||||
command_args: tuple[str, ...]
|
||||
preview_title: str
|
||||
preview_lines: list[str]
|
||||
execution_mode: ActionExecutionMode = 'nested_json'
|
||||
append_yes: bool = True
|
||||
refresh_view: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiActionResult:
|
||||
"""
|
||||
TUI 动作执行结果。
|
||||
|
||||
:param spec: 动作定义
|
||||
:param payload: CLI JSON 负载
|
||||
"""
|
||||
|
||||
spec: TuiActionSpec
|
||||
payload: dict[str, object] | None = None
|
||||
external_exit_code: int | None = None
|
||||
external_message: str | None = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""
|
||||
判断动作是否执行成功。
|
||||
|
||||
:return: 是否成功
|
||||
"""
|
||||
if self.external_exit_code is not None:
|
||||
return self.external_exit_code == 0
|
||||
return bool(isinstance(self.payload, dict) and self.payload.get('ok', False))
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
"""
|
||||
获取动作结果摘要。
|
||||
|
||||
:return: 结果摘要
|
||||
"""
|
||||
if self.external_message is not None:
|
||||
return self.external_message
|
||||
return TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(self.payload)
|
||||
@@ -0,0 +1,110 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cli.tui.actions.models import TuiActionResult
|
||||
from cli.tui.actions.registry import TuiActionRegistry
|
||||
from cli.tui.adapters.models import BrowserRecordSnapshot
|
||||
from cli.tui.capabilities import TuiCapabilityRegistry
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.keymaps import TUI_KEYMAP_REGISTRY
|
||||
from cli.utils import SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiActionPresentationService:
|
||||
"""
|
||||
TUI 动作展示服务。
|
||||
|
||||
该对象负责动作提示、动作列表和动作结果通知等展示层文本构建,
|
||||
避免继续保留模块级桥接函数。
|
||||
"""
|
||||
|
||||
capability_registry: TuiCapabilityRegistry
|
||||
action_registry: TuiActionRegistry
|
||||
|
||||
def build_browser_action_hint(self, view_key: str) -> str:
|
||||
"""
|
||||
构建浏览页动作提示文本。
|
||||
|
||||
:param view_key: 当前页面视图标识
|
||||
:return: 动作提示文本
|
||||
"""
|
||||
capability_labels = [
|
||||
f'[{capability.key}] {capability.hint_label}'
|
||||
for capability in self.capability_registry.get_browser_capabilities(view_key)
|
||||
]
|
||||
interaction_text = TUI_COPY.build_browser_action_hint_text(
|
||||
view_key, TUI_KEYMAP_REGISTRY.browser_interaction_hint
|
||||
)
|
||||
return TUI_COPY.build_capability_hint_text(
|
||||
capability_labels,
|
||||
interaction_text,
|
||||
fallback='{interaction_hint}',
|
||||
)
|
||||
|
||||
def build_detail_action_hint(self, view_key: str) -> str:
|
||||
"""
|
||||
构建详情页动作提示文本。
|
||||
|
||||
:param view_key: 当前页面视图标识
|
||||
:return: 动作提示文本
|
||||
"""
|
||||
capability_labels = [
|
||||
f'[{capability.key}] {capability.hint_label}'
|
||||
for capability in self.capability_registry.get_detail_capabilities(view_key)
|
||||
]
|
||||
interaction_text = TUI_COPY.build_detail_action_hint_text(
|
||||
view_key, TUI_KEYMAP_REGISTRY.browser_interaction_hint
|
||||
)
|
||||
return TUI_COPY.build_capability_hint_text(
|
||||
capability_labels,
|
||||
interaction_text,
|
||||
fallback='{interaction_hint}',
|
||||
)
|
||||
|
||||
def build_browser_action_lines(
|
||||
self,
|
||||
*,
|
||||
view_key: str,
|
||||
record: BrowserRecordSnapshot | None,
|
||||
env: str,
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建当前页面可执行动作列表。
|
||||
|
||||
:param view_key: 当前页面视图标识
|
||||
:param record: 当前选中记录
|
||||
:param env: 当前运行环境
|
||||
:return: 动作文本行
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for capability in self.capability_registry.get_browser_capabilities(view_key):
|
||||
slot = capability.slot # type: ignore[assignment]
|
||||
action = self.action_registry.resolve_browser_action(
|
||||
view_key=view_key,
|
||||
slot=slot,
|
||||
record=record,
|
||||
env=env,
|
||||
)
|
||||
if action is None:
|
||||
continue
|
||||
lines.append(TUI_COPY.build_action_line(capability.key, action.label))
|
||||
return lines or [TUI_COPY.build_action_empty_line()]
|
||||
|
||||
@staticmethod
|
||||
def build_action_result_message(result: TuiActionResult) -> str:
|
||||
"""
|
||||
构建动作执行结果通知文本。
|
||||
|
||||
:param result: 动作执行结果
|
||||
:return: 通知文本
|
||||
"""
|
||||
outcome = (
|
||||
TUI_COPY.build_action_result_field_label('success')
|
||||
if result.ok
|
||||
else TUI_COPY.build_action_result_field_label('fail')
|
||||
)
|
||||
return TUI_COPY.build_action_result_toast(
|
||||
result.spec.label,
|
||||
outcome,
|
||||
SHELL_TEXT_FORMATTER.truncate_text(result.message, 72),
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cli.tui.actions.builders import TuiActionSpecFactory, TuiActionTemplate
|
||||
from cli.tui.actions.models import ActionSlot, TuiActionSpec
|
||||
from cli.tui.adapters.models import BrowserRecordSnapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiActionRegistry:
|
||||
"""
|
||||
TUI 动作注册表。
|
||||
|
||||
该注册表负责集中维护浏览页和详情页的动作解析器,从而将
|
||||
原本散落的条件分发收敛为可声明的映射关系。
|
||||
|
||||
:param browser_resolvers: 浏览页动作槽位解析器映射
|
||||
:param detail_resolvers: 详情页动作槽位解析器映射
|
||||
"""
|
||||
|
||||
browser_resolvers: dict[str, TuiActionSlotResolver]
|
||||
detail_resolvers: dict[str, TuiActionSlotResolver]
|
||||
|
||||
def resolve_browser_action(
|
||||
self,
|
||||
*,
|
||||
view_key: str,
|
||||
slot: ActionSlot,
|
||||
record: BrowserRecordSnapshot | None,
|
||||
env: str,
|
||||
) -> TuiActionSpec | None:
|
||||
"""
|
||||
解析浏览页动作。
|
||||
|
||||
:param view_key: 当前页面视图标识
|
||||
:param slot: 动作槽位
|
||||
:param record: 当前选中记录
|
||||
:param env: 当前运行环境
|
||||
:return: 动作定义
|
||||
"""
|
||||
resolver = self.browser_resolvers.get(view_key)
|
||||
if resolver is None:
|
||||
return None
|
||||
return resolver.resolve(slot=slot, record=record, env=env)
|
||||
|
||||
def resolve_detail_action(
|
||||
self,
|
||||
*,
|
||||
view_key: str,
|
||||
slot: ActionSlot,
|
||||
env: str,
|
||||
) -> TuiActionSpec | None:
|
||||
"""
|
||||
解析详情页动作。
|
||||
|
||||
:param view_key: 当前页面视图标识
|
||||
:param slot: 动作槽位
|
||||
:param env: 当前运行环境
|
||||
:return: 动作定义
|
||||
"""
|
||||
resolver = self.detail_resolvers.get(view_key)
|
||||
if resolver is None:
|
||||
return None
|
||||
return resolver.resolve(slot=slot, record=None, env=env)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiActionSlotResolver:
|
||||
"""
|
||||
TUI 动作槽位解析器。
|
||||
|
||||
该对象将一个页面支持的槽位动作集中定义为映射关系,避免继续在
|
||||
`resolve_*_action` 中堆叠条件分支。
|
||||
|
||||
:param slot_templates: 槽位到动作模板的映射
|
||||
:param spec_factory: 动作规格构建器
|
||||
"""
|
||||
|
||||
slot_templates: dict[ActionSlot, TuiActionTemplate]
|
||||
spec_factory: TuiActionSpecFactory
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
*,
|
||||
slot: ActionSlot,
|
||||
record: BrowserRecordSnapshot | None,
|
||||
env: str,
|
||||
) -> TuiActionSpec | None:
|
||||
"""
|
||||
解析指定槽位的动作定义。
|
||||
|
||||
:param slot: 动作槽位
|
||||
:param record: 当前选中记录
|
||||
:param env: 当前运行环境
|
||||
:return: 动作定义
|
||||
"""
|
||||
template = self.slot_templates.get(slot)
|
||||
if template is None:
|
||||
return None
|
||||
return template.build(record=record, env=env, spec_factory=self.spec_factory)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,898 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from textual.app import App
|
||||
from textual.css.query import NoMatches
|
||||
|
||||
from cli.tui.adapters import (
|
||||
TUI_SNAPSHOT_COLLECTOR_REGISTRY,
|
||||
BrowserPageSnapshot,
|
||||
DashboardSnapshot,
|
||||
DetailPageSnapshot,
|
||||
)
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.keymaps import TUI_KEYMAP_REGISTRY
|
||||
from cli.tui.screens import BrowserScreen, DashboardScreen, DetailScreen
|
||||
from cli.tui.widgets import NavigationItem, WorkspaceSidebar
|
||||
|
||||
SnapshotCollector = Callable[['ShierfengTuiApp'], BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiViewSpec:
|
||||
"""
|
||||
TUI 视图规格定义。
|
||||
|
||||
:param view_key: 视图标识
|
||||
:param include_query: 是否注入搜索词
|
||||
:param include_filter: 是否注入筛选键
|
||||
"""
|
||||
|
||||
view_key: str
|
||||
include_query: bool = False
|
||||
include_filter: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiViewDefinition:
|
||||
"""
|
||||
TUI 视图定义。
|
||||
|
||||
:param view_key: 视图标识
|
||||
:param collector: 页面快照采集器
|
||||
"""
|
||||
|
||||
view_key: str
|
||||
collector: SnapshotCollector
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiViewRegistry:
|
||||
"""
|
||||
TUI 视图注册表。
|
||||
|
||||
该对象集中维护视图打开、刷新与顺序导航所需的元数据,
|
||||
避免 `ShierfengTuiApp` 内继续手工维护多份视图映射。
|
||||
|
||||
:param definitions: 视图定义映射
|
||||
:param navigation_items: 导航项列表
|
||||
"""
|
||||
|
||||
definitions: dict[str, TuiViewDefinition]
|
||||
navigation_items: list[NavigationItem]
|
||||
|
||||
@staticmethod
|
||||
def normalize_view_key(view_key: str) -> str:
|
||||
"""
|
||||
规范化视图标识。
|
||||
|
||||
:param view_key: 原始视图标识
|
||||
:return: 规范化后的视图标识
|
||||
"""
|
||||
return str(view_key).strip().lower()
|
||||
|
||||
def resolve_view_key(self, view_key: str) -> str:
|
||||
"""
|
||||
解析可用视图标识,不存在时回退到 dashboard。
|
||||
|
||||
:param view_key: 原始视图标识
|
||||
:return: 可用视图标识
|
||||
"""
|
||||
normalized_view_key = self.normalize_view_key(view_key)
|
||||
if normalized_view_key in self.definitions:
|
||||
return normalized_view_key
|
||||
return 'dashboard'
|
||||
|
||||
def collect_snapshot(
|
||||
self, app: 'ShierfengTuiApp', view_key: str
|
||||
) -> BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot:
|
||||
"""
|
||||
采集指定视图的页面快照。
|
||||
|
||||
:param app: 当前 TUI 应用实例
|
||||
:param view_key: 目标视图标识
|
||||
:return: 页面快照
|
||||
"""
|
||||
resolved_view_key = self.resolve_view_key(view_key)
|
||||
return self.definitions[resolved_view_key].collector(app)
|
||||
|
||||
def get_navigation_index(self, view_key: str) -> int:
|
||||
"""
|
||||
获取视图在导航列表中的索引。
|
||||
|
||||
:param view_key: 目标视图标识
|
||||
:return: 导航索引
|
||||
"""
|
||||
resolved_view_key = self.resolve_view_key(view_key)
|
||||
return next(
|
||||
(index for index, item in enumerate(self.navigation_items) if item.view_key == resolved_view_key),
|
||||
0,
|
||||
)
|
||||
|
||||
def get_relative_view_key(self, view_key: str, offset: int) -> str:
|
||||
"""
|
||||
获取相对当前视图偏移后的目标视图标识。
|
||||
|
||||
:param view_key: 当前视图标识
|
||||
:param offset: 偏移量
|
||||
:return: 目标视图标识
|
||||
"""
|
||||
current_index = self.get_navigation_index(view_key)
|
||||
target_index = (current_index + offset) % len(self.navigation_items)
|
||||
return self.navigation_items[target_index].view_key
|
||||
|
||||
|
||||
_TUI_VIEW_SPECS: tuple[TuiViewSpec, ...] = (
|
||||
TuiViewSpec('dashboard'),
|
||||
TuiViewSpec('app', include_query=True),
|
||||
TuiViewSpec('ops', include_query=True),
|
||||
TuiViewSpec('database', include_query=True),
|
||||
TuiViewSpec('cache', include_query=True),
|
||||
TuiViewSpec('jobs', include_query=True, include_filter=True),
|
||||
TuiViewSpec('gen', include_query=True),
|
||||
TuiViewSpec('configs', include_query=True, include_filter=True),
|
||||
TuiViewSpec('crypto', include_query=True),
|
||||
)
|
||||
|
||||
_TUI_APP_CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
background: #04080f;
|
||||
color: #d8f7ff;
|
||||
scrollbar-size-horizontal: 1;
|
||||
scrollbar-size-vertical: 1;
|
||||
scrollbar-background: #06111b;
|
||||
scrollbar-background-hover: #0a1723;
|
||||
scrollbar-background-active: #0d1f2d;
|
||||
scrollbar-color: #1f6f8d;
|
||||
scrollbar-color-hover: #38d8ff;
|
||||
scrollbar-color-active: #6df7ff;
|
||||
}
|
||||
|
||||
Widget {
|
||||
scrollbar-size-horizontal: 1;
|
||||
scrollbar-size-vertical: 1;
|
||||
scrollbar-background: #06111b;
|
||||
scrollbar-background-hover: #0a1723;
|
||||
scrollbar-background-active: #0d1f2d;
|
||||
scrollbar-color: #1f6f8d;
|
||||
scrollbar-color-hover: #38d8ff;
|
||||
scrollbar-color-active: #6df7ff;
|
||||
}
|
||||
|
||||
#workspace-shell {
|
||||
height: 1fr;
|
||||
background: #050c15;
|
||||
}
|
||||
|
||||
#workspace-main {
|
||||
padding: 1 2 1 1;
|
||||
background: #050c15;
|
||||
border: round #18425d;
|
||||
margin: 1 1 1 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
scrollbar-size-horizontal: 1;
|
||||
scrollbar-size-vertical: 1;
|
||||
scrollbar-background: #06111b;
|
||||
scrollbar-background-hover: #0a1723;
|
||||
scrollbar-background-active: #0d1f2d;
|
||||
scrollbar-color: #1f6f8d;
|
||||
scrollbar-color-hover: #38d8ff;
|
||||
scrollbar-color-active: #6df7ff;
|
||||
}
|
||||
|
||||
#workspace-main:focus {
|
||||
border: heavy #6df7ff;
|
||||
background: #071320;
|
||||
}
|
||||
|
||||
#detail-body {
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
border: double #18425d;
|
||||
background: #06111b;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#browser-body {
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
border: double #18425d;
|
||||
background: #06111b;
|
||||
padding: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#browser-filter-bar {
|
||||
border: round #163c57;
|
||||
padding: 1 2;
|
||||
margin-bottom: 1;
|
||||
background: #07131f;
|
||||
color: #d9fbff;
|
||||
}
|
||||
|
||||
#browser-detail-pane {
|
||||
width: 1fr;
|
||||
min-width: 48;
|
||||
border: round #163c57;
|
||||
padding: 1;
|
||||
background: #07131f;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#browser-detail-pane.is-ok {
|
||||
border: round #22c983;
|
||||
}
|
||||
|
||||
#browser-detail-pane.is-fail {
|
||||
border: round #ff6b7a;
|
||||
background: #190d12;
|
||||
}
|
||||
|
||||
#browser-detail-pane.is-warn {
|
||||
border: round #f2b84b;
|
||||
background: #171108;
|
||||
}
|
||||
|
||||
#browser-detail-pane.is-info {
|
||||
border: round #38d8ff;
|
||||
}
|
||||
|
||||
#browser-section-body {
|
||||
height: auto;
|
||||
border-top: solid #163c57;
|
||||
padding-top: 1;
|
||||
background: #06111b;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#dashboard-metrics {
|
||||
grid-size: 4;
|
||||
grid-gutter: 1 2;
|
||||
grid-columns: 1fr 1fr 1fr 1fr;
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
margin-bottom: 1;
|
||||
padding: 1 0;
|
||||
}
|
||||
|
||||
#dashboard-grid, #detail-grid {
|
||||
grid-size: 2;
|
||||
grid-gutter: 1 2;
|
||||
grid-columns: 1fr 1fr;
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#detail-grid {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
Footer {
|
||||
background: #060d16;
|
||||
color: #b6efff;
|
||||
border-top: heavy #163c57;
|
||||
}
|
||||
|
||||
FooterKey {
|
||||
background: #0b2130;
|
||||
color: #ecfcff;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiBindingSpec:
|
||||
"""
|
||||
TUI 快捷键绑定定义。
|
||||
|
||||
:param key: 快捷键
|
||||
:param action: 动作名称
|
||||
:param label: 展示标签
|
||||
"""
|
||||
|
||||
key: str
|
||||
action: str
|
||||
label: str
|
||||
|
||||
|
||||
class TuiBindingBuilder:
|
||||
"""
|
||||
TUI 快捷键绑定构建器。
|
||||
|
||||
该对象负责统一装配应用级固定绑定和视图跳转绑定,避免 `ShierfengTuiApp`
|
||||
继续手工维护重复的绑定元组列表。
|
||||
"""
|
||||
|
||||
def build_base_bindings(self) -> list[TuiBindingSpec]:
|
||||
"""
|
||||
构建应用级固定绑定。
|
||||
|
||||
:return: 绑定定义列表
|
||||
"""
|
||||
return [
|
||||
TuiBindingSpec('q', 'quit', TUI_COPY.build_app_binding_label('quit')),
|
||||
TuiBindingSpec('s', 'focus_sidebar', TUI_COPY.build_app_binding_label('sidebar')),
|
||||
TuiBindingSpec('[', 'show_previous_view', TUI_COPY.build_app_binding_label('previous')),
|
||||
TuiBindingSpec(']', 'show_next_view', TUI_COPY.build_app_binding_label('next')),
|
||||
TuiBindingSpec('r', 'refresh_current_view', TUI_COPY.build_app_binding_label('refresh')),
|
||||
]
|
||||
|
||||
def build_navigation_bindings(self) -> list[TuiBindingSpec]:
|
||||
"""
|
||||
构建页面导航绑定。
|
||||
|
||||
:return: 绑定定义列表
|
||||
"""
|
||||
return [
|
||||
TuiBindingSpec(
|
||||
TUI_KEYMAP_REGISTRY.get_navigation_shortcut(spec.view_key),
|
||||
f'show_{spec.view_key}',
|
||||
TUI_COPY.render_view_label(spec.view_key),
|
||||
)
|
||||
for spec in _TUI_VIEW_SPECS
|
||||
]
|
||||
|
||||
def build(self) -> list[tuple[str, str, str]]:
|
||||
"""
|
||||
构建 Textual 所需的绑定元组列表。
|
||||
|
||||
:return: 绑定元组列表
|
||||
"""
|
||||
return [
|
||||
(binding.key, binding.action, binding.label)
|
||||
for binding in [*self.build_base_bindings(), *self.build_navigation_bindings()]
|
||||
]
|
||||
|
||||
|
||||
TUI_APP_BINDINGS = TuiBindingBuilder().build()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiViewRegistryBuilder:
|
||||
"""
|
||||
TUI 视图注册表构建器。
|
||||
|
||||
该对象负责根据统一规格构建导航项和快照采集定义,避免模块内
|
||||
继续维护重复的 `lambda collect(...)` 配置块。
|
||||
"""
|
||||
|
||||
def build_navigation_items(self) -> list[NavigationItem]:
|
||||
"""
|
||||
构建工作台导航项列表。
|
||||
|
||||
:return: 导航项列表
|
||||
"""
|
||||
return [
|
||||
NavigationItem(
|
||||
view_key,
|
||||
TUI_COPY.render_view_label(view_key),
|
||||
shortcut,
|
||||
TUI_COPY.render_navigation_description(view_key),
|
||||
)
|
||||
for view_key, shortcut in TUI_KEYMAP_REGISTRY.navigation_shortcuts.items()
|
||||
]
|
||||
|
||||
def build_collector(self, spec: TuiViewSpec) -> SnapshotCollector:
|
||||
"""
|
||||
根据视图规格构建页面快照采集器。
|
||||
|
||||
:param spec: 视图规格
|
||||
:return: 页面快照采集器
|
||||
"""
|
||||
|
||||
def collect(app: 'ShierfengTuiApp') -> BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot:
|
||||
collect_kwargs: dict[str, str] = {}
|
||||
if spec.include_filter:
|
||||
collect_kwargs['filter_key'] = app.get_browser_filter(spec.view_key)
|
||||
if spec.include_query:
|
||||
collect_kwargs['query'] = app.get_browser_query(spec.view_key)
|
||||
return TUI_SNAPSHOT_COLLECTOR_REGISTRY.collect(spec.view_key, app.env, **collect_kwargs)
|
||||
|
||||
return collect
|
||||
|
||||
def build_definitions(self) -> dict[str, TuiViewDefinition]:
|
||||
"""
|
||||
构建视图定义映射。
|
||||
|
||||
:return: 视图定义映射
|
||||
"""
|
||||
return {spec.view_key: TuiViewDefinition(spec.view_key, self.build_collector(spec)) for spec in _TUI_VIEW_SPECS}
|
||||
|
||||
def build(self) -> TuiViewRegistry:
|
||||
"""
|
||||
构建 TUI 视图注册表。
|
||||
|
||||
:return: 视图注册表
|
||||
"""
|
||||
return TuiViewRegistry(
|
||||
definitions=self.build_definitions(),
|
||||
navigation_items=self.build_navigation_items(),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TuiViewStateStore:
|
||||
"""
|
||||
TUI 视图状态存储。
|
||||
|
||||
该对象集中维护页面级动作反馈、浏览页筛选键和搜索词,避免
|
||||
`ShierfengTuiApp` 本体继续直接操作多份同类字典状态。
|
||||
|
||||
:param action_feedback_by_view: 动作反馈映射
|
||||
:param browser_filter_by_view: 浏览页筛选映射
|
||||
:param browser_query_by_view: 浏览页搜索词映射
|
||||
"""
|
||||
|
||||
action_feedback_by_view: dict[str, list[str]]
|
||||
browser_filter_by_view: dict[str, str]
|
||||
browser_query_by_view: dict[str, str]
|
||||
|
||||
@classmethod
|
||||
def create_default(cls) -> 'TuiViewStateStore':
|
||||
"""
|
||||
创建默认视图状态存储。
|
||||
|
||||
:return: 视图状态存储
|
||||
"""
|
||||
return cls(
|
||||
action_feedback_by_view={},
|
||||
browser_filter_by_view={'jobs': 'all', 'configs': 'all'},
|
||||
browser_query_by_view={},
|
||||
)
|
||||
|
||||
def remember_action_feedback(self, view_key: str, lines: list[str]) -> None:
|
||||
"""
|
||||
记录指定页面最近一次动作反馈。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:param lines: 反馈文本行
|
||||
:return: None
|
||||
"""
|
||||
self.action_feedback_by_view[view_key] = list(lines)
|
||||
|
||||
def get_action_feedback_lines(self, view_key: str) -> list[str]:
|
||||
"""
|
||||
读取指定页面最近一次动作反馈。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:return: 反馈文本行
|
||||
"""
|
||||
return list(self.action_feedback_by_view.get(view_key, []))
|
||||
|
||||
def remember_browser_filter(self, view_key: str, filter_key: str) -> None:
|
||||
"""
|
||||
记录指定浏览页当前筛选键。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:param filter_key: 当前筛选键
|
||||
:return: None
|
||||
"""
|
||||
self.browser_filter_by_view[view_key] = filter_key
|
||||
|
||||
def get_browser_filter(self, view_key: str, default: str = 'all') -> str:
|
||||
"""
|
||||
读取指定浏览页当前筛选键。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:param default: 默认筛选键
|
||||
:return: 当前筛选键
|
||||
"""
|
||||
return str(self.browser_filter_by_view.get(view_key, default) or default)
|
||||
|
||||
def remember_browser_query(self, view_key: str, query: str) -> None:
|
||||
"""
|
||||
记录指定浏览页当前搜索词。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:param query: 当前搜索词
|
||||
:return: None
|
||||
"""
|
||||
normalized_query = str(query or '').strip()
|
||||
if normalized_query:
|
||||
self.browser_query_by_view[view_key] = normalized_query
|
||||
return
|
||||
self.browser_query_by_view.pop(view_key, None)
|
||||
|
||||
def get_browser_query(self, view_key: str, default: str = '') -> str:
|
||||
"""
|
||||
读取指定浏览页当前搜索词。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:param default: 默认搜索词
|
||||
:return: 当前搜索词
|
||||
"""
|
||||
return str(self.browser_query_by_view.get(view_key, default) or default)
|
||||
|
||||
|
||||
TUI_VIEW_REGISTRY = TuiViewRegistryBuilder().build()
|
||||
NAVIGATION_ITEMS = TUI_VIEW_REGISTRY.navigation_items
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiScreenFactory:
|
||||
"""
|
||||
TUI 工作台页面工厂。
|
||||
|
||||
该对象负责把页面快照转换为具体 screen 实例,避免 `ShierfengTuiApp`
|
||||
同时承担状态协调和 screen 构建职责。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_refresh_timestamp() -> str:
|
||||
"""
|
||||
构建当前页面刷新时间文本。
|
||||
|
||||
:return: 格式化后的刷新时间
|
||||
"""
|
||||
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
def build(
|
||||
self,
|
||||
*,
|
||||
snapshot: BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot,
|
||||
env: str,
|
||||
active_view: str,
|
||||
navigation_items: list[NavigationItem],
|
||||
action_feedback_lines: list[str],
|
||||
) -> BrowserScreen | DashboardScreen | DetailScreen:
|
||||
"""
|
||||
根据快照类型构建对应的工作台页面。
|
||||
|
||||
:param snapshot: 页面快照
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前激活视图
|
||||
:param navigation_items: 导航项列表
|
||||
:param action_feedback_lines: 当前视图动作反馈
|
||||
:return: 对应的页面对象
|
||||
"""
|
||||
refreshed_at = self.build_refresh_timestamp()
|
||||
if isinstance(snapshot, DashboardSnapshot):
|
||||
return DashboardScreen(
|
||||
snapshot,
|
||||
env=env,
|
||||
active_view=active_view,
|
||||
navigation_items=navigation_items,
|
||||
refreshed_at=refreshed_at,
|
||||
)
|
||||
if isinstance(snapshot, BrowserPageSnapshot):
|
||||
return BrowserScreen(
|
||||
snapshot,
|
||||
env=env,
|
||||
active_view=active_view,
|
||||
navigation_items=navigation_items,
|
||||
refreshed_at=refreshed_at,
|
||||
action_feedback_lines=action_feedback_lines,
|
||||
)
|
||||
return DetailScreen(
|
||||
snapshot,
|
||||
env=env,
|
||||
active_view=active_view,
|
||||
navigation_items=navigation_items,
|
||||
refreshed_at=refreshed_at,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TuiScreenNavigator:
|
||||
"""
|
||||
TUI 工作台页面导航器。
|
||||
|
||||
该对象负责处理首屏展示与后续切屏差异,让应用壳更专注于状态切换。
|
||||
|
||||
:param app: 当前 TUI 应用实例
|
||||
:param initialized: 工作台屏幕是否已初始化
|
||||
"""
|
||||
|
||||
app: 'ShierfengTuiApp'
|
||||
initialized: bool = False
|
||||
|
||||
def show(self, screen: BrowserScreen | DashboardScreen | DetailScreen) -> None:
|
||||
"""
|
||||
显示工作台页面,并兼容首屏初始化与后续切屏。
|
||||
|
||||
Textual 在默认根屏阶段直接调用 `switch_screen()` 会触发内部回调栈异常,
|
||||
因此首次展示页面需要使用 `push_screen()` 建立活动屏,后续再改用
|
||||
`switch_screen()` 做同层页面替换。
|
||||
|
||||
:param screen: 待显示的页面对象
|
||||
:return: None
|
||||
"""
|
||||
if not self.initialized:
|
||||
self.app.push_screen(screen)
|
||||
self.initialized = True
|
||||
return
|
||||
self.app.switch_screen(screen)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiViewOpeningCoordinator:
|
||||
"""
|
||||
TUI 视图打开协调器。
|
||||
|
||||
该对象负责串联视图解析、快照采集、screen 构建与切屏展示,
|
||||
让 `ShierfengTuiApp` 更专注于状态暴露与 Textual action 桥接。
|
||||
|
||||
:param view_registry: 视图注册表
|
||||
:param screen_factory: 工作台页面工厂
|
||||
:param navigation_items: 导航项列表
|
||||
"""
|
||||
|
||||
view_registry: TuiViewRegistry
|
||||
screen_factory: TuiScreenFactory
|
||||
navigation_items: list[NavigationItem]
|
||||
|
||||
def show(self, app: 'ShierfengTuiApp', view_key: str) -> None:
|
||||
"""
|
||||
切换并展示指定视图。
|
||||
|
||||
:param app: 当前 TUI 应用实例
|
||||
:param view_key: 目标视图标识
|
||||
:return: None
|
||||
"""
|
||||
resolved_view_key = self.view_registry.resolve_view_key(view_key)
|
||||
app.current_view = resolved_view_key
|
||||
snapshot = self.view_registry.collect_snapshot(app, resolved_view_key)
|
||||
screen = self.screen_factory.build(
|
||||
snapshot=snapshot,
|
||||
env=app.env,
|
||||
active_view=app.current_view,
|
||||
navigation_items=self.navigation_items,
|
||||
action_feedback_lines=app.get_action_feedback_lines(app.current_view),
|
||||
)
|
||||
app.screen_navigator.show(screen)
|
||||
|
||||
|
||||
class ShierfengTuiApp(App[None]):
|
||||
"""
|
||||
ShiErFeng CLI Textual 应用。
|
||||
|
||||
:param env: 当前运行环境
|
||||
"""
|
||||
|
||||
CSS = _TUI_APP_CSS
|
||||
BINDINGS = TUI_APP_BINDINGS
|
||||
|
||||
def __init__(self, env: str) -> None:
|
||||
"""
|
||||
初始化 TUI 应用实例。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:return: None
|
||||
"""
|
||||
self.env = env
|
||||
self.current_view = 'dashboard'
|
||||
self.view_state_store = TuiViewStateStore.create_default()
|
||||
self.screen_factory = TuiScreenFactory()
|
||||
self.screen_navigator = TuiScreenNavigator(self)
|
||||
self.view_opening_coordinator = TuiViewOpeningCoordinator(
|
||||
view_registry=TUI_VIEW_REGISTRY,
|
||||
screen_factory=self.screen_factory,
|
||||
navigation_items=NAVIGATION_ITEMS,
|
||||
)
|
||||
super().__init__()
|
||||
|
||||
def show_view(self, view_key: str) -> None:
|
||||
"""
|
||||
切换并展示指定视图。
|
||||
|
||||
:param view_key: 目标视图标识
|
||||
:return: None
|
||||
"""
|
||||
self.view_opening_coordinator.show(self, view_key)
|
||||
|
||||
def remember_action_feedback(self, view_key: str, lines: list[str]) -> None:
|
||||
"""
|
||||
记录指定页面最近一次动作反馈。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:param lines: 反馈文本行
|
||||
:return: None
|
||||
"""
|
||||
self.view_state_store.remember_action_feedback(view_key, lines)
|
||||
|
||||
def get_action_feedback_lines(self, view_key: str) -> list[str]:
|
||||
"""
|
||||
读取指定页面最近一次动作反馈。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:return: 反馈文本行
|
||||
"""
|
||||
return self.view_state_store.get_action_feedback_lines(view_key)
|
||||
|
||||
def remember_browser_filter(self, view_key: str, filter_key: str) -> None:
|
||||
"""
|
||||
记录指定浏览页当前筛选键。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:param filter_key: 当前筛选键
|
||||
:return: None
|
||||
"""
|
||||
self.view_state_store.remember_browser_filter(view_key, filter_key)
|
||||
|
||||
def get_browser_filter(self, view_key: str, default: str = 'all') -> str:
|
||||
"""
|
||||
读取指定浏览页当前筛选键。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:param default: 默认筛选键
|
||||
:return: 当前筛选键
|
||||
"""
|
||||
return self.view_state_store.get_browser_filter(view_key, default)
|
||||
|
||||
def remember_browser_query(self, view_key: str, query: str) -> None:
|
||||
"""
|
||||
记录指定浏览页当前搜索词。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:param query: 当前搜索词
|
||||
:return: None
|
||||
"""
|
||||
self.view_state_store.remember_browser_query(view_key, query)
|
||||
|
||||
def get_browser_query(self, view_key: str, default: str = '') -> str:
|
||||
"""
|
||||
读取指定浏览页当前搜索词。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:param default: 默认搜索词
|
||||
:return: 当前搜索词
|
||||
"""
|
||||
return self.view_state_store.get_browser_query(view_key, default)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""
|
||||
在应用启动时载入首页。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.show_view('dashboard')
|
||||
|
||||
def open_view(self, view_key: str) -> None:
|
||||
"""
|
||||
按视图标识打开对应页面。
|
||||
|
||||
:param view_key: 视图标识
|
||||
:return: None
|
||||
"""
|
||||
self.show_view(view_key)
|
||||
|
||||
def action_focus_sidebar(self) -> None:
|
||||
"""
|
||||
将焦点切换到左侧导航栏。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
self.screen.query_one(WorkspaceSidebar).focus()
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
def action_show_previous_view(self) -> None:
|
||||
"""
|
||||
切换到前一个工作台视图。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.open_view(TUI_VIEW_REGISTRY.get_relative_view_key(self.current_view, -1))
|
||||
|
||||
def action_show_next_view(self) -> None:
|
||||
"""
|
||||
切换到后一个工作台视图。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.open_view(TUI_VIEW_REGISTRY.get_relative_view_key(self.current_view, 1))
|
||||
|
||||
def action_refresh_current_view(self) -> None:
|
||||
"""
|
||||
刷新当前视图数据。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.show_view(self.current_view)
|
||||
|
||||
def action_show_dashboard(self) -> None:
|
||||
"""
|
||||
切换到首页视图。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.open_view('dashboard')
|
||||
|
||||
def action_show_app(self) -> None:
|
||||
"""
|
||||
切换到应用详情视图。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.open_view('app')
|
||||
|
||||
def action_show_ops(self) -> None:
|
||||
"""
|
||||
切换到运维详情视图。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.open_view('ops')
|
||||
|
||||
def action_show_database(self) -> None:
|
||||
"""
|
||||
切换到数据库详情视图。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.open_view('database')
|
||||
|
||||
def action_show_cache(self) -> None:
|
||||
"""
|
||||
切换到缓存浏览视图。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.open_view('cache')
|
||||
|
||||
def action_show_jobs(self) -> None:
|
||||
"""
|
||||
切换到任务浏览视图。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.open_view('jobs')
|
||||
|
||||
def action_show_gen(self) -> None:
|
||||
"""
|
||||
切换到代码生成浏览视图。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.open_view('gen')
|
||||
|
||||
def action_show_configs(self) -> None:
|
||||
"""
|
||||
切换到配置浏览视图。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.open_view('configs')
|
||||
|
||||
def action_show_crypto(self) -> None:
|
||||
"""
|
||||
切换到传输加密详情视图。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.open_view('crypto')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiAppRunner:
|
||||
"""
|
||||
TUI 应用启动器。
|
||||
|
||||
该对象负责按环境创建并运行 Textual 应用实例,使命令入口不再依赖
|
||||
模块级启动函数。
|
||||
|
||||
:param app_factory: TUI 应用工厂
|
||||
"""
|
||||
|
||||
app_factory: Callable[[str], ShierfengTuiApp]
|
||||
|
||||
def run(self, env: str) -> None:
|
||||
"""
|
||||
启动 TUI 应用。
|
||||
|
||||
:param env: 当前运行环境
|
||||
:return: None
|
||||
"""
|
||||
self.app_factory(env).run()
|
||||
|
||||
|
||||
TUI_APP_RUNNER = TuiAppRunner(ShierfengTuiApp)
|
||||
@@ -0,0 +1,209 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from cli.tui.copy import TUI_COPY
|
||||
|
||||
CapabilityKind = Literal['read_only', 'preview', 'low_risk_action', 'wizard_entry', 'command_hint']
|
||||
CapabilityScope = Literal['browser', 'detail']
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiCapability:
|
||||
"""
|
||||
TUI 页面能力定义。
|
||||
|
||||
:param kind: 能力类型
|
||||
:param slot: 绑定的动作槽位
|
||||
:param key: 绑定的快捷键
|
||||
:param label: 展示标签
|
||||
:param hint_label: 用于顶部提示的简洁标签
|
||||
:param scope: 能力所在页面范围
|
||||
"""
|
||||
|
||||
kind: CapabilityKind
|
||||
slot: str
|
||||
key: str
|
||||
label: str
|
||||
hint_label: str
|
||||
scope: CapabilityScope
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiCapabilitySpec:
|
||||
"""
|
||||
TUI 页面能力规格定义。
|
||||
|
||||
:param capability_key: 能力标识
|
||||
:param kind: 能力类型
|
||||
:param slot: 绑定动作槽位
|
||||
:param key: 绑定快捷键
|
||||
"""
|
||||
|
||||
capability_key: str
|
||||
kind: CapabilityKind
|
||||
slot: str
|
||||
key: str
|
||||
|
||||
|
||||
_BROWSER_CAPABILITY_SPECS: dict[str, tuple[TuiCapabilitySpec, ...]] = {
|
||||
'jobs': (
|
||||
TuiCapabilitySpec('job_run_once', 'low_risk_action', 'primary', 'X'),
|
||||
TuiCapabilitySpec('job_toggle', 'low_risk_action', 'secondary', 'Z'),
|
||||
TuiCapabilitySpec('job_sync', 'preview', 'global', 'Y'),
|
||||
),
|
||||
'configs': (TuiCapabilitySpec('config_sync', 'low_risk_action', 'global', 'Y'),),
|
||||
'cache': (
|
||||
TuiCapabilitySpec('cache_clear_wizard', 'wizard_entry', 'global', 'Y'),
|
||||
TuiCapabilitySpec('cache_warmup', 'low_risk_action', 'utility', 'W'),
|
||||
),
|
||||
'gen': (
|
||||
TuiCapabilitySpec('gen_export_wizard', 'wizard_entry', 'primary', 'X'),
|
||||
TuiCapabilitySpec('gen_import_wizard', 'wizard_entry', 'secondary', 'Z'),
|
||||
TuiCapabilitySpec('gen_export_dry_run', 'preview', 'global', 'Y'),
|
||||
TuiCapabilitySpec('gen_sync_db', 'low_risk_action', 'utility', 'W'),
|
||||
),
|
||||
}
|
||||
|
||||
_DETAIL_CAPABILITY_SPECS: dict[str, tuple[TuiCapabilitySpec, ...]] = {
|
||||
'app': (
|
||||
TuiCapabilitySpec('app_run', 'wizard_entry', 'primary', 'X'),
|
||||
TuiCapabilitySpec('app_run_wizard', 'wizard_entry', 'global', 'Y'),
|
||||
TuiCapabilitySpec('completion_install', 'command_hint', 'utility', 'W'),
|
||||
),
|
||||
'database': (
|
||||
TuiCapabilitySpec('db_upgrade_wizard', 'wizard_entry', 'global', 'Y'),
|
||||
TuiCapabilitySpec('db_init_dry_run', 'preview', 'utility', 'W'),
|
||||
),
|
||||
'ops': (
|
||||
TuiCapabilitySpec('ops_ping_db', 'low_risk_action', 'primary', 'X'),
|
||||
TuiCapabilitySpec('ops_ping_redis', 'low_risk_action', 'secondary', 'Z'),
|
||||
TuiCapabilitySpec('prod_check_wizard', 'wizard_entry', 'global', 'Y'),
|
||||
),
|
||||
'crypto': (
|
||||
TuiCapabilitySpec('crypto_keygen', 'wizard_entry', 'primary', 'X'),
|
||||
TuiCapabilitySpec('crypto_rotate_dry_run', 'preview', 'global', 'Y'),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiCapabilityRegistry:
|
||||
"""
|
||||
TUI 页面能力注册表。
|
||||
|
||||
该对象集中维护浏览页与详情页的能力描述,避免模块级散落
|
||||
的字典常量在后续扩展时继续增长。
|
||||
|
||||
:param browser_capabilities: 浏览页能力映射
|
||||
:param detail_capabilities: 详情页能力映射
|
||||
"""
|
||||
|
||||
browser_capabilities: dict[str, tuple[TuiCapability, ...]]
|
||||
detail_capabilities: dict[str, tuple[TuiCapability, ...]]
|
||||
|
||||
@staticmethod
|
||||
def normalize_view_key(view_key: str) -> str:
|
||||
"""
|
||||
规范化页面标识。
|
||||
|
||||
:param view_key: 原始页面标识
|
||||
:return: 规范化后的页面标识
|
||||
"""
|
||||
return str(view_key).strip().lower()
|
||||
|
||||
def get_browser_capabilities(self, view_key: str) -> tuple[TuiCapability, ...]:
|
||||
"""
|
||||
获取浏览页支持的能力列表。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:return: 能力列表
|
||||
"""
|
||||
return self.browser_capabilities.get(self.normalize_view_key(view_key), ())
|
||||
|
||||
def get_detail_capabilities(self, view_key: str) -> tuple[TuiCapability, ...]:
|
||||
"""
|
||||
获取详情页支持的能力列表。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:return: 能力列表
|
||||
"""
|
||||
return self.detail_capabilities.get(self.normalize_view_key(view_key), ())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiCapabilityRegistryBuilder:
|
||||
"""
|
||||
TUI 页面能力注册表构建器。
|
||||
|
||||
该对象负责通过统一文案服务构建页面能力定义,避免模块内继续
|
||||
直接堆叠硬编码的标题和提示标签。
|
||||
"""
|
||||
|
||||
def build_capability(
|
||||
self,
|
||||
*,
|
||||
capability_key: str,
|
||||
kind: CapabilityKind,
|
||||
slot: str,
|
||||
key: str,
|
||||
scope: CapabilityScope,
|
||||
) -> TuiCapability:
|
||||
"""
|
||||
构建单条能力定义。
|
||||
|
||||
:param capability_key: 能力标识
|
||||
:param kind: 能力类型
|
||||
:param slot: 绑定动作槽位
|
||||
:param key: 绑定快捷键
|
||||
:param scope: 页面范围
|
||||
:return: 能力定义
|
||||
"""
|
||||
return TuiCapability(
|
||||
kind=kind,
|
||||
slot=slot,
|
||||
key=key,
|
||||
label=TUI_COPY.build_capability_label(capability_key),
|
||||
hint_label=TUI_COPY.build_capability_hint_label(capability_key),
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
def build_scope_capabilities(
|
||||
self,
|
||||
specs_by_view: dict[str, tuple[TuiCapabilitySpec, ...]],
|
||||
*,
|
||||
scope: CapabilityScope,
|
||||
) -> dict[str, tuple[TuiCapability, ...]]:
|
||||
"""
|
||||
根据规格映射构建指定页面范围的能力注册表。
|
||||
|
||||
:param specs_by_view: 页面能力规格映射
|
||||
:param scope: 页面范围
|
||||
:return: 构建后的能力映射
|
||||
"""
|
||||
return {
|
||||
view_key: tuple(
|
||||
self.build_capability(
|
||||
capability_key=spec.capability_key,
|
||||
kind=spec.kind,
|
||||
slot=spec.slot,
|
||||
key=spec.key,
|
||||
scope=scope,
|
||||
)
|
||||
for spec in specs
|
||||
)
|
||||
for view_key, specs in specs_by_view.items()
|
||||
}
|
||||
|
||||
def build(self) -> TuiCapabilityRegistry:
|
||||
"""
|
||||
构建页面能力注册表。
|
||||
|
||||
:return: 能力注册表
|
||||
"""
|
||||
return TuiCapabilityRegistry(
|
||||
browser_capabilities=self.build_scope_capabilities(_BROWSER_CAPABILITY_SPECS, scope='browser'),
|
||||
detail_capabilities=self.build_scope_capabilities(_DETAIL_CAPABILITY_SPECS, scope='detail'),
|
||||
)
|
||||
|
||||
|
||||
TUI_CAPABILITY_REGISTRY = TuiCapabilityRegistryBuilder().build()
|
||||
@@ -0,0 +1,145 @@
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from types import ModuleType
|
||||
|
||||
import typer
|
||||
|
||||
from cli.context import EnvOption
|
||||
from cli.core import DEFAULT_CORE_SERVICES, CliContextFactory, CliExecutionService
|
||||
from cli.exit_codes import DEPENDENCY_ERROR
|
||||
from cli.output import CommandResult
|
||||
from cli.tui.copy import TUI_COPY
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiDependencyResultFactory:
|
||||
"""
|
||||
TUI 依赖结果构建器。
|
||||
|
||||
该对象负责统一构建 Textual/Rich 缺失时的降级结果,
|
||||
避免命令注册层继续直接拼接错误负载。
|
||||
"""
|
||||
|
||||
def build_missing_dependency_result(self) -> CommandResult:
|
||||
"""
|
||||
构建 TUI 可选依赖缺失时的提示结果。
|
||||
|
||||
:return: 命令结果对象
|
||||
"""
|
||||
return CommandResult(
|
||||
data={
|
||||
'ok': False,
|
||||
'message': TUI_COPY.build_missing_dependency_message(),
|
||||
'hint': TUI_COPY.build_missing_dependency_hint(),
|
||||
},
|
||||
exit_code=DEPENDENCY_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiAppModuleLoader:
|
||||
"""
|
||||
TUI 应用模块加载器。
|
||||
|
||||
该对象负责识别缺失的可选依赖,并在命令执行阶段按需导入
|
||||
`cli.tui.app` 模块。
|
||||
"""
|
||||
|
||||
def is_missing_dependency_error(self, exc: ModuleNotFoundError) -> bool:
|
||||
"""
|
||||
判断模块导入异常是否由 TUI 可选依赖缺失触发。
|
||||
|
||||
:param exc: 模块导入异常
|
||||
:return: 是否为 TUI 依赖缺失
|
||||
"""
|
||||
missing_module_name = (exc.name or '').split('.', 1)[0]
|
||||
return missing_module_name in {'textual', 'rich'}
|
||||
|
||||
def load(self) -> ModuleType:
|
||||
"""
|
||||
惰性导入 TUI 应用模块。
|
||||
|
||||
:return: `cli.tui.app` 模块对象
|
||||
"""
|
||||
return import_module('cli.tui.app')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiCommandRegistration:
|
||||
"""
|
||||
TUI 命令注册器。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param dependency_result_factory: TUI 缺失依赖结果构建器
|
||||
:param module_loader: TUI 应用模块加载器
|
||||
"""
|
||||
|
||||
context_factory: CliContextFactory
|
||||
execution_service: CliExecutionService
|
||||
dependency_result_factory: TuiDependencyResultFactory
|
||||
module_loader: TuiAppModuleLoader
|
||||
|
||||
def handle_tui_command(self, env: str) -> None:
|
||||
"""
|
||||
执行 `shierfeng tui` 命令。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, 'text')
|
||||
try:
|
||||
tui_app_module = self.module_loader.load()
|
||||
except ModuleNotFoundError as exc:
|
||||
if self.module_loader.is_missing_dependency_error(exc):
|
||||
self.execution_service.complete_result(
|
||||
ctx,
|
||||
self.dependency_result_factory.build_missing_dependency_result(),
|
||||
)
|
||||
return
|
||||
raise
|
||||
tui_app_module.TUI_APP_RUNNER.run(env)
|
||||
|
||||
def build(self) -> typer.Typer:
|
||||
"""
|
||||
构建 `tui` 子应用。
|
||||
|
||||
:return: `tui` 子应用
|
||||
"""
|
||||
app = typer.Typer(
|
||||
help=TUI_COPY.build_tui_command_help(),
|
||||
no_args_is_help=False,
|
||||
invoke_without_command=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
|
||||
@app.callback()
|
||||
def tui(
|
||||
env: EnvOption = 'dev',
|
||||
) -> None:
|
||||
"""
|
||||
进入只读巡检工作台。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:return: None
|
||||
"""
|
||||
self.handle_tui_command(env)
|
||||
|
||||
return app
|
||||
|
||||
def register(self, root_cli: typer.Typer) -> None:
|
||||
"""
|
||||
向根 Typer 应用注册 `tui` 命令。
|
||||
|
||||
:param root_cli: 根 Typer 应用
|
||||
:return: None
|
||||
"""
|
||||
root_cli.add_typer(self.build(), name='tui')
|
||||
|
||||
|
||||
TUI_COMMAND_REGISTRATION = TuiCommandRegistration(
|
||||
context_factory=DEFAULT_CORE_SERVICES.context_factory,
|
||||
execution_service=DEFAULT_CORE_SERVICES.execution_service,
|
||||
dependency_result_factory=TuiDependencyResultFactory(),
|
||||
module_loader=TuiAppModuleLoader(),
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
from cli.tui.copy.actions import TuiActionCopyMixin
|
||||
from cli.tui.copy.navigation import TuiNavigationCopyMixin
|
||||
from cli.tui.copy.workspace import TuiWorkspaceCopyMixin
|
||||
|
||||
|
||||
class TuiCopyService(TuiNavigationCopyMixin, TuiActionCopyMixin, TuiWorkspaceCopyMixin):
|
||||
"""
|
||||
TUI 文案构造服务。
|
||||
|
||||
该服务通过组合导航、动作和工作区文案混入对象,统一对外提供
|
||||
TUI 所需的文案构造能力,避免入口模块继续堆积实现细节。
|
||||
"""
|
||||
|
||||
|
||||
TUI_COPY = TuiCopyService()
|
||||
|
||||
__all__ = [
|
||||
'TUI_COPY',
|
||||
'TuiActionCopyMixin',
|
||||
'TuiCopyService',
|
||||
'TuiNavigationCopyMixin',
|
||||
'TuiWorkspaceCopyMixin',
|
||||
]
|
||||
@@ -0,0 +1,298 @@
|
||||
from cli.tui.copy.fragments import (
|
||||
ACTION_CONSEQUENCE_TEXTS,
|
||||
ACTION_FEEDBACK_COPY,
|
||||
ACTION_HINT_COPY,
|
||||
ACTION_LABELS,
|
||||
ACTION_NOTIFICATION_COPY,
|
||||
ACTION_PREVIEW_FIELD_LABELS,
|
||||
ACTION_PREVIEW_TITLES,
|
||||
ACTION_PURPOSE_LABELS,
|
||||
ACTION_RESULT_FIELD_LABELS,
|
||||
ACTION_SCOPE_LABELS,
|
||||
CAPABILITY_HINT_LABELS,
|
||||
CAPABILITY_LABELS,
|
||||
COMMAND_HINT_TITLES,
|
||||
GENERAL_COPY,
|
||||
)
|
||||
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
class TuiActionCopyMixin:
|
||||
"""
|
||||
TUI 动作与能力文案混入。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_action_confirm_hint() -> str:
|
||||
"""
|
||||
构建动作确认弹窗底部提示文案。
|
||||
|
||||
:return: 确认提示文案
|
||||
"""
|
||||
return GENERAL_COPY['action_confirm_hint']
|
||||
|
||||
@staticmethod
|
||||
def build_action_confirm_cancel_label() -> str:
|
||||
"""
|
||||
构建动作确认弹窗取消按钮文案。
|
||||
|
||||
:return: 取消按钮文案
|
||||
"""
|
||||
return GENERAL_COPY['action_confirm_cancel_label']
|
||||
|
||||
@staticmethod
|
||||
def build_action_label(action_key: str) -> str:
|
||||
"""
|
||||
根据动作标识构建动作标题文案。
|
||||
|
||||
:param action_key: 动作标识
|
||||
:return: 动作标题文案
|
||||
"""
|
||||
normalized_key = str(action_key).strip().lower()
|
||||
return ACTION_LABELS.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_action_scope_label(action_key: str) -> str:
|
||||
"""
|
||||
根据动作标识构建作用范围文案。
|
||||
|
||||
:param action_key: 动作标识
|
||||
:return: 作用范围文案
|
||||
"""
|
||||
normalized_key = str(action_key).strip().lower()
|
||||
return ACTION_SCOPE_LABELS.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_action_purpose_label(action_key: str) -> str:
|
||||
"""
|
||||
根据动作标识构建用途文案。
|
||||
|
||||
:param action_key: 动作标识
|
||||
:return: 用途文案
|
||||
"""
|
||||
normalized_key = str(action_key).strip().lower()
|
||||
return ACTION_PURPOSE_LABELS.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_action_preview_title(title_key: str) -> str:
|
||||
"""
|
||||
根据预览区块标识构建标题文案。
|
||||
|
||||
:param title_key: 预览区块标识
|
||||
:return: 标题文案
|
||||
"""
|
||||
normalized_key = str(title_key).strip().lower()
|
||||
return ACTION_PREVIEW_TITLES.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_action_preview_field_label(field_key: str) -> str:
|
||||
"""
|
||||
根据预览字段标识构建字段文案。
|
||||
|
||||
:param field_key: 预览字段标识
|
||||
:return: 字段文案
|
||||
"""
|
||||
normalized_key = str(field_key).strip().lower()
|
||||
return ACTION_PREVIEW_FIELD_LABELS.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_action_result_field_label(field_key: str) -> str:
|
||||
"""
|
||||
根据结果字段标识构建字段文案。
|
||||
|
||||
:param field_key: 结果字段标识
|
||||
:return: 字段文案
|
||||
"""
|
||||
normalized_key = str(field_key).strip().lower()
|
||||
return ACTION_RESULT_FIELD_LABELS.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_action_notification_title() -> str:
|
||||
"""
|
||||
构建动作通知标题文案。
|
||||
|
||||
:return: 通知标题文案
|
||||
"""
|
||||
return ACTION_NOTIFICATION_COPY['title']
|
||||
|
||||
@staticmethod
|
||||
def build_action_unavailable_message() -> str:
|
||||
"""
|
||||
构建无可执行动作时的提示文案。
|
||||
|
||||
:return: 提示文案
|
||||
"""
|
||||
return ACTION_NOTIFICATION_COPY['unavailable_message']
|
||||
|
||||
@staticmethod
|
||||
def build_action_empty_line() -> str:
|
||||
"""
|
||||
构建无可执行动作时的空态占位文案。
|
||||
|
||||
:return: 空态占位文案
|
||||
"""
|
||||
return ACTION_NOTIFICATION_COPY['empty_line']
|
||||
|
||||
@staticmethod
|
||||
def build_action_consequence_text(consequence_key: str) -> str:
|
||||
"""
|
||||
根据后果说明标识构建说明文案。
|
||||
|
||||
:param consequence_key: 后果说明标识
|
||||
:return: 后果说明文案
|
||||
"""
|
||||
normalized_key = str(consequence_key).strip().lower()
|
||||
return ACTION_CONSEQUENCE_TEXTS.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_capability_label(capability_key: str) -> str:
|
||||
"""
|
||||
根据能力标识构建展示标签文案。
|
||||
|
||||
:param capability_key: 能力标识
|
||||
:return: 展示标签文案
|
||||
"""
|
||||
normalized_key = str(capability_key).strip().lower()
|
||||
return CAPABILITY_LABELS.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_capability_hint_label(capability_key: str) -> str:
|
||||
"""
|
||||
根据能力标识构建顶部提示短标签文案。
|
||||
|
||||
:param capability_key: 能力标识
|
||||
:return: 短标签文案
|
||||
"""
|
||||
normalized_key = str(capability_key).strip().lower()
|
||||
return CAPABILITY_HINT_LABELS.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_action_running_message(label: str) -> str:
|
||||
"""
|
||||
构建动作执行中的提示文案。
|
||||
|
||||
:param label: 动作标题
|
||||
:return: 提示文案
|
||||
"""
|
||||
return f'正在执行:{label}'
|
||||
|
||||
@staticmethod
|
||||
def build_browser_action_hint_text(view_key: str, interaction_hint: str) -> str:
|
||||
"""
|
||||
构建浏览页动作提示文本。
|
||||
|
||||
:param view_key: 当前页面视图标识
|
||||
:param interaction_hint: 通用浏览提示
|
||||
:return: 动作提示文本
|
||||
"""
|
||||
normalized_view = str(view_key).strip().lower()
|
||||
template = ACTION_HINT_COPY['browser_templates'].get(normalized_view, ACTION_HINT_COPY['browser_default'])
|
||||
return template.format(interaction_hint=interaction_hint)
|
||||
|
||||
@staticmethod
|
||||
def build_detail_action_hint_text(view_key: str, interaction_hint: str) -> str:
|
||||
"""
|
||||
构建详情页动作提示文本。
|
||||
|
||||
:param view_key: 当前页面视图标识
|
||||
:param interaction_hint: 通用浏览提示
|
||||
:return: 动作提示文本
|
||||
"""
|
||||
normalized_view = str(view_key).strip().lower()
|
||||
template = ACTION_HINT_COPY['detail_templates'].get(normalized_view, ACTION_HINT_COPY['detail_default'])
|
||||
return template.format(interaction_hint=interaction_hint)
|
||||
|
||||
@staticmethod
|
||||
def build_action_line(key: str, label: str) -> str:
|
||||
"""
|
||||
构建单条动作快捷键信息。
|
||||
|
||||
:param key: 快捷键
|
||||
:param label: 动作标题
|
||||
:return: 文本文案
|
||||
"""
|
||||
return f'动作键 [{key}] · {label}'
|
||||
|
||||
@staticmethod
|
||||
def build_capability_hint_text(capability_labels: list[str], interaction_hint: str, *, fallback: str) -> str:
|
||||
"""
|
||||
根据能力标签列表构建统一动作提示文本。
|
||||
|
||||
:param capability_labels: 动作标签列表
|
||||
:param interaction_hint: 通用浏览提示
|
||||
:param fallback: 缺省提示模板
|
||||
:return: 动作提示文本
|
||||
"""
|
||||
if not capability_labels:
|
||||
return fallback.format(interaction_hint=interaction_hint)
|
||||
return f'动作键:{" ".join(capability_labels)} | {interaction_hint}'
|
||||
|
||||
@staticmethod
|
||||
def build_action_result_message_line(label: str, value: str) -> str:
|
||||
"""
|
||||
构建动作结果详情的单行文案。
|
||||
|
||||
:param label: 字段标题
|
||||
:param value: 字段值
|
||||
:return: 文本文案
|
||||
"""
|
||||
return f'{label}: {value}'
|
||||
|
||||
@staticmethod
|
||||
def build_cli_command_hint(*command_args: str) -> str:
|
||||
"""
|
||||
构建 TUI 中展示的 CLI 命令提示文本。
|
||||
|
||||
:param command_args: CLI 命令参数
|
||||
:return: 可直接复制执行的命令文本
|
||||
"""
|
||||
return SHELL_TEXT_FORMATTER.format_shell_command(NESTED_CLI_SUPPORT.build_nested_cli_command(*command_args))
|
||||
|
||||
@staticmethod
|
||||
def build_command_hint_lines(
|
||||
*,
|
||||
scenario: str,
|
||||
command: str,
|
||||
guide: str,
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建统一的命令入口提示文本。
|
||||
|
||||
:param scenario: 适用场景说明
|
||||
:param command: 推荐命令
|
||||
:param guide: 使用说明
|
||||
:return: 文本行列表
|
||||
"""
|
||||
return [
|
||||
COMMAND_HINT_TITLES['scenario'],
|
||||
scenario,
|
||||
'',
|
||||
COMMAND_HINT_TITLES['command'],
|
||||
command,
|
||||
'',
|
||||
COMMAND_HINT_TITLES['guide'],
|
||||
guide,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_labeled_value_line(label: str, value: str) -> str:
|
||||
"""
|
||||
构建通用的“标签: 值”文案。
|
||||
|
||||
:param label: 字段标题
|
||||
:param value: 字段值
|
||||
:return: 文本文案
|
||||
"""
|
||||
return f'{label}: {value}'
|
||||
|
||||
@staticmethod
|
||||
def build_action_result_toast(label: str, outcome: str, message: str) -> str:
|
||||
"""
|
||||
构建动作完成后的通知文案。
|
||||
|
||||
:param label: 动作标题
|
||||
:param outcome: 执行结果
|
||||
:param message: 结果摘要
|
||||
:return: 通知文案
|
||||
"""
|
||||
return f'{ACTION_FEEDBACK_COPY["toast_prefix"]} · {label} · {outcome} · {message}'
|
||||
@@ -0,0 +1,339 @@
|
||||
VIEW_LABELS = {
|
||||
'dashboard': '总览',
|
||||
'app': '应用',
|
||||
'ops': '运维',
|
||||
'database': '数据库',
|
||||
'cache': '缓存',
|
||||
'jobs': '任务',
|
||||
'gen': '代码生成',
|
||||
'configs': '参数配置',
|
||||
'crypto': '加密',
|
||||
}
|
||||
|
||||
STATUS_LABELS = {
|
||||
'ok': '正常',
|
||||
'success': '正常',
|
||||
'healthy': '正常',
|
||||
'fail': '失败',
|
||||
'error': '失败',
|
||||
'down': '失败',
|
||||
'warn': '警告',
|
||||
'warning': '警告',
|
||||
'degraded': '警告',
|
||||
'info': '信息',
|
||||
}
|
||||
|
||||
GENERAL_COPY = {
|
||||
'action_confirm_cancel_label': '取消',
|
||||
'action_confirm_hint': '按键提示 · [Enter] 确认执行 [Esc] 取消返回',
|
||||
'more_detail_hint': '> 进入对应页面查看更多',
|
||||
'tui_command_help': '进入只读巡检工作台',
|
||||
'tui_missing_dependency_hint': '请重新执行 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt`',
|
||||
'tui_missing_dependency_message': '当前环境未安装 TUI 可选依赖,无法启动 `shierfeng tui`',
|
||||
}
|
||||
|
||||
STATE_SUGGESTIONS = {
|
||||
'dashboard_empty': '按 [R] 刷新,或进入对应页面查看更多上下文',
|
||||
'dashboard_failure': '按 [R] 刷新,或进入对应页面查看完整诊断信息',
|
||||
'empty': '可按 [R] 刷新,或切换到其他页面继续查看',
|
||||
'failure': '可按 [R] 刷新,或切换页面后重新进入',
|
||||
'loading': '可继续切换其他区域,当前内容加载完成后会自动刷新',
|
||||
}
|
||||
|
||||
PAGE_SUBTITLES = {
|
||||
'crypto': '查看运行校验、公钥身份和兼容版本信息',
|
||||
'ops': '查看基础连通性、依赖版本与服务器资源',
|
||||
}
|
||||
|
||||
NAVIGATION_DESCRIPTIONS = {
|
||||
'dashboard': '全链路态势与关键健康信号',
|
||||
'app': '环境解析、配置快照、补全诊断与路由摘要',
|
||||
'ops': '健康检查、依赖版本与服务器资源',
|
||||
'database': '连接、迁移版本与存储状态',
|
||||
'cache': 'Redis 容量、热点与命中信号',
|
||||
'jobs': '定时任务、执行轨迹与失败样本',
|
||||
'gen': '业务表、字段与生成配置',
|
||||
'configs': '配置巡检、漂移与异常值',
|
||||
'crypto': '传输加密校验、公钥身份与兼容版本',
|
||||
}
|
||||
|
||||
DASHBOARD_HERO_COPY = {
|
||||
'subtitle': '先看整体风险和巡检结论,再进入数据库、缓存、任务等分区继续定位',
|
||||
'title': '运行驾驶舱',
|
||||
}
|
||||
|
||||
STATUS_PANEL_COPY = {
|
||||
'empty': '暂无数据',
|
||||
}
|
||||
|
||||
ACTION_LABELS = {
|
||||
'app_run': '直接启动应用',
|
||||
'app_run_wizard': '打开启动向导',
|
||||
'cache_clear_wizard': '打开缓存清理向导',
|
||||
'cache_warmup': '执行缓存预热',
|
||||
'completion_install': '安装当前 Shell 补全',
|
||||
'config_sync': '刷新参数缓存',
|
||||
'crypto_keygen': '打开密钥生成入口',
|
||||
'crypto_rotate_dry_run': '执行轮换预演',
|
||||
'db_init_dry_run': '执行初始化预演',
|
||||
'db_upgrade_wizard': '打开升级向导',
|
||||
'gen_export_dry_run': '执行导出预演',
|
||||
'gen_export_wizard': '打开导出向导',
|
||||
'gen_import_wizard': '打开导入向导',
|
||||
'gen_sync_db': '同步数据库表结构',
|
||||
'job_pause': '暂停任务',
|
||||
'job_resume': '恢复任务',
|
||||
'job_run_once': '执行一次任务',
|
||||
'job_sync': '同步调度配置',
|
||||
'ops_ping_db': '执行数据库探活',
|
||||
'ops_ping_redis': '执行 Redis 探活',
|
||||
'prod_check_wizard': '打开生产巡检向导',
|
||||
}
|
||||
|
||||
ACTION_SCOPE_LABELS = {
|
||||
'cache_warmup': '系统字典与系统参数基础缓存',
|
||||
'completion_install': '当前终端活动 Shell 的补全安装流程',
|
||||
'config_sync': '当前环境全部系统参数',
|
||||
'db_init_dry_run': '当前环境数据库初始化流程',
|
||||
'gen_export_dry_run': '当前业务表的代码导出结果预演',
|
||||
'gen_sync_db': '当前业务表对应的生成配置和物理表结构',
|
||||
'job_sync': '当前环境调度器配置',
|
||||
'ops_ping_db': '当前环境数据库连接与基础读写探活',
|
||||
'ops_ping_redis': '当前环境 Redis 连接与基础命令探活',
|
||||
}
|
||||
|
||||
ACTION_PURPOSE_LABELS = {
|
||||
'cache_warmup': '重建运行所需的基础缓存内容',
|
||||
'completion_install': '自动识别当前 Shell,写入补全脚本并按需更新 rc 文件激活命令',
|
||||
'config_sync': '将数据库中的参数值重新写入 Redis 缓存',
|
||||
'db_init_dry_run': '仅预演初始化到最新迁移版本,不直接执行真实升级',
|
||||
'gen_export_dry_run': '先演练导出流程并确认输出模式、模板数量和结果摘要',
|
||||
'gen_sync_db': '将数据库中的最新字段结构同步回当前业务表配置',
|
||||
'job_sync': '将最新任务配置同步到调度执行器',
|
||||
'ops_ping_db': '快速确认当前环境数据库是否可连接并返回基础探活结果',
|
||||
'ops_ping_redis': '快速确认当前环境 Redis 是否可连接并返回基础命令探活结果',
|
||||
}
|
||||
|
||||
ACTION_PREVIEW_TITLES = {
|
||||
'command': '## 执行命令',
|
||||
'consequence': '## 确认后果',
|
||||
'env': '## 执行环境',
|
||||
'summary': '## 动作信息',
|
||||
}
|
||||
|
||||
ACTION_PREVIEW_FIELD_LABELS = {
|
||||
'current_status': '当前状态',
|
||||
'job': '任务',
|
||||
'job_id': '任务 ID',
|
||||
'purpose': '用途',
|
||||
'scope': '作用范围',
|
||||
'target_action': '目标动作',
|
||||
'target_env': '目标环境',
|
||||
}
|
||||
|
||||
ACTION_RESULT_FIELD_LABELS = {
|
||||
'count': '影响数量',
|
||||
'exit_code': '退出码',
|
||||
'fail': '失败',
|
||||
'hint': '建议',
|
||||
'job_id': '任务 ID',
|
||||
'name': '动作名称',
|
||||
'operation': '操作标签',
|
||||
'outcome': '结果',
|
||||
'service': '服务反馈',
|
||||
'success': '成功',
|
||||
'summary': '摘要',
|
||||
}
|
||||
|
||||
ACTION_CONSEQUENCE_TEXTS = {
|
||||
'external': '确认后会暂时挂起 TUI,并在当前终端中执行对应命令;命令结束后自动返回工作台。',
|
||||
'preview': '确认后会立即执行对应 CLI 低风险命令,并按结果刷新当前页面。',
|
||||
'wizard': '确认后会暂时挂起 TUI,并在当前终端中打开对应向导;向导结束后自动返回工作台。',
|
||||
}
|
||||
|
||||
APP_BINDING_LABELS = {
|
||||
'next': '下一页',
|
||||
'previous': '上一页',
|
||||
'quit': '退出',
|
||||
'refresh': '刷新',
|
||||
'sidebar': '侧边栏',
|
||||
}
|
||||
|
||||
INTERNAL_BINDING_LABELS = {
|
||||
'action_global': '同步动作',
|
||||
'action_primary': '执行动作',
|
||||
'action_secondary': '切换状态',
|
||||
'action_utility': '工具动作',
|
||||
'clear_search': '清空搜索',
|
||||
'confirm_cancel': '取消',
|
||||
'confirm_submit': '确认',
|
||||
'end': '跳到末尾',
|
||||
'filter_1': '筛选项 1',
|
||||
'filter_2': '筛选项 2',
|
||||
'filter_3': '筛选项 3',
|
||||
'filter_4': '筛选项 4',
|
||||
'focus_left': '向左聚焦',
|
||||
'focus_right': '向右聚焦',
|
||||
'home': '回到顶部',
|
||||
'page_down': '下翻页',
|
||||
'page_up': '上翻页',
|
||||
'scroll_down': '向下滚动',
|
||||
'scroll_up': '向上滚动',
|
||||
'search': '页内搜索',
|
||||
}
|
||||
|
||||
BROWSER_EMPTY_RECORD_COPY = {
|
||||
'detail': '当前页面没有可浏览记录',
|
||||
'label': '记录列表',
|
||||
'summary': '当前页面没有可浏览记录',
|
||||
'title': '暂无记录数据',
|
||||
'value': '暂无数据',
|
||||
}
|
||||
|
||||
BROWSER_LOADING_COPY = {
|
||||
'detail': '正在后台加载当前记录详情,请稍候',
|
||||
'label': '详情状态',
|
||||
'title': '详情加载中',
|
||||
'value': '加载中',
|
||||
}
|
||||
|
||||
DETAIL_EMPTY_SECTION_COPY = {
|
||||
'detail_page': '当前页面没有可展示的分区详情',
|
||||
'detail_record': '当前记录没有可展示的分区详情',
|
||||
'label': '分区详情',
|
||||
'title': '暂无分区数据',
|
||||
'value': '暂无内容',
|
||||
}
|
||||
|
||||
CAPABILITY_LABELS = {
|
||||
'app_run': ACTION_LABELS['app_run'],
|
||||
'app_run_wizard': ACTION_LABELS['app_run_wizard'],
|
||||
'cache_clear_wizard': ACTION_LABELS['cache_clear_wizard'],
|
||||
'cache_warmup': ACTION_LABELS['cache_warmup'],
|
||||
'completion_install': ACTION_LABELS['completion_install'],
|
||||
'config_sync': ACTION_LABELS['config_sync'],
|
||||
'crypto_keygen': ACTION_LABELS['crypto_keygen'],
|
||||
'crypto_rotate_dry_run': ACTION_LABELS['crypto_rotate_dry_run'],
|
||||
'db_init_dry_run': ACTION_LABELS['db_init_dry_run'],
|
||||
'db_upgrade_wizard': ACTION_LABELS['db_upgrade_wizard'],
|
||||
'gen_export_dry_run': ACTION_LABELS['gen_export_dry_run'],
|
||||
'gen_export_wizard': ACTION_LABELS['gen_export_wizard'],
|
||||
'gen_import_wizard': ACTION_LABELS['gen_import_wizard'],
|
||||
'gen_sync_db': ACTION_LABELS['gen_sync_db'],
|
||||
'job_run_once': ACTION_LABELS['job_run_once'],
|
||||
'job_toggle': '暂停/恢复任务',
|
||||
'job_sync': ACTION_LABELS['job_sync'],
|
||||
'ops_ping_db': ACTION_LABELS['ops_ping_db'],
|
||||
'ops_ping_redis': ACTION_LABELS['ops_ping_redis'],
|
||||
'prod_check_wizard': ACTION_LABELS['prod_check_wizard'],
|
||||
}
|
||||
|
||||
CAPABILITY_HINT_LABELS = {
|
||||
'app_run': '直接启动',
|
||||
'app_run_wizard': '打开启动向导',
|
||||
'cache_clear_wizard': '清理向导',
|
||||
'cache_warmup': '执行缓存预热',
|
||||
'completion_install': '安装补全',
|
||||
'config_sync': '刷新参数缓存',
|
||||
'crypto_keygen': '密钥生成',
|
||||
'crypto_rotate_dry_run': '执行轮换预演',
|
||||
'db_init_dry_run': '初始化预演',
|
||||
'db_upgrade_wizard': '打开升级向导',
|
||||
'gen_export_dry_run': '导出预演',
|
||||
'gen_export_wizard': '导出向导',
|
||||
'gen_import_wizard': '导入向导',
|
||||
'gen_sync_db': '同步表结构',
|
||||
'job_run_once': '执行一次',
|
||||
'job_toggle': '暂停/恢复',
|
||||
'job_sync': '同步调度',
|
||||
'ops_ping_db': '数据库探活',
|
||||
'ops_ping_redis': 'Redis 探活',
|
||||
'prod_check_wizard': '打开生产巡检向导',
|
||||
}
|
||||
|
||||
WORKSPACE_LABELS = {
|
||||
'current_section': '当前分区',
|
||||
'menu': '菜单',
|
||||
'overview': '概览',
|
||||
'record': '记录',
|
||||
'section': '分区',
|
||||
'shortcut': '快捷键',
|
||||
'status': '状态',
|
||||
}
|
||||
|
||||
WORKSPACE_TITLES = {
|
||||
'action_feedback': '【执行反馈】',
|
||||
'detail_content': '详情内容',
|
||||
'key_fields': '【关键字段】',
|
||||
'key_info': '【关键信息】',
|
||||
}
|
||||
|
||||
WORKSPACE_EMPTY_TEXTS = {
|
||||
'actions': '暂无可执行动作',
|
||||
'detail': '暂无详情',
|
||||
'key_info': '暂无关键信息',
|
||||
}
|
||||
|
||||
STATE_SECTION_TITLES = {
|
||||
'detail': '## 补充说明',
|
||||
'diagnostic': '## 诊断线索',
|
||||
'error': '## 错误摘要',
|
||||
'status': '## 当前状态',
|
||||
'suggestion': '## 下一步',
|
||||
}
|
||||
|
||||
ACTION_NOTIFICATION_COPY = {
|
||||
'empty_line': '当前记录没有可执行的低风险动作',
|
||||
'title': 'TUI 动作',
|
||||
'unavailable_message': '当前页面没有可执行的对应快捷动作',
|
||||
}
|
||||
|
||||
BROWSER_ACTION_PANEL_COPY = {
|
||||
'action_section_title': '## 可执行动作',
|
||||
'operation_section_title': '## 浏览操作',
|
||||
'recent_action_section_title': '## 最近动作反馈',
|
||||
}
|
||||
|
||||
COMMAND_HINT_TITLES = {
|
||||
'command': '## 推荐命令',
|
||||
'guide': '## 使用说明',
|
||||
'scenario': '## 适用场景',
|
||||
}
|
||||
|
||||
SIGNAL_RAIL_COPY = {
|
||||
'empty': '暂无实时信号',
|
||||
'title': '系统信号带',
|
||||
}
|
||||
|
||||
METRIC_COPY = {
|
||||
'hint_label': '说明',
|
||||
'label': '指标',
|
||||
'value_label': '数值',
|
||||
}
|
||||
|
||||
ACTION_HINT_COPY = {
|
||||
'browser_default': '当前页面以浏览为主。{interaction_hint}',
|
||||
'browser_templates': {
|
||||
'cache': '建议先核对 Redis 概览与键值样本,再决定预热或进入清理向导。{interaction_hint}',
|
||||
'configs': '建议先看高风险配置,再决定是否刷新参数缓存。{interaction_hint}',
|
||||
'gen': '建议先看生成前校验、同步预检查与代码预览,再决定是否进入导入向导、同步表结构或进入导出向导。{interaction_hint}',
|
||||
'jobs': '建议先看失败聚合,再决定执行一次、暂停恢复或同步调度。{interaction_hint}',
|
||||
},
|
||||
'detail_default': '当前页面以查看分区为主。{interaction_hint}',
|
||||
'detail_templates': {
|
||||
'app': '建议先确认环境映射、应用配置、启动前检查、补全诊断和路由状态,再决定是安装补全、直接启动应用还是进入启动向导。{interaction_hint}',
|
||||
'crypto': '建议先确认运行校验、公钥身份和兼容版本,再决定是否生成新密钥或执行轮换预演。{interaction_hint}',
|
||||
'database': '建议先确认 revision、Heads 和历史链路,再决定是否执行初始化预演或进入升级向导。{interaction_hint}',
|
||||
'ops': '建议先确认数据库探活、Redis 探活、依赖版本和服务器资源,再决定是否进入生产巡检。{interaction_hint}',
|
||||
},
|
||||
'operation_hint_lines': [
|
||||
'> [←/→] 切换焦点',
|
||||
'> [J/K] 向上/向下滚动',
|
||||
'> [PgUp/PgDn] 整页滚动',
|
||||
'> [Home/End] 跳到首尾',
|
||||
],
|
||||
}
|
||||
|
||||
ACTION_FEEDBACK_COPY = {
|
||||
'toast_prefix': '动作结果',
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
from cli.tui.copy.fragments import (
|
||||
APP_BINDING_LABELS,
|
||||
GENERAL_COPY,
|
||||
INTERNAL_BINDING_LABELS,
|
||||
NAVIGATION_DESCRIPTIONS,
|
||||
STATUS_LABELS,
|
||||
VIEW_LABELS,
|
||||
)
|
||||
|
||||
|
||||
class TuiNavigationCopyMixin:
|
||||
"""
|
||||
TUI 导航与基础标签文案混入。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def render_view_label(view_key: str) -> str:
|
||||
"""
|
||||
将内部视图标识转换为中文显示名称。
|
||||
|
||||
:param view_key: 视图标识
|
||||
:return: 中文视图名称
|
||||
"""
|
||||
normalized_view = str(view_key).strip().lower()
|
||||
return VIEW_LABELS.get(normalized_view, view_key)
|
||||
|
||||
@staticmethod
|
||||
def render_status_label(status: str, fallback: str = '信息') -> str:
|
||||
"""
|
||||
将状态值转换为中文显示文本。
|
||||
|
||||
:param status: 原始状态值
|
||||
:param fallback: 缺失时兜底文本
|
||||
:return: 中文状态文本
|
||||
"""
|
||||
normalized_status = str(status).strip().lower()
|
||||
if not normalized_status:
|
||||
return fallback
|
||||
return STATUS_LABELS.get(normalized_status, str(status).strip().upper())
|
||||
|
||||
@staticmethod
|
||||
def render_status_code(status: str) -> str:
|
||||
"""
|
||||
将状态值转换为控制台风格状态码。
|
||||
|
||||
:param status: 原始状态值
|
||||
:return: 状态码文本
|
||||
"""
|
||||
normalized_status = str(status).strip().lower()
|
||||
status_code_mapping = {
|
||||
'ok': 'SYS.OK',
|
||||
'success': 'SYS.OK',
|
||||
'healthy': 'SYS.OK',
|
||||
'fail': 'SYS.FAIL',
|
||||
'error': 'SYS.FAIL',
|
||||
'down': 'SYS.FAIL',
|
||||
'warn': 'SYS.WARN',
|
||||
'warning': 'SYS.WARN',
|
||||
'degraded': 'SYS.WARN',
|
||||
'info': 'SYS.INFO',
|
||||
}
|
||||
return status_code_mapping.get(normalized_status, f'SYS.{normalized_status.upper() or "INFO"}')
|
||||
|
||||
@staticmethod
|
||||
def render_navigation_description(view_key: str) -> str:
|
||||
"""
|
||||
获取指定视图的导航说明。
|
||||
|
||||
:param view_key: 视图标识
|
||||
:return: 导航说明
|
||||
"""
|
||||
normalized_view = str(view_key).strip().lower()
|
||||
return NAVIGATION_DESCRIPTIONS.get(normalized_view, '')
|
||||
|
||||
@staticmethod
|
||||
def build_tui_command_help() -> str:
|
||||
"""
|
||||
构建 `shierfeng tui` 命令帮助文案。
|
||||
|
||||
:return: 命令帮助文案
|
||||
"""
|
||||
return GENERAL_COPY['tui_command_help']
|
||||
|
||||
@staticmethod
|
||||
def build_missing_dependency_message() -> str:
|
||||
"""
|
||||
构建 TUI 可选依赖缺失时的提示消息。
|
||||
|
||||
:return: 缺依赖提示消息
|
||||
"""
|
||||
return GENERAL_COPY['tui_missing_dependency_message']
|
||||
|
||||
@staticmethod
|
||||
def build_missing_dependency_hint() -> str:
|
||||
"""
|
||||
构建 TUI 可选依赖缺失时的恢复建议。
|
||||
|
||||
:return: 恢复建议文案
|
||||
"""
|
||||
return GENERAL_COPY['tui_missing_dependency_hint']
|
||||
|
||||
@staticmethod
|
||||
def build_app_binding_label(binding_key: str) -> str:
|
||||
"""
|
||||
根据绑定标识构建应用级快捷键标签文案。
|
||||
|
||||
:param binding_key: 绑定标识
|
||||
:return: 快捷键标签文案
|
||||
"""
|
||||
normalized_key = str(binding_key).strip().lower()
|
||||
return APP_BINDING_LABELS.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_confirm_binding_label(binding_key: str) -> str:
|
||||
"""
|
||||
根据确认弹窗绑定标识构建快捷键标签文案。
|
||||
|
||||
:param binding_key: 绑定标识
|
||||
:return: 快捷键标签文案
|
||||
"""
|
||||
normalized_key = str(binding_key).strip().lower()
|
||||
return INTERNAL_BINDING_LABELS.get(f'confirm_{normalized_key}', normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_internal_binding_label(binding_key: str) -> str:
|
||||
"""
|
||||
根据内部交互绑定标识构建快捷键标签文案。
|
||||
|
||||
:param binding_key: 绑定标识
|
||||
:return: 快捷键标签文案
|
||||
"""
|
||||
normalized_key = str(binding_key).strip().lower()
|
||||
return INTERNAL_BINDING_LABELS.get(normalized_key, normalized_key)
|
||||
@@ -0,0 +1,593 @@
|
||||
from cli.tui.copy.fragments import (
|
||||
ACTION_HINT_COPY,
|
||||
BROWSER_ACTION_PANEL_COPY,
|
||||
BROWSER_EMPTY_RECORD_COPY,
|
||||
BROWSER_LOADING_COPY,
|
||||
DASHBOARD_HERO_COPY,
|
||||
DETAIL_EMPTY_SECTION_COPY,
|
||||
GENERAL_COPY,
|
||||
METRIC_COPY,
|
||||
SIGNAL_RAIL_COPY,
|
||||
STATE_SECTION_TITLES,
|
||||
STATE_SUGGESTIONS,
|
||||
STATUS_PANEL_COPY,
|
||||
WORKSPACE_EMPTY_TEXTS,
|
||||
WORKSPACE_LABELS,
|
||||
WORKSPACE_TITLES,
|
||||
)
|
||||
|
||||
|
||||
class TuiWorkspaceCopyMixin:
|
||||
"""
|
||||
TUI 工作区与状态文案混入。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_workspace_label(label_key: str) -> str:
|
||||
"""
|
||||
根据工作台标签标识构建文案。
|
||||
|
||||
:param label_key: 标签标识
|
||||
:return: 标签文案
|
||||
"""
|
||||
normalized_key = str(label_key).strip().lower()
|
||||
return WORKSPACE_LABELS.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_workspace_title(title_key: str) -> str:
|
||||
"""
|
||||
根据工作台标题标识构建文案。
|
||||
|
||||
:param title_key: 标题标识
|
||||
:return: 标题文案
|
||||
"""
|
||||
normalized_key = str(title_key).strip().lower()
|
||||
return WORKSPACE_TITLES.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_workspace_empty_text(text_key: str) -> str:
|
||||
"""
|
||||
根据工作台空态标识构建文案。
|
||||
|
||||
:param text_key: 空态标识
|
||||
:return: 空态文案
|
||||
"""
|
||||
normalized_key = str(text_key).strip().lower()
|
||||
return WORKSPACE_EMPTY_TEXTS.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_status_panel_empty_text() -> str:
|
||||
"""
|
||||
构建状态面板空态提示文案。
|
||||
|
||||
:return: 空态提示文案
|
||||
"""
|
||||
return STATUS_PANEL_COPY['empty']
|
||||
|
||||
@staticmethod
|
||||
def build_signal_rail_empty_text() -> str:
|
||||
"""
|
||||
构建信号带空态提示文案。
|
||||
|
||||
:return: 空态提示文案
|
||||
"""
|
||||
return SIGNAL_RAIL_COPY['empty']
|
||||
|
||||
@staticmethod
|
||||
def build_more_detail_hint() -> str:
|
||||
"""
|
||||
构建查看更多提示文案。
|
||||
|
||||
:return: 提示文案
|
||||
"""
|
||||
return GENERAL_COPY['more_detail_hint']
|
||||
|
||||
@staticmethod
|
||||
def build_empty_state_suggestion() -> str:
|
||||
"""
|
||||
构建通用空态建议文案。
|
||||
|
||||
:return: 建议文案
|
||||
"""
|
||||
return STATE_SUGGESTIONS['empty']
|
||||
|
||||
@staticmethod
|
||||
def build_loading_state_suggestion() -> str:
|
||||
"""
|
||||
构建通用加载态建议文案。
|
||||
|
||||
:return: 建议文案
|
||||
"""
|
||||
return STATE_SUGGESTIONS['loading']
|
||||
|
||||
@staticmethod
|
||||
def build_failure_state_suggestion() -> str:
|
||||
"""
|
||||
构建通用失败态建议文案。
|
||||
|
||||
:return: 建议文案
|
||||
"""
|
||||
return STATE_SUGGESTIONS['failure']
|
||||
|
||||
@staticmethod
|
||||
def build_dashboard_failure_suggestion() -> str:
|
||||
"""
|
||||
构建首页失败态建议文案。
|
||||
|
||||
:return: 建议文案
|
||||
"""
|
||||
return STATE_SUGGESTIONS['dashboard_failure']
|
||||
|
||||
@staticmethod
|
||||
def build_dashboard_empty_suggestion() -> str:
|
||||
"""
|
||||
构建首页空态建议文案。
|
||||
|
||||
:return: 建议文案
|
||||
"""
|
||||
return STATE_SUGGESTIONS['dashboard_empty']
|
||||
|
||||
@staticmethod
|
||||
def build_state_section_title(title_key: str) -> str:
|
||||
"""
|
||||
根据状态区块标识构建标题文案。
|
||||
|
||||
:param title_key: 区块标识
|
||||
:return: 标题文案
|
||||
"""
|
||||
normalized_key = str(title_key).strip().lower()
|
||||
return STATE_SECTION_TITLES.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_dashboard_hero_title() -> str:
|
||||
"""
|
||||
构建首页 Hero 标题文案。
|
||||
|
||||
:return: 标题文案
|
||||
"""
|
||||
return DASHBOARD_HERO_COPY['title']
|
||||
|
||||
@staticmethod
|
||||
def build_dashboard_hero_subtitle() -> str:
|
||||
"""
|
||||
构建首页 Hero 副标题文案。
|
||||
|
||||
:return: 副标题文案
|
||||
"""
|
||||
return DASHBOARD_HERO_COPY['subtitle']
|
||||
|
||||
@staticmethod
|
||||
def build_browser_empty_record_copy(field_key: str) -> str:
|
||||
"""
|
||||
根据字段标识构建浏览页空记录文案。
|
||||
|
||||
:param field_key: 字段标识
|
||||
:return: 文案
|
||||
"""
|
||||
normalized_key = str(field_key).strip().lower()
|
||||
return BROWSER_EMPTY_RECORD_COPY.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_browser_loading_copy(field_key: str) -> str:
|
||||
"""
|
||||
根据字段标识构建浏览页加载占位文案。
|
||||
|
||||
:param field_key: 字段标识
|
||||
:return: 文案
|
||||
"""
|
||||
normalized_key = str(field_key).strip().lower()
|
||||
return BROWSER_LOADING_COPY.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_detail_empty_section_copy(field_key: str) -> str:
|
||||
"""
|
||||
根据字段标识构建详情页空态文案。
|
||||
|
||||
:param field_key: 字段标识
|
||||
:return: 文案
|
||||
"""
|
||||
normalized_key = str(field_key).strip().lower()
|
||||
return DETAIL_EMPTY_SECTION_COPY.get(normalized_key, normalized_key)
|
||||
|
||||
@staticmethod
|
||||
def build_refresh_page_suggestion(page_name: str, detail: str) -> str:
|
||||
"""
|
||||
构建详情页场景下的统一建议操作文案。
|
||||
|
||||
:param page_name: 页面名称
|
||||
:param detail: 后续动作描述
|
||||
:return: 建议文案
|
||||
"""
|
||||
return f'可按 [R] 刷新,或进入{page_name}页面{detail}'
|
||||
|
||||
@staticmethod
|
||||
def build_dashboard_page_suggestion(page_name: str, detail: str) -> str:
|
||||
"""
|
||||
构建首页总览面板场景下的统一建议操作文案。
|
||||
|
||||
:param page_name: 页面名称
|
||||
:param detail: 后续动作描述
|
||||
:return: 建议文案
|
||||
"""
|
||||
return f'按 [R] 刷新,或进入{page_name}页面{detail}'
|
||||
|
||||
@staticmethod
|
||||
def build_unavailable_record_title(resource_name: str) -> str:
|
||||
"""
|
||||
构建浏览页失败兜底记录标题。
|
||||
|
||||
:param resource_name: 资源名称
|
||||
:return: 标题文本
|
||||
"""
|
||||
return f'{resource_name}数据暂不可用'
|
||||
|
||||
@staticmethod
|
||||
def build_load_failure_section_title(resource_name: str) -> str:
|
||||
"""
|
||||
构建浏览页失败兜底分区标题。
|
||||
|
||||
:param resource_name: 资源名称
|
||||
:return: 分区标题
|
||||
"""
|
||||
return f'{resource_name}加载失败'
|
||||
|
||||
@staticmethod
|
||||
def build_empty_record_title(resource_name: str) -> str:
|
||||
"""
|
||||
构建浏览页空记录标题。
|
||||
|
||||
:param resource_name: 资源名称
|
||||
:return: 标题文本
|
||||
"""
|
||||
return f'暂无{resource_name}'
|
||||
|
||||
@staticmethod
|
||||
def build_unavailable_subtitle(resource_name: str, message: str) -> str:
|
||||
"""
|
||||
构建浏览页数据不可用副标题。
|
||||
|
||||
:param resource_name: 资源名称
|
||||
:param message: 错误摘要
|
||||
:return: 副标题文本
|
||||
"""
|
||||
return f'{resource_name}数据不可用:{message}'
|
||||
|
||||
@staticmethod
|
||||
def build_empty_record_summary(detail: str) -> str:
|
||||
"""
|
||||
构建浏览页空记录摘要。
|
||||
|
||||
:param detail: 摘要说明
|
||||
:return: 摘要文本
|
||||
"""
|
||||
return detail
|
||||
|
||||
@staticmethod
|
||||
def build_loaded_collection_subtitle(count: int, unit: str, detail: str) -> str:
|
||||
"""
|
||||
构建浏览页“已加载”副标题。
|
||||
|
||||
:param count: 数量
|
||||
:param unit: 单位
|
||||
:param detail: 补充说明
|
||||
:return: 副标题文本
|
||||
"""
|
||||
return f'已加载 {count} {unit},{detail}'
|
||||
|
||||
@staticmethod
|
||||
def build_summary_with_message(summary: str, message: str) -> str:
|
||||
"""
|
||||
构建带结果摘要补充的副标题。
|
||||
|
||||
:param summary: 主摘要
|
||||
:param message: 结果消息
|
||||
:return: 副标题文本
|
||||
"""
|
||||
return f'{summary} | {message}'
|
||||
|
||||
@staticmethod
|
||||
def build_count_detail_subtitle(prefix: str, count: int, unit: str, suffix: str) -> str:
|
||||
"""
|
||||
构建带数量的详情页副标题。
|
||||
|
||||
:param prefix: 前缀文本
|
||||
:param count: 数量
|
||||
:param unit: 数量单位
|
||||
:param suffix: 后缀文本
|
||||
:return: 副标题文本
|
||||
"""
|
||||
return f'{prefix} {count} {unit}{suffix}'
|
||||
|
||||
@staticmethod
|
||||
def build_value_detail_subtitle(prefix: str, value: str, suffix: str) -> str:
|
||||
"""
|
||||
构建带单值信息的详情页副标题。
|
||||
|
||||
:param prefix: 前缀文本
|
||||
:param value: 值文本
|
||||
:param suffix: 后缀文本
|
||||
:return: 副标题文本
|
||||
"""
|
||||
return f'{prefix} {value}{suffix}'
|
||||
|
||||
@staticmethod
|
||||
def build_workspace_hero_lines(
|
||||
*,
|
||||
view_label: str,
|
||||
title: str,
|
||||
subtitle: str,
|
||||
env: str,
|
||||
summary: str,
|
||||
refreshed_at: str,
|
||||
shortcut_hint: str,
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建工作区顶部摘要文本行。
|
||||
|
||||
:param view_label: 当前页面显示名称
|
||||
:param title: 页面标题
|
||||
:param subtitle: 页面副标题
|
||||
:param env: 当前环境
|
||||
:param summary: 页面摘要
|
||||
:param refreshed_at: 刷新时间
|
||||
:param shortcut_hint: 快捷键提示
|
||||
:return: 文本行列表
|
||||
"""
|
||||
return [
|
||||
f'[{view_label} 控制台]',
|
||||
title,
|
||||
subtitle,
|
||||
'',
|
||||
f'运行环境 · {env}',
|
||||
f'当前页面 · {view_label}',
|
||||
f'运行摘要 · {summary}',
|
||||
f'操作提示 · {shortcut_hint}',
|
||||
f'最近刷新 · {refreshed_at}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_navigation_item_lines(index: int, label: str, shortcut: str, description: str) -> list[str]:
|
||||
"""
|
||||
构建左侧导航项文案。
|
||||
|
||||
:param index: 导航索引
|
||||
:param label: 导航标题
|
||||
:param shortcut: 快捷键
|
||||
:param description: 导航说明
|
||||
:return: 文本行列表
|
||||
"""
|
||||
return [
|
||||
f'▌ {WORKSPACE_LABELS["menu"]} {index + 1:02d} · {label}',
|
||||
f'│ {WORKSPACE_LABELS["shortcut"]} [{shortcut.upper()}] · {description}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_section_item_lines(index: int, title: str, status_badge: str, preview: str) -> list[str]:
|
||||
"""
|
||||
构建详情页分区导航项文案。
|
||||
|
||||
:param index: 分区索引
|
||||
:param title: 分区标题
|
||||
:param status_badge: 状态徽标
|
||||
:param preview: 预览文本
|
||||
:return: 文本行列表
|
||||
"""
|
||||
return [
|
||||
f'▌ {WORKSPACE_LABELS["section"]} {index + 1:02d} · {title}',
|
||||
f'│ {status_badge} {preview}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_record_item_lines(index: int, title: str, status_badge: str, summary: str) -> list[str]:
|
||||
"""
|
||||
构建浏览页记录导航项文案。
|
||||
|
||||
:param index: 记录索引
|
||||
:param title: 记录标题
|
||||
:param status_badge: 状态徽标
|
||||
:param summary: 记录摘要
|
||||
:return: 文本行列表
|
||||
"""
|
||||
return [
|
||||
f'▌ {WORKSPACE_LABELS["record"]} {index + 1:02d} · {title}',
|
||||
f'│ {status_badge} {summary}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_section_detail_lines(title: str, status_label: str, status_badge: str, body: str) -> list[str]:
|
||||
"""
|
||||
构建详情页右侧分区内容文案。
|
||||
|
||||
:param title: 分区标题
|
||||
:param status_label: 分区状态
|
||||
:param status_badge: 状态徽标
|
||||
:param body: 主体文本
|
||||
:return: 文本行列表
|
||||
"""
|
||||
return [
|
||||
f'{status_badge} {title}',
|
||||
f'{WORKSPACE_LABELS["status"]} · {status_label}',
|
||||
'',
|
||||
body,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_browser_action_panel_lines(action_lines: list[str], feedback_lines: list[str]) -> list[str]:
|
||||
"""
|
||||
构建浏览页动作面板文案。
|
||||
|
||||
:param action_lines: 可执行动作文本
|
||||
:param feedback_lines: 最近动作反馈文本
|
||||
:return: 文本行列表
|
||||
"""
|
||||
lines = [
|
||||
BROWSER_ACTION_PANEL_COPY['action_section_title'],
|
||||
*[f'> {line}' for line in action_lines],
|
||||
'',
|
||||
BROWSER_ACTION_PANEL_COPY['operation_section_title'],
|
||||
]
|
||||
lines.extend(ACTION_HINT_COPY['operation_hint_lines'])
|
||||
if feedback_lines:
|
||||
lines.extend(
|
||||
[
|
||||
'',
|
||||
BROWSER_ACTION_PANEL_COPY['recent_action_section_title'],
|
||||
*[f'> {line}' for line in feedback_lines],
|
||||
]
|
||||
)
|
||||
return lines
|
||||
|
||||
@staticmethod
|
||||
def build_status_panel_text(title: str, status_code: str, status_label: str, body: str) -> str:
|
||||
"""
|
||||
构建状态面板渲染文本。
|
||||
|
||||
:param title: 面板标题
|
||||
:param status_code: 状态码
|
||||
:param status_label: 状态标签
|
||||
:param body: 面板正文
|
||||
:return: 渲染文本
|
||||
"""
|
||||
return f'{status_code} {title}\n{WORKSPACE_LABELS["status"]} · {status_label}\n\n{body}'
|
||||
|
||||
@staticmethod
|
||||
def build_metric_panel_text(title: str, value: str, status_code: str, status_label: str, hint: str) -> str:
|
||||
"""
|
||||
构建指标卡渲染文本。
|
||||
|
||||
:param title: 指标标题
|
||||
:param value: 指标值
|
||||
:param status_code: 状态码
|
||||
:param status_label: 状态标签
|
||||
:param hint: 提示文本
|
||||
:return: 渲染文本
|
||||
"""
|
||||
return (
|
||||
f'{METRIC_COPY["label"]} · {title}\n'
|
||||
f'────────────────────────\n'
|
||||
f'{METRIC_COPY["value_label"]} · {value}\n'
|
||||
f'{WORKSPACE_LABELS["status"]} · {status_code} / {status_label}\n'
|
||||
f'{METRIC_COPY["hint_label"]} · {hint}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_signal_rail_text(pulse: str, body: str) -> str:
|
||||
"""
|
||||
构建首页信号带渲染文本。
|
||||
|
||||
:param pulse: 当前脉冲字符
|
||||
:param body: 主体文本
|
||||
:return: 渲染文本
|
||||
"""
|
||||
return '\n'.join(
|
||||
[
|
||||
f'{SIGNAL_RAIL_COPY["title"]} {pulse}',
|
||||
'────────────────────────',
|
||||
body or SIGNAL_RAIL_COPY['empty'],
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_workspace_header_lines(
|
||||
*,
|
||||
env: str,
|
||||
view_label: str,
|
||||
timestamp: str,
|
||||
shortcut_hint: str,
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建工作区顶部状态栏文本行。
|
||||
|
||||
:param env: 当前环境
|
||||
:param view_label: 当前页面显示名称
|
||||
:param timestamp: 当前时间
|
||||
:param shortcut_hint: 快捷键提示
|
||||
:return: 文本行列表
|
||||
"""
|
||||
return [
|
||||
f'ShiErFeng 控制台 · 环境 {env.upper()} · 页面 {view_label}',
|
||||
f'快捷键 · {shortcut_hint} 时间 {timestamp}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_dashboard_signal_lines(
|
||||
*,
|
||||
status_track: str,
|
||||
fail_count: int,
|
||||
warn_count: int,
|
||||
ok_count: int,
|
||||
total_count: int,
|
||||
navigation_shortcut_hint: str,
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建首页信号带文案。
|
||||
|
||||
:param status_track: 面板状态轨迹
|
||||
:param fail_count: 失败数量
|
||||
:param warn_count: 警告数量
|
||||
:param ok_count: 正常数量
|
||||
:param total_count: 总数量
|
||||
:param navigation_shortcut_hint: 页面快捷键提示
|
||||
:return: 文本行列表
|
||||
"""
|
||||
return [
|
||||
f'面板轨迹 · {status_track or "-"}',
|
||||
f'风险分布 · 失败 {fail_count:02d} 警告 {warn_count:02d} 正常 {ok_count:02d} 合计 {total_count:02d}',
|
||||
f'快捷入口 · 页面 {navigation_shortcut_hint}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_dashboard_summary_text(total_count: int, ok_count: int, warn_count: int, fail_count: int) -> str:
|
||||
"""
|
||||
构建首页摘要文本。
|
||||
|
||||
:param total_count: 面板总数
|
||||
:param ok_count: 正常数量
|
||||
:param warn_count: 警告数量
|
||||
:param fail_count: 失败数量
|
||||
:return: 摘要文本
|
||||
"""
|
||||
posture = '存在风险' if fail_count else '需要关注' if warn_count else '运行稳定'
|
||||
return (
|
||||
f'当前态势:{posture}。共 {total_count} 个面板,正常 {ok_count} 个,'
|
||||
f'警告 {warn_count} 个,失败 {fail_count} 个。'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_detail_summary_text(total_count: int, ok_count: int, warn_count: int, fail_count: int) -> str:
|
||||
"""
|
||||
构建详情页摘要文本。
|
||||
|
||||
:param total_count: 分区总数
|
||||
:param ok_count: 正常数量
|
||||
:param warn_count: 警告数量
|
||||
:param fail_count: 失败数量
|
||||
:return: 摘要文本
|
||||
"""
|
||||
return (
|
||||
f'当前页共 {total_count} 个分区,正常 {ok_count} 个,警告 {warn_count} 个,失败 {fail_count} 个。'
|
||||
'左侧上下切页,左右切换当前焦点区域。'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_browser_summary_text(
|
||||
total_count: int,
|
||||
ok_count: int,
|
||||
warn_count: int,
|
||||
fail_count: int,
|
||||
action_hint: str,
|
||||
) -> str:
|
||||
"""
|
||||
构建浏览页摘要文本。
|
||||
|
||||
:param total_count: 记录总数
|
||||
:param ok_count: 正常数量
|
||||
:param warn_count: 警告数量
|
||||
:param fail_count: 失败数量
|
||||
:param action_hint: 动作提示文案
|
||||
:return: 摘要文本
|
||||
"""
|
||||
return (
|
||||
f'当前页共 {total_count} 条记录,正常 {ok_count} 条,警告 {warn_count} 条,失败 {fail_count} 条。'
|
||||
f'切换记录后,右侧会联动展示概览与分区详情。{action_hint}'
|
||||
)
|
||||
@@ -0,0 +1,581 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cli.utils import SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiagnosticPagePolicy:
|
||||
"""
|
||||
TUI 页面诊断策略定义。
|
||||
|
||||
:param focus_terms: 页面聚焦词
|
||||
:param subtitle_builder: 页面摘要构建函数
|
||||
"""
|
||||
|
||||
focus_terms: tuple[str, ...]
|
||||
subtitle_builder: Callable[..., str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiagnosticPagePolicyRegistry:
|
||||
"""
|
||||
TUI 页面诊断策略注册表。
|
||||
|
||||
:param policies: 页面诊断策略映射
|
||||
"""
|
||||
|
||||
policies: dict[str, DiagnosticPagePolicy]
|
||||
|
||||
def get(self, page_key: str) -> DiagnosticPagePolicy:
|
||||
"""
|
||||
读取指定页面诊断策略。
|
||||
|
||||
:param page_key: 页面标识
|
||||
:return: 页面诊断策略
|
||||
:raises KeyError: 页面未注册时抛出异常
|
||||
"""
|
||||
return self.policies[page_key]
|
||||
|
||||
|
||||
class TuiDiagnosticService:
|
||||
"""
|
||||
统一封装 TUI 页面诊断提示与摘要拼装逻辑。
|
||||
|
||||
该服务通过页面级诊断策略注册表管理各页面的聚焦词和 subtitle 规则,
|
||||
供 adapter、dashboard 等对象复用。
|
||||
|
||||
:param page_policy_registry: 页面诊断策略注册表
|
||||
"""
|
||||
|
||||
def __init__(self, page_policy_registry: DiagnosticPagePolicyRegistry) -> None:
|
||||
"""
|
||||
初始化诊断服务。
|
||||
|
||||
:param page_policy_registry: 页面诊断策略注册表
|
||||
:return: None
|
||||
"""
|
||||
self.page_policy_registry = page_policy_registry
|
||||
|
||||
@staticmethod
|
||||
def build_focus_hint(terms: tuple[str, ...]) -> str:
|
||||
"""
|
||||
将诊断聚焦词渲染为统一提示文本。
|
||||
|
||||
:param terms: 聚焦词元组
|
||||
:return: 提示文本
|
||||
"""
|
||||
return f'聚焦:{" / ".join(terms)}'
|
||||
|
||||
def build_page_focus_hint(self, page_key: str) -> str:
|
||||
"""
|
||||
构建指定页面统一聚焦词。
|
||||
|
||||
:param page_key: 页面标识
|
||||
:return: 聚焦提示
|
||||
"""
|
||||
return self.build_focus_hint(self.page_policy_registry.get(page_key).focus_terms)
|
||||
|
||||
def build_database_focus_hint(self) -> str:
|
||||
"""
|
||||
构建数据库页统一聚焦词。
|
||||
|
||||
:return: 聚焦提示
|
||||
"""
|
||||
return self.build_page_focus_hint('database')
|
||||
|
||||
def build_ops_focus_hint(self) -> str:
|
||||
"""
|
||||
构建运维页统一聚焦词。
|
||||
|
||||
:return: 聚焦提示
|
||||
"""
|
||||
return self.build_page_focus_hint('ops')
|
||||
|
||||
def build_cache_focus_hint(self) -> str:
|
||||
"""
|
||||
构建缓存页统一聚焦词。
|
||||
|
||||
:return: 聚焦提示
|
||||
"""
|
||||
return self.build_page_focus_hint('cache')
|
||||
|
||||
def build_app_focus_hint(self) -> str:
|
||||
"""
|
||||
构建应用页统一聚焦词。
|
||||
|
||||
:return: 聚焦提示
|
||||
"""
|
||||
return self.build_page_focus_hint('app')
|
||||
|
||||
def build_jobs_focus_hint(self) -> str:
|
||||
"""
|
||||
构建任务页统一聚焦词。
|
||||
|
||||
:return: 聚焦提示
|
||||
"""
|
||||
return self.build_page_focus_hint('jobs')
|
||||
|
||||
def build_configs_focus_hint(self) -> str:
|
||||
"""
|
||||
构建参数配置页统一聚焦词。
|
||||
|
||||
:return: 聚焦提示
|
||||
"""
|
||||
return self.build_page_focus_hint('configs')
|
||||
|
||||
def build_gen_focus_hint(self) -> str:
|
||||
"""
|
||||
构建代码生成页统一聚焦词。
|
||||
|
||||
:return: 聚焦提示
|
||||
"""
|
||||
return self.build_page_focus_hint('gen')
|
||||
|
||||
def build_crypto_focus_hint(self) -> str:
|
||||
"""
|
||||
构建加密页统一聚焦词。
|
||||
|
||||
:return: 聚焦提示
|
||||
"""
|
||||
return self.build_page_focus_hint('crypto')
|
||||
|
||||
@staticmethod
|
||||
def _build_app_subtitle(
|
||||
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,
|
||||
*,
|
||||
focus_hint: str,
|
||||
) -> str:
|
||||
"""
|
||||
构建应用页诊断摘要。
|
||||
|
||||
: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 focus_hint: 聚焦提示
|
||||
: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
|
||||
|
||||
if not env_ok or not config_ok:
|
||||
return f'{focus_hint} | 应用基础信息读取异常,优先确认环境映射与配置摘要'
|
||||
if not doctor_ok:
|
||||
return f'{focus_hint} | 启动前检查异常,优先确认数据库、Redis 与加密组件状态'
|
||||
if not completion_ok:
|
||||
return f'{focus_hint} | 补全诊断读取异常,优先确认项目目录与 shell 补全配置'
|
||||
if not routes_ok:
|
||||
return f'{focus_hint} | 路由摘要读取异常,优先确认应用初始化与路由注册状态'
|
||||
return (
|
||||
f'{focus_hint} | 当前已加载 {route_count} 条注册路由,'
|
||||
'可继续查看环境映射、配置摘要、启动前检查、补全诊断与路由状态'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_database_subtitle(
|
||||
revision_payload: dict[str, Any] | None,
|
||||
check_payload: dict[str, Any] | None,
|
||||
heads_payload: dict[str, Any] | None,
|
||||
*,
|
||||
focus_hint: str,
|
||||
) -> str:
|
||||
"""
|
||||
构建数据库页诊断摘要。
|
||||
|
||||
:param revision_payload: `db current` 结果
|
||||
:param check_payload: `db check` 结果
|
||||
:param heads_payload: `db heads` 结果
|
||||
:param focus_hint: 聚焦提示
|
||||
:return: 页面摘要
|
||||
"""
|
||||
revision = revision_payload.get('currentRevision', '-') if isinstance(revision_payload, dict) else '-'
|
||||
revision_text = SHELL_TEXT_FORMATTER.truncate_text(str(revision or '-'), 48)
|
||||
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))
|
||||
heads_items = heads_payload.get('items') if isinstance(heads_payload, dict) else None
|
||||
heads_count = len(heads_items) if isinstance(heads_items, list) else 0
|
||||
|
||||
if not revision_ok or not check_ok:
|
||||
return f'{focus_hint} | 数据库异常,优先确认迁移版本、连接状态与 heads 信息'
|
||||
if not heads_ok or heads_count != 1:
|
||||
return f'{focus_hint} | 数据库存在迁移分叉风险,优先确认 heads 和历史版本'
|
||||
return f'{focus_hint} | 数据库基线正常,当前 revision {revision_text},可继续查看连接、heads 和历史版本'
|
||||
|
||||
@staticmethod
|
||||
def _build_ops_subtitle(
|
||||
health_payload: dict[str, Any] | None,
|
||||
deps_payload: dict[str, Any] | None,
|
||||
server_payload: dict[str, Any] | None,
|
||||
*,
|
||||
focus_hint: str,
|
||||
) -> str:
|
||||
"""
|
||||
构建运维页诊断摘要。
|
||||
|
||||
:param health_payload: `ops health` 结果
|
||||
:param deps_payload: `ops deps` 结果
|
||||
:param server_payload: `ops server-info` 结果
|
||||
:param focus_hint: 聚焦提示
|
||||
: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))
|
||||
|
||||
if not health_ok:
|
||||
return f'{focus_hint} | 运维探活存在异常,优先核对数据库/Redis 连通性与依赖版本'
|
||||
if not deps_ok:
|
||||
return f'{focus_hint} | 运维依赖存在异常,优先核对缺失依赖与版本兼容性'
|
||||
if not server_ok:
|
||||
return f'{focus_hint} | 服务器信息采集异常,优先重新检查主机资源与运行环境'
|
||||
return f'{focus_hint} | 基础探活和依赖状态正常,可继续查看服务器资源与磁盘样本'
|
||||
|
||||
@staticmethod
|
||||
def _build_cache_subtitle(
|
||||
stats_payload: dict[str, Any] | None,
|
||||
matched_count: int,
|
||||
*,
|
||||
focus_hint: str,
|
||||
) -> str:
|
||||
"""
|
||||
构建缓存页诊断摘要。
|
||||
|
||||
:param stats_payload: `cache stats` 结果
|
||||
:param matched_count: 当前筛选后缓存名数量
|
||||
:param focus_hint: 聚焦提示
|
||||
:return: 页面摘要
|
||||
"""
|
||||
db_size = stats_payload.get('dbSize', '-') if isinstance(stats_payload, dict) else '-'
|
||||
client_count = '-'
|
||||
if isinstance(stats_payload, dict) and isinstance(stats_payload.get('info'), dict):
|
||||
client_count = stats_payload['info'].get('connected_clients', '-') # type: ignore[index]
|
||||
return (
|
||||
f'{focus_hint} | 缓存基线正常,当前已加载 {matched_count} 个缓存名,'
|
||||
f'Redis 键数 {db_size},连接数 {client_count},可继续查看键列表、键值样本和 TTL'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_jobs_subtitle(
|
||||
filter_label: str,
|
||||
matched_count: int,
|
||||
failed_job_names: set[str],
|
||||
paused_count: int,
|
||||
*,
|
||||
focus_hint: str,
|
||||
) -> str:
|
||||
"""
|
||||
构建任务页诊断摘要。
|
||||
|
||||
:param filter_label: 当前筛选标签
|
||||
:param matched_count: 当前匹配任务数
|
||||
:param failed_job_names: 最近失败任务集合
|
||||
:param paused_count: 暂停任务数量
|
||||
:param focus_hint: 聚焦提示
|
||||
:return: 页面摘要
|
||||
"""
|
||||
if failed_job_names:
|
||||
return (
|
||||
f'{focus_hint} | 当前筛选:{filter_label},已匹配 {matched_count} 条任务,'
|
||||
f'失败任务 {len(failed_job_names)} 个,暂停任务 {paused_count} 个'
|
||||
)
|
||||
return (
|
||||
f'{focus_hint} | 当前筛选:{filter_label},已匹配 {matched_count} 条任务,'
|
||||
f'当前没有失败任务,暂停任务 {paused_count} 个'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_configs_subtitle(
|
||||
filter_label: str,
|
||||
matched_count: int,
|
||||
mismatch_count: int,
|
||||
drift_count: int,
|
||||
*,
|
||||
focus_hint: str,
|
||||
) -> str:
|
||||
"""
|
||||
构建参数配置页诊断摘要。
|
||||
|
||||
:param filter_label: 当前筛选标签
|
||||
:param matched_count: 当前匹配配置数
|
||||
:param mismatch_count: 值不一致数量
|
||||
:param drift_count: 缓存漂移数量
|
||||
:param focus_hint: 聚焦提示
|
||||
:return: 页面摘要
|
||||
"""
|
||||
return (
|
||||
f'{focus_hint} | 当前筛选:{filter_label},已匹配 {matched_count} 项配置,'
|
||||
f'值不一致 {mismatch_count} 项,缓存漂移 {drift_count} 项'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_gen_subtitle(
|
||||
matched_count: int,
|
||||
importable_count: int,
|
||||
*,
|
||||
focus_hint: str,
|
||||
) -> str:
|
||||
"""
|
||||
构建代码生成页诊断摘要。
|
||||
|
||||
:param matched_count: 当前匹配业务表数
|
||||
:param importable_count: 可导入物理表数
|
||||
:param focus_hint: 聚焦提示
|
||||
:return: 页面摘要
|
||||
"""
|
||||
return (
|
||||
f'{focus_hint} | 当前已匹配 {matched_count} 张业务表,'
|
||||
f'可导入物理表 {importable_count} 张,可继续查看表定义、预检查和代码预览'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_crypto_subtitle(
|
||||
validate_payload: dict[str, Any] | None,
|
||||
public_payload: dict[str, Any] | None,
|
||||
*,
|
||||
focus_hint: str,
|
||||
) -> str:
|
||||
"""
|
||||
构建加密页诊断摘要。
|
||||
|
||||
:param validate_payload: `crypto validate` 结果
|
||||
:param public_payload: `crypto export-public` 结果
|
||||
:param focus_hint: 聚焦提示
|
||||
: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
|
||||
current_kid = public_key_payload.get('kid', '-') if isinstance(public_key_payload, dict) else '-'
|
||||
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
|
||||
|
||||
if not validate_ok:
|
||||
return f'{focus_hint} | 运行校验失败,优先确认传输加密配置与环境变量'
|
||||
if not public_ok or not isinstance(public_key_payload, dict):
|
||||
return f'{focus_hint} | 公钥导出结果异常,优先确认当前 KID 与公钥内容'
|
||||
return (
|
||||
f'{focus_hint} | 当前 KID {current_kid},兼容版本 {supported_count} 个,'
|
||||
'可继续查看公钥身份、兼容版本与轮换预演入口'
|
||||
)
|
||||
|
||||
def build_app_diagnostic_subtitle(
|
||||
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,
|
||||
) -> str:
|
||||
"""
|
||||
构建应用页统一诊断摘要。
|
||||
|
||||
: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` 结果
|
||||
:return: 页面摘要
|
||||
"""
|
||||
return self.page_policy_registry.get('app').subtitle_builder(
|
||||
env_payload,
|
||||
config_payload,
|
||||
doctor_payload,
|
||||
routes_payload,
|
||||
completion_payload,
|
||||
focus_hint=self.build_app_focus_hint(),
|
||||
)
|
||||
|
||||
def build_database_diagnostic_subtitle(
|
||||
self,
|
||||
revision_payload: dict[str, Any] | None,
|
||||
check_payload: dict[str, Any] | None,
|
||||
heads_payload: dict[str, Any] | None,
|
||||
) -> str:
|
||||
"""
|
||||
构建数据库页统一诊断摘要。
|
||||
|
||||
:param revision_payload: `db current` 结果
|
||||
:param check_payload: `db check` 结果
|
||||
:param heads_payload: `db heads` 结果
|
||||
:return: 页面摘要
|
||||
"""
|
||||
return self.page_policy_registry.get('database').subtitle_builder(
|
||||
revision_payload,
|
||||
check_payload,
|
||||
heads_payload,
|
||||
focus_hint=self.build_database_focus_hint(),
|
||||
)
|
||||
|
||||
def build_ops_diagnostic_subtitle(
|
||||
self,
|
||||
health_payload: dict[str, Any] | None,
|
||||
deps_payload: dict[str, Any] | None,
|
||||
server_payload: dict[str, Any] | None,
|
||||
) -> str:
|
||||
"""
|
||||
构建运维页统一诊断摘要。
|
||||
|
||||
:param health_payload: `ops health` 结果
|
||||
:param deps_payload: `ops deps` 结果
|
||||
:param server_payload: `ops server-info` 结果
|
||||
:return: 页面摘要
|
||||
"""
|
||||
return self.page_policy_registry.get('ops').subtitle_builder(
|
||||
health_payload,
|
||||
deps_payload,
|
||||
server_payload,
|
||||
focus_hint=self.build_ops_focus_hint(),
|
||||
)
|
||||
|
||||
def build_cache_diagnostic_subtitle(
|
||||
self,
|
||||
stats_payload: dict[str, Any] | None,
|
||||
matched_count: int,
|
||||
) -> str:
|
||||
"""
|
||||
构建缓存页统一诊断摘要。
|
||||
|
||||
:param stats_payload: `cache stats` 结果
|
||||
:param matched_count: 当前筛选后缓存名数量
|
||||
:return: 页面摘要
|
||||
"""
|
||||
return self.page_policy_registry.get('cache').subtitle_builder(
|
||||
stats_payload,
|
||||
matched_count,
|
||||
focus_hint=self.build_cache_focus_hint(),
|
||||
)
|
||||
|
||||
def build_jobs_diagnostic_subtitle(
|
||||
self,
|
||||
filter_label: str,
|
||||
matched_count: int,
|
||||
failed_job_names: set[str],
|
||||
paused_count: int,
|
||||
) -> str:
|
||||
"""
|
||||
构建任务页统一诊断摘要。
|
||||
|
||||
:param filter_label: 当前筛选标签
|
||||
:param matched_count: 当前匹配任务数
|
||||
:param failed_job_names: 最近失败任务集合
|
||||
:param paused_count: 暂停任务数量
|
||||
:return: 页面摘要
|
||||
"""
|
||||
return self.page_policy_registry.get('jobs').subtitle_builder(
|
||||
filter_label,
|
||||
matched_count,
|
||||
failed_job_names,
|
||||
paused_count,
|
||||
focus_hint=self.build_jobs_focus_hint(),
|
||||
)
|
||||
|
||||
def build_configs_diagnostic_subtitle(
|
||||
self,
|
||||
filter_label: str,
|
||||
matched_count: int,
|
||||
mismatch_count: int,
|
||||
drift_count: int,
|
||||
) -> str:
|
||||
"""
|
||||
构建参数配置页统一诊断摘要。
|
||||
|
||||
:param filter_label: 当前筛选标签
|
||||
:param matched_count: 当前匹配配置数
|
||||
:param mismatch_count: 值不一致数量
|
||||
:param drift_count: 缓存漂移数量
|
||||
:return: 页面摘要
|
||||
"""
|
||||
return self.page_policy_registry.get('configs').subtitle_builder(
|
||||
filter_label,
|
||||
matched_count,
|
||||
mismatch_count,
|
||||
drift_count,
|
||||
focus_hint=self.build_configs_focus_hint(),
|
||||
)
|
||||
|
||||
def build_gen_diagnostic_subtitle(
|
||||
self,
|
||||
matched_count: int,
|
||||
importable_count: int,
|
||||
) -> str:
|
||||
"""
|
||||
构建代码生成页统一诊断摘要。
|
||||
|
||||
:param matched_count: 当前匹配业务表数
|
||||
:param importable_count: 可导入物理表数
|
||||
:return: 页面摘要
|
||||
"""
|
||||
return self.page_policy_registry.get('gen').subtitle_builder(
|
||||
matched_count,
|
||||
importable_count,
|
||||
focus_hint=self.build_gen_focus_hint(),
|
||||
)
|
||||
|
||||
def build_crypto_diagnostic_subtitle(
|
||||
self,
|
||||
validate_payload: dict[str, Any] | None,
|
||||
public_payload: dict[str, Any] | None,
|
||||
) -> str:
|
||||
"""
|
||||
构建加密页统一诊断摘要。
|
||||
|
||||
:param validate_payload: `crypto validate` 结果
|
||||
:param public_payload: `crypto export-public` 结果
|
||||
:return: 页面摘要
|
||||
"""
|
||||
return self.page_policy_registry.get('crypto').subtitle_builder(
|
||||
validate_payload,
|
||||
public_payload,
|
||||
focus_hint=self.build_crypto_focus_hint(),
|
||||
)
|
||||
|
||||
|
||||
TUI_DIAGNOSTIC_PAGE_POLICY_REGISTRY = DiagnosticPagePolicyRegistry(
|
||||
policies={
|
||||
'database': DiagnosticPagePolicy(
|
||||
focus_terms=('迁移版本', '连接状态', 'Heads'),
|
||||
subtitle_builder=TuiDiagnosticService._build_database_subtitle,
|
||||
),
|
||||
'ops': DiagnosticPagePolicy(
|
||||
focus_terms=('数据库连通性', 'Redis 连通性', '依赖版本'),
|
||||
subtitle_builder=TuiDiagnosticService._build_ops_subtitle,
|
||||
),
|
||||
'cache': DiagnosticPagePolicy(
|
||||
focus_terms=('Redis 键数', '连接数', '缓存名前缀'),
|
||||
subtitle_builder=TuiDiagnosticService._build_cache_subtitle,
|
||||
),
|
||||
'app': DiagnosticPagePolicy(
|
||||
focus_terms=('环境映射', '配置摘要', '启动前检查'),
|
||||
subtitle_builder=TuiDiagnosticService._build_app_subtitle,
|
||||
),
|
||||
'jobs': DiagnosticPagePolicy(
|
||||
focus_terms=('失败聚合', '暂停任务', '执行轨迹'),
|
||||
subtitle_builder=TuiDiagnosticService._build_jobs_subtitle,
|
||||
),
|
||||
'configs': DiagnosticPagePolicy(
|
||||
focus_terms=('高风险配置', '值不一致', '缓存漂移'),
|
||||
subtitle_builder=TuiDiagnosticService._build_configs_subtitle,
|
||||
),
|
||||
'gen': DiagnosticPagePolicy(
|
||||
focus_terms=('生成前校验', '同步预检查', '代码预览'),
|
||||
subtitle_builder=TuiDiagnosticService._build_gen_subtitle,
|
||||
),
|
||||
'crypto': DiagnosticPagePolicy(
|
||||
focus_terms=('运行校验', '公钥身份', '兼容版本'),
|
||||
subtitle_builder=TuiDiagnosticService._build_crypto_subtitle,
|
||||
),
|
||||
}
|
||||
)
|
||||
TUI_DIAGNOSTIC_SERVICE = TuiDiagnosticService(TUI_DIAGNOSTIC_PAGE_POLICY_REGISTRY)
|
||||
@@ -0,0 +1,177 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuiKeymapRegistry:
|
||||
"""
|
||||
TUI 键位注册表。
|
||||
|
||||
该对象集中维护页面切换快捷键和派生提示文案,避免模块级
|
||||
常量散落并在后续扩展时继续增长。
|
||||
|
||||
:param navigation_shortcuts: 页面导航快捷键映射
|
||||
"""
|
||||
|
||||
navigation_shortcuts: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@staticmethod
|
||||
def normalize_view_key(view_key: str) -> str:
|
||||
"""
|
||||
规范化页面标识。
|
||||
|
||||
:param view_key: 原始页面标识
|
||||
:return: 规范化后的页面标识
|
||||
"""
|
||||
return str(view_key).strip().lower()
|
||||
|
||||
def get_navigation_shortcut(self, view_key: str) -> str:
|
||||
"""
|
||||
获取指定页面的导航快捷键。
|
||||
|
||||
:param view_key: 页面标识
|
||||
:return: 快捷键文本
|
||||
"""
|
||||
normalized_view_key = self.normalize_view_key(view_key)
|
||||
return self.navigation_shortcuts.get(normalized_view_key, '')
|
||||
|
||||
@property
|
||||
def navigation_shortcut_hint(self) -> str:
|
||||
"""
|
||||
构建页面导航快捷键总提示。
|
||||
|
||||
:return: 快捷键提示
|
||||
"""
|
||||
ordered_keys = ['dashboard', 'app', 'ops', 'database', 'cache', 'jobs', 'gen', 'configs', 'crypto']
|
||||
shortcuts = [
|
||||
self.get_navigation_shortcut(view_key).upper()
|
||||
for view_key in ordered_keys
|
||||
if self.get_navigation_shortcut(view_key)
|
||||
]
|
||||
return f'[{" / ".join(shortcuts).replace(" / ", "/")}]'
|
||||
|
||||
@property
|
||||
def sidebar_shortcut_hint(self) -> str:
|
||||
"""
|
||||
获取侧栏快捷键提示。
|
||||
|
||||
:return: 提示文本
|
||||
"""
|
||||
return '[S] 侧栏'
|
||||
|
||||
@property
|
||||
def refresh_shortcut_hint(self) -> str:
|
||||
"""
|
||||
获取刷新快捷键提示。
|
||||
|
||||
:return: 提示文本
|
||||
"""
|
||||
return '[R] 刷新'
|
||||
|
||||
@property
|
||||
def quit_shortcut_hint(self) -> str:
|
||||
"""
|
||||
获取退出快捷键提示。
|
||||
|
||||
:return: 提示文本
|
||||
"""
|
||||
return '[Q] 退出'
|
||||
|
||||
@property
|
||||
def focus_shortcut_hint(self) -> str:
|
||||
"""
|
||||
获取焦点切换快捷键提示。
|
||||
|
||||
:return: 提示文本
|
||||
"""
|
||||
return '[←/→] 切换焦点'
|
||||
|
||||
@property
|
||||
def region_switch_shortcut_hint(self) -> str:
|
||||
"""
|
||||
获取区域切换快捷键提示。
|
||||
|
||||
:return: 提示文本
|
||||
"""
|
||||
return '[←/→] 切换区域'
|
||||
|
||||
@property
|
||||
def scroll_shortcut_hint(self) -> str:
|
||||
"""
|
||||
获取滚动快捷键提示。
|
||||
|
||||
:return: 提示文本
|
||||
"""
|
||||
return '[J/K] 滚动'
|
||||
|
||||
@property
|
||||
def page_scroll_shortcut_hint(self) -> str:
|
||||
"""
|
||||
获取翻页快捷键提示。
|
||||
|
||||
:return: 提示文本
|
||||
"""
|
||||
return '[PgUp/PgDn] 翻页'
|
||||
|
||||
@property
|
||||
def home_end_shortcut_hint(self) -> str:
|
||||
"""
|
||||
获取首尾跳转快捷键提示。
|
||||
|
||||
:return: 提示文本
|
||||
"""
|
||||
return '[Home/End] 首尾'
|
||||
|
||||
@property
|
||||
def browser_interaction_hint(self) -> str:
|
||||
"""
|
||||
构建浏览页交互提示。
|
||||
|
||||
:return: 提示文本
|
||||
"""
|
||||
return (
|
||||
f'浏览键:{self.focus_shortcut_hint} {self.scroll_shortcut_hint} '
|
||||
f'{self.page_scroll_shortcut_hint} {self.home_end_shortcut_hint}'
|
||||
)
|
||||
|
||||
@property
|
||||
def global_shortcut_hint(self) -> str:
|
||||
"""
|
||||
构建全局快捷键提示。
|
||||
|
||||
:return: 提示文本
|
||||
"""
|
||||
return (
|
||||
f'页面 {self.navigation_shortcut_hint} {self.sidebar_shortcut_hint} '
|
||||
f'{self.focus_shortcut_hint} {self.scroll_shortcut_hint} '
|
||||
f'{self.page_scroll_shortcut_hint} {self.home_end_shortcut_hint} '
|
||||
f'{self.refresh_shortcut_hint} {self.quit_shortcut_hint}'
|
||||
)
|
||||
|
||||
@property
|
||||
def hero_shortcut_hint(self) -> str:
|
||||
"""
|
||||
构建首页英雄区快捷键提示。
|
||||
|
||||
:return: 提示文本
|
||||
"""
|
||||
return (
|
||||
f'页面 {self.navigation_shortcut_hint} {self.sidebar_shortcut_hint} '
|
||||
f'{self.region_switch_shortcut_hint} {self.scroll_shortcut_hint} '
|
||||
f'{self.page_scroll_shortcut_hint} {self.home_end_shortcut_hint} '
|
||||
f'{self.refresh_shortcut_hint} {self.quit_shortcut_hint}'
|
||||
)
|
||||
|
||||
|
||||
TUI_KEYMAP_REGISTRY = TuiKeymapRegistry(
|
||||
navigation_shortcuts={
|
||||
'dashboard': 'd',
|
||||
'app': 'a',
|
||||
'ops': 'o',
|
||||
'database': 'b',
|
||||
'cache': 'c',
|
||||
'jobs': 't',
|
||||
'gen': 'g',
|
||||
'configs': 'p',
|
||||
'crypto': 'e',
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
from cli.tui.screens.browser import BrowserScreen
|
||||
from cli.tui.screens.dashboard import DashboardScreen
|
||||
from cli.tui.screens.detail import DetailScreen
|
||||
|
||||
__all__ = ['BrowserScreen', 'DashboardScreen', 'DetailScreen']
|
||||
@@ -0,0 +1,840 @@
|
||||
import asyncio
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, ScrollableContainer, Vertical
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Footer, Static
|
||||
|
||||
from cli.tui.actions import (
|
||||
TUI_ACTION_PRESENTATION_SERVICE,
|
||||
TUI_ACTION_REGISTRY,
|
||||
TuiActionResult,
|
||||
TuiActionSpec,
|
||||
)
|
||||
from cli.tui.adapters import BrowserPageSnapshot, BrowserRecordSnapshot, DetailSectionSnapshot
|
||||
from cli.tui.adapters.models import TUI_ADAPTER_MODEL_RENDERER
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.screens.focus import BaseScreenFocusService, ScreenFocusActionsMixin
|
||||
from cli.tui.screens.interactions import TUI_SCREEN_INTERACTION_SERVICE, ScreenInteractionActionsMixin
|
||||
from cli.tui.screens.summary import STATUS_SUMMARY_BUILDER
|
||||
from cli.tui.search import TUI_SEARCH_SERVICE
|
||||
from cli.tui.widgets import (
|
||||
NavigationItem,
|
||||
RecordNavigator,
|
||||
RecordSummaryView,
|
||||
SectionDetailView,
|
||||
SectionNavigator,
|
||||
WorkspaceHeader,
|
||||
WorkspaceHero,
|
||||
WorkspaceSidebar,
|
||||
)
|
||||
|
||||
|
||||
class BrowserScreenSupport:
|
||||
"""
|
||||
浏览页屏幕支持对象。
|
||||
|
||||
该对象负责浏览页摘要、搜索词、动作解析、空态/加载态分区构建和
|
||||
焦点顺序编排,使 `BrowserScreen` 本体主要保留 Textual 事件桥接。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_summary_text(snapshot: BrowserPageSnapshot, active_view: str) -> str:
|
||||
"""
|
||||
构建浏览页摘要文本。
|
||||
|
||||
:param snapshot: 浏览页快照
|
||||
:param active_view: 当前激活视图
|
||||
:return: 摘要文本
|
||||
"""
|
||||
summary = STATUS_SUMMARY_BUILDER.build(snapshot.records)
|
||||
return TUI_COPY.build_browser_summary_text(
|
||||
total_count=summary.total_count,
|
||||
ok_count=summary.ok_count,
|
||||
warn_count=summary.warn_count,
|
||||
fail_count=summary.fail_count,
|
||||
action_hint=TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint(active_view),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_filter_bar_text(snapshot: BrowserPageSnapshot) -> str:
|
||||
"""
|
||||
构建当前浏览页筛选条文本。
|
||||
|
||||
:param snapshot: 浏览页快照
|
||||
:return: 筛选条文本
|
||||
"""
|
||||
search_context = snapshot.search
|
||||
return TUI_SEARCH_SERVICE.build_filter_bar_text(
|
||||
snapshot.filters,
|
||||
snapshot.active_filter_key,
|
||||
search_query=search_context.query if search_context is not None else '',
|
||||
search_placeholder=search_context.placeholder if search_context is not None else '',
|
||||
search_suggestions=search_context.suggestions if search_context is not None else [],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def current_search_query(snapshot: BrowserPageSnapshot) -> str:
|
||||
"""
|
||||
读取当前浏览页搜索词。
|
||||
|
||||
:param snapshot: 浏览页快照
|
||||
:return: 搜索词
|
||||
"""
|
||||
search_context = snapshot.search
|
||||
return search_context.query if search_context is not None else ''
|
||||
|
||||
@staticmethod
|
||||
def resolve_action(
|
||||
*,
|
||||
active_view: str,
|
||||
slot: str,
|
||||
record: BrowserRecordSnapshot,
|
||||
env: str,
|
||||
) -> TuiActionSpec | None:
|
||||
"""
|
||||
解析当前页面指定槽位对应的动作。
|
||||
|
||||
:param active_view: 当前激活视图
|
||||
:param slot: 动作槽位
|
||||
:param record: 当前选中记录
|
||||
:param env: 当前运行环境
|
||||
:return: 动作定义
|
||||
"""
|
||||
return TUI_ACTION_REGISTRY.resolve_browser_action(
|
||||
view_key=active_view,
|
||||
slot=slot, # type: ignore[arg-type]
|
||||
record=record,
|
||||
env=env,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_record_action_lines(
|
||||
*,
|
||||
active_view: str,
|
||||
record: BrowserRecordSnapshot,
|
||||
env: str,
|
||||
feedback_lines: list[str],
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建当前记录动作面板文本。
|
||||
|
||||
:param active_view: 当前激活视图
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:param feedback_lines: 最近动作反馈文本
|
||||
:return: 动作面板文本
|
||||
"""
|
||||
action_lines = TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_lines(
|
||||
view_key=active_view,
|
||||
record=record,
|
||||
env=env,
|
||||
)
|
||||
return TUI_COPY.build_browser_action_panel_lines(action_lines, feedback_lines)
|
||||
|
||||
@staticmethod
|
||||
def build_empty_record() -> BrowserRecordSnapshot:
|
||||
"""
|
||||
构建浏览页空态兜底记录。
|
||||
|
||||
:return: 记录快照
|
||||
"""
|
||||
return BrowserRecordSnapshot(
|
||||
key='empty',
|
||||
title=TUI_COPY.build_browser_empty_record_copy('title'),
|
||||
status='info',
|
||||
summary=TUI_COPY.build_browser_empty_record_copy('summary'),
|
||||
metadata_lines=[],
|
||||
detail_sections=[
|
||||
DetailSectionSnapshot(
|
||||
title=TUI_COPY.build_browser_empty_record_copy('title'),
|
||||
status='info',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label=TUI_COPY.build_browser_empty_record_copy('label'),
|
||||
empty_value=TUI_COPY.build_browser_empty_record_copy('value'),
|
||||
detail=TUI_COPY.build_browser_empty_record_copy('detail'),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_loading_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建详情加载中的占位分区。
|
||||
|
||||
:return: 占位分区
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title=TUI_COPY.build_browser_loading_copy('title'),
|
||||
status='info',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_loading_lines(
|
||||
loading_label=TUI_COPY.build_browser_loading_copy('label'),
|
||||
loading_value=TUI_COPY.build_browser_loading_copy('value'),
|
||||
detail=TUI_COPY.build_browser_loading_copy('detail'),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_detail_load_failure_sections(error: Exception, resource_name: str) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
构建详情加载失败时的兜底分区。
|
||||
|
||||
:param error: 后台加载异常
|
||||
:param resource_name: 资源名称
|
||||
:return: 失败分区列表
|
||||
"""
|
||||
return [
|
||||
DetailSectionSnapshot(
|
||||
title=TUI_COPY.build_load_failure_section_title(resource_name),
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
{
|
||||
'message': '后台详情加载失败',
|
||||
'error': str(error) or error.__class__.__name__,
|
||||
'hint': '可按 [R] 刷新当前页面,或切换记录后重试',
|
||||
},
|
||||
empty_label='详情状态',
|
||||
empty_value='不可用',
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_empty_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建浏览页详情空态兜底分区。
|
||||
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title=TUI_COPY.build_detail_empty_section_copy('title'),
|
||||
status='info',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label=TUI_COPY.build_detail_empty_section_copy('label'),
|
||||
empty_value=TUI_COPY.build_detail_empty_section_copy('value'),
|
||||
detail=TUI_COPY.build_detail_empty_section_copy('detail_record'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BrowserScreenFocusService(BaseScreenFocusService):
|
||||
"""
|
||||
浏览页焦点服务。
|
||||
|
||||
该对象负责定义浏览页可聚焦组件顺序,以及按左右键和滚动键解析
|
||||
当前目标区域。
|
||||
"""
|
||||
|
||||
def get_focus_order(
|
||||
self,
|
||||
screen: 'BrowserScreen',
|
||||
) -> list[WorkspaceSidebar | RecordNavigator | SectionNavigator | RecordSummaryView | SectionDetailView]:
|
||||
"""
|
||||
定义浏览页可通过左右键切换的焦点顺序。
|
||||
|
||||
:param screen: 当前浏览页屏幕
|
||||
:return: 焦点顺序列表
|
||||
"""
|
||||
return [
|
||||
screen.query_one(WorkspaceSidebar),
|
||||
screen.query_one(RecordNavigator),
|
||||
screen.query_one(SectionNavigator),
|
||||
screen.query_one(RecordSummaryView),
|
||||
screen.query_one(SectionDetailView),
|
||||
]
|
||||
|
||||
def get_default_scroll_target(
|
||||
self,
|
||||
screen: 'BrowserScreen',
|
||||
) -> ScrollableContainer:
|
||||
"""
|
||||
返回浏览页默认滚动容器。
|
||||
|
||||
:param screen: 当前浏览页屏幕
|
||||
:return: 主工作区滚动容器
|
||||
"""
|
||||
return screen.query_one('#workspace-main', ScrollableContainer)
|
||||
|
||||
|
||||
class BrowserScreen(ScreenInteractionActionsMixin, ScreenFocusActionsMixin, Screen[None]):
|
||||
"""
|
||||
TUI 浏览型页面。
|
||||
|
||||
:param snapshot: 浏览页快照
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前激活视图
|
||||
:param navigation_items: 导航项列表
|
||||
:param refreshed_at: 本次刷新时间
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding('left', 'focus_left', TUI_COPY.build_internal_binding_label('focus_left'), show=False),
|
||||
Binding('right', 'focus_right', TUI_COPY.build_internal_binding_label('focus_right'), show=False),
|
||||
Binding('j', 'scroll_focus_down', TUI_COPY.build_internal_binding_label('scroll_down'), show=False),
|
||||
Binding('k', 'scroll_focus_up', TUI_COPY.build_internal_binding_label('scroll_up'), show=False),
|
||||
Binding('pagedown', 'scroll_focus_page_down', TUI_COPY.build_internal_binding_label('page_down'), show=False),
|
||||
Binding('pageup', 'scroll_focus_page_up', TUI_COPY.build_internal_binding_label('page_up'), show=False),
|
||||
Binding('home', 'scroll_focus_home', TUI_COPY.build_internal_binding_label('home'), show=False),
|
||||
Binding('end', 'scroll_focus_end', TUI_COPY.build_internal_binding_label('end'), show=False),
|
||||
Binding('x', 'trigger_primary_action', TUI_COPY.build_internal_binding_label('action_primary'), show=False),
|
||||
Binding('z', 'trigger_secondary_action', TUI_COPY.build_internal_binding_label('action_secondary'), show=False),
|
||||
Binding('y', 'trigger_global_action', TUI_COPY.build_internal_binding_label('action_global'), show=False),
|
||||
Binding('w', 'trigger_utility_action', TUI_COPY.build_internal_binding_label('action_utility'), show=False),
|
||||
Binding('1', 'apply_filter_1', TUI_COPY.build_internal_binding_label('filter_1'), show=False),
|
||||
Binding('2', 'apply_filter_2', TUI_COPY.build_internal_binding_label('filter_2'), show=False),
|
||||
Binding('3', 'apply_filter_3', TUI_COPY.build_internal_binding_label('filter_3'), show=False),
|
||||
Binding('4', 'apply_filter_4', TUI_COPY.build_internal_binding_label('filter_4'), show=False),
|
||||
Binding('/', 'open_search', TUI_COPY.build_internal_binding_label('search'), show=False),
|
||||
Binding('backspace', 'clear_search', TUI_COPY.build_internal_binding_label('clear_search'), show=False),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
snapshot: BrowserPageSnapshot,
|
||||
env: str,
|
||||
active_view: str,
|
||||
navigation_items: list[NavigationItem],
|
||||
refreshed_at: str,
|
||||
action_feedback_lines: list[str] | None = None,
|
||||
support: BrowserScreenSupport | None = None,
|
||||
focus_service: BrowserScreenFocusService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化浏览型页面。
|
||||
|
||||
:param snapshot: 浏览页快照
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前激活视图
|
||||
:param navigation_items: 导航项列表
|
||||
:param refreshed_at: 本次刷新时间
|
||||
:param action_feedback_lines: 最近动作反馈文本
|
||||
:param support: 浏览页屏幕支持对象
|
||||
:param focus_service: 浏览页焦点服务
|
||||
:return: None
|
||||
"""
|
||||
self.snapshot = snapshot
|
||||
self.env = env
|
||||
self.active_view = active_view
|
||||
self.navigation_items = navigation_items
|
||||
self.refreshed_at = refreshed_at
|
||||
self.selected_record_index = 0
|
||||
self.selected_section_index = 0
|
||||
self._is_syncing_sections = False
|
||||
self._record_detail_request_id = 0
|
||||
self._record_detail_task: asyncio.Task[None] | None = None
|
||||
self._action_task: asyncio.Task[None] | None = None
|
||||
self._last_action_result: TuiActionResult | None = None
|
||||
self._action_feedback_lines = list(action_feedback_lines or [])
|
||||
self.support = support or BrowserScreenSupport()
|
||||
self.focus_service = focus_service or BrowserScreenFocusService()
|
||||
super().__init__()
|
||||
|
||||
def _build_summary_text(self) -> str:
|
||||
"""
|
||||
构建浏览页摘要文本。
|
||||
|
||||
:return: 摘要文本
|
||||
"""
|
||||
return self.support.build_summary_text(self.snapshot, self.active_view)
|
||||
|
||||
def _build_filter_bar_text(self) -> str:
|
||||
"""
|
||||
构建当前浏览页筛选条文本。
|
||||
|
||||
:return: 筛选条文本
|
||||
"""
|
||||
return self.support.build_filter_bar_text(self.snapshot)
|
||||
|
||||
def _current_search_query(self) -> str:
|
||||
return self.support.current_search_query(self.snapshot)
|
||||
|
||||
def _apply_filter_shortcut(self, shortcut: str) -> None:
|
||||
"""
|
||||
按快捷键切换当前浏览页筛选条件。
|
||||
|
||||
:param shortcut: 筛选快捷键
|
||||
:return: None
|
||||
"""
|
||||
if not self.snapshot.filters:
|
||||
return
|
||||
target = next((option for option in self.snapshot.filters if option.shortcut == shortcut), None)
|
||||
if target is None or target.key == self.snapshot.active_filter_key:
|
||||
return
|
||||
remember_filter = getattr(self.app, 'remember_browser_filter', None)
|
||||
if callable(remember_filter):
|
||||
remember_filter(self.active_view, target.key)
|
||||
self.app.action_refresh_current_view()
|
||||
|
||||
def _open_search(self) -> None:
|
||||
"""
|
||||
打开当前浏览页搜索输入弹窗。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
TUI_SCREEN_INTERACTION_SERVICE.open_search_dialog(
|
||||
self,
|
||||
self.snapshot.search,
|
||||
self._handle_search_submitted,
|
||||
)
|
||||
|
||||
def _handle_search_submitted(self, query: str | None) -> None:
|
||||
"""
|
||||
处理页内搜索输入结果。
|
||||
|
||||
:param query: 搜索词
|
||||
:return: None
|
||||
"""
|
||||
TUI_SCREEN_INTERACTION_SERVICE.remember_query_and_refresh(self, self.active_view, query)
|
||||
|
||||
def _clear_search(self) -> None:
|
||||
"""
|
||||
清空当前浏览页搜索词。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
TUI_SCREEN_INTERACTION_SERVICE.clear_query_and_refresh(
|
||||
self,
|
||||
self.active_view,
|
||||
self._current_search_query(),
|
||||
)
|
||||
|
||||
def _resolve_action(self, slot: str) -> TuiActionSpec | None:
|
||||
"""
|
||||
解析当前页面指定槽位对应的动作。
|
||||
|
||||
:param slot: 动作槽位
|
||||
:return: 动作定义
|
||||
"""
|
||||
return self.support.resolve_action(
|
||||
active_view=self.active_view,
|
||||
slot=slot,
|
||||
record=self._get_record_or_fallback(self.selected_record_index),
|
||||
env=self.env,
|
||||
)
|
||||
|
||||
def _build_record_action_lines(self, record: BrowserRecordSnapshot) -> list[str]:
|
||||
"""
|
||||
构建当前记录动作面板文本。
|
||||
|
||||
:param record: 当前记录
|
||||
:return: 动作面板文本
|
||||
"""
|
||||
return self.support.build_record_action_lines(
|
||||
active_view=self.active_view,
|
||||
record=record,
|
||||
env=self.env,
|
||||
feedback_lines=self._action_feedback_lines,
|
||||
)
|
||||
|
||||
def _open_action_confirm(self, slot: str) -> None:
|
||||
"""
|
||||
打开指定槽位动作的确认弹窗。
|
||||
|
||||
:param slot: 动作槽位
|
||||
:return: None
|
||||
"""
|
||||
self._action_task = TUI_SCREEN_INTERACTION_SERVICE.confirm_and_schedule_action(
|
||||
self,
|
||||
self._resolve_action(slot),
|
||||
self._action_task,
|
||||
self._execute_action,
|
||||
)
|
||||
|
||||
async def _execute_action(self, action: TuiActionSpec) -> None:
|
||||
"""
|
||||
执行低风险动作并在结束后通知与刷新页面。
|
||||
|
||||
:param action: 动作定义
|
||||
:return: None
|
||||
"""
|
||||
|
||||
async def _handle_result(result: TuiActionResult, feedback_lines: list[str]) -> None:
|
||||
self._last_action_result = result
|
||||
self._action_feedback_lines = feedback_lines
|
||||
await self._render_record_detail(eager=False)
|
||||
|
||||
await TUI_SCREEN_INTERACTION_SERVICE.execute_action_with_feedback(
|
||||
self,
|
||||
action,
|
||||
self.env,
|
||||
self.active_view,
|
||||
_handle_result,
|
||||
)
|
||||
|
||||
def _get_record_or_fallback(self, index: int) -> BrowserRecordSnapshot:
|
||||
"""
|
||||
获取指定索引的记录快照,缺失时返回兜底记录。
|
||||
|
||||
:param index: 记录索引
|
||||
:return: 记录快照
|
||||
"""
|
||||
if 0 <= index < len(self.snapshot.records):
|
||||
return self.snapshot.records[index]
|
||||
return self.support.build_empty_record()
|
||||
|
||||
def _build_loading_section(self) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建详情加载中的占位分区。
|
||||
|
||||
:return: 占位分区
|
||||
"""
|
||||
return self.support.build_loading_section()
|
||||
|
||||
def _build_detail_load_failure_sections(self, error: Exception) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
构建详情加载失败时的兜底分区。
|
||||
|
||||
:param error: 后台加载异常
|
||||
:return: 失败分区列表
|
||||
"""
|
||||
resource_name = self.snapshot.title if '详情' in self.snapshot.title else f'{self.snapshot.title}详情'
|
||||
return self.support.build_detail_load_failure_sections(error, resource_name)
|
||||
|
||||
@staticmethod
|
||||
def _cancel_task(task: asyncio.Task[None] | None) -> None:
|
||||
"""
|
||||
取消指定后台任务。
|
||||
|
||||
:param task: 待取消任务
|
||||
:return: None
|
||||
"""
|
||||
if task is None or task.done():
|
||||
return
|
||||
task.cancel()
|
||||
|
||||
def _cancel_background_tasks(self) -> None:
|
||||
"""
|
||||
取消当前屏幕持有的后台任务。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._record_detail_request_id += 1
|
||||
self._cancel_task(self._record_detail_task)
|
||||
self._cancel_task(self._action_task)
|
||||
|
||||
def _get_sections_for_record(self, index: int, *, eager: bool = False) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
获取指定记录对应的详情分区列表。
|
||||
|
||||
:param index: 记录索引
|
||||
:param eager: 是否立即加载按需详情
|
||||
:return: 详情分区列表
|
||||
"""
|
||||
record = self._get_record_or_fallback(index)
|
||||
if eager:
|
||||
try:
|
||||
detail_sections = record.resolve_detail_sections()
|
||||
except Exception as error:
|
||||
detail_sections = self._build_detail_load_failure_sections(error)
|
||||
elif record.detail_sections:
|
||||
detail_sections = record.detail_sections
|
||||
elif record._cached_detail_sections:
|
||||
detail_sections = list(record._cached_detail_sections)
|
||||
elif record.detail_loader is not None:
|
||||
detail_sections = [self._build_loading_section()]
|
||||
else:
|
||||
detail_sections = []
|
||||
return [*detail_sections, *self.snapshot.shared_sections]
|
||||
|
||||
def _get_section_or_fallback(self, record_index: int, section_index: int) -> DetailSectionSnapshot:
|
||||
"""
|
||||
获取指定记录下的分区快照,缺失时返回兜底分区。
|
||||
|
||||
:param record_index: 记录索引
|
||||
:param section_index: 分区索引
|
||||
:return: 分区快照
|
||||
"""
|
||||
sections = self._get_sections_for_record(record_index)
|
||||
if 0 <= section_index < len(sections):
|
||||
return sections[section_index]
|
||||
return self.support.build_empty_section()
|
||||
|
||||
async def _render_record_detail(self, *, eager: bool = False) -> None:
|
||||
"""
|
||||
根据当前选中记录刷新右侧详情区域。
|
||||
|
||||
:param eager: 是否立即解析完整详情
|
||||
:return: None
|
||||
"""
|
||||
record = self._get_record_or_fallback(self.selected_record_index)
|
||||
sections = self._get_sections_for_record(self.selected_record_index, eager=eager)
|
||||
selected_section = self._get_section_or_fallback(self.selected_record_index, self.selected_section_index)
|
||||
self.query_one(RecordSummaryView).show_record(
|
||||
record,
|
||||
selected_section,
|
||||
self._build_record_action_lines(record),
|
||||
self._current_search_query(),
|
||||
)
|
||||
navigator = self.query_one(SectionNavigator)
|
||||
detail_view = self.query_one(SectionDetailView)
|
||||
self._is_syncing_sections = True
|
||||
try:
|
||||
await navigator.show_sections(
|
||||
sections,
|
||||
initial_index=self.selected_section_index,
|
||||
query=self._current_search_query(),
|
||||
)
|
||||
detail_view.show_section(selected_section, query=self._current_search_query())
|
||||
self._sync_detail_pane_state(selected_section)
|
||||
finally:
|
||||
self._is_syncing_sections = False
|
||||
|
||||
def _sync_detail_pane_state(self, section: DetailSectionSnapshot) -> None:
|
||||
"""
|
||||
依据当前分区状态同步右侧详情容器样式。
|
||||
|
||||
:param section: 当前分区快照
|
||||
:return: None
|
||||
"""
|
||||
detail_pane = self.query_one('#browser-detail-pane', Vertical)
|
||||
detail_pane.remove_class('is-ok', 'is-fail', 'is-warn', 'is-info')
|
||||
normalized = section.status if section.status in {'ok', 'fail', 'warn'} else 'info'
|
||||
detail_pane.add_class(f'is-{normalized}')
|
||||
|
||||
async def _load_record_detail_async(self, index: int, request_id: int) -> None:
|
||||
"""
|
||||
在后台线程中加载指定记录的详情,并在仍为当前选中记录时回填界面。
|
||||
|
||||
:param index: 记录索引
|
||||
:param request_id: 当前加载请求编号
|
||||
:return: None
|
||||
"""
|
||||
record = self._get_record_or_fallback(index)
|
||||
try:
|
||||
await asyncio.to_thread(record.resolve_detail_sections)
|
||||
except Exception as error:
|
||||
failure_sections = self._build_detail_load_failure_sections(error)
|
||||
object.__setattr__(record, '_cached_detail_sections', tuple(failure_sections))
|
||||
if request_id != self._record_detail_request_id:
|
||||
return
|
||||
if index != self.selected_record_index:
|
||||
return
|
||||
await self._render_record_detail(eager=False)
|
||||
|
||||
def _schedule_record_detail_load(self, index: int) -> None:
|
||||
"""
|
||||
为当前选中记录安排一次后台详情加载。
|
||||
|
||||
:param index: 记录索引
|
||||
:return: None
|
||||
"""
|
||||
record = self._get_record_or_fallback(index)
|
||||
if record.detail_loader is None:
|
||||
return
|
||||
if record._cached_detail_sections:
|
||||
return
|
||||
self._cancel_task(self._record_detail_task)
|
||||
self._record_detail_request_id += 1
|
||||
self._record_detail_task = asyncio.create_task(
|
||||
self._load_record_detail_async(index, self._record_detail_request_id)
|
||||
)
|
||||
|
||||
async def _update_selected_record(self, index: int) -> None:
|
||||
"""
|
||||
更新当前选中记录,并同步右侧详情视图。
|
||||
|
||||
:param index: 待选中的记录索引
|
||||
:return: None
|
||||
"""
|
||||
if not self.snapshot.records:
|
||||
self.selected_record_index = 0
|
||||
self.selected_section_index = 0
|
||||
await self._render_record_detail(eager=False)
|
||||
return
|
||||
if not 0 <= index < len(self.snapshot.records):
|
||||
return
|
||||
if index == self.selected_record_index:
|
||||
return
|
||||
self.selected_record_index = index
|
||||
self.selected_section_index = 0
|
||||
await self._render_record_detail(eager=False)
|
||||
self._schedule_record_detail_load(index)
|
||||
|
||||
def _update_selected_section(self, index: int) -> None:
|
||||
"""
|
||||
更新当前选中分区,并同步最右侧详情内容。
|
||||
|
||||
:param index: 分区索引
|
||||
:return: None
|
||||
"""
|
||||
if self._is_syncing_sections:
|
||||
return
|
||||
sections = self._get_sections_for_record(self.selected_record_index)
|
||||
if not sections:
|
||||
detail_view = self.query_one(SectionDetailView)
|
||||
fallback = self._get_section_or_fallback(self.selected_record_index, index)
|
||||
detail_view.show_section(fallback, query=self._current_search_query())
|
||||
record = self._get_record_or_fallback(self.selected_record_index)
|
||||
self.query_one(RecordSummaryView).show_record(
|
||||
record,
|
||||
fallback,
|
||||
self._build_record_action_lines(record),
|
||||
self._current_search_query(),
|
||||
)
|
||||
self._sync_detail_pane_state(fallback)
|
||||
return
|
||||
if not 0 <= index < len(sections):
|
||||
return
|
||||
if index == self.selected_section_index:
|
||||
return
|
||||
self.selected_section_index = index
|
||||
current_section = sections[index]
|
||||
detail_view = self.query_one(SectionDetailView)
|
||||
detail_view.show_section(current_section, query=self._current_search_query())
|
||||
record = self._get_record_or_fallback(self.selected_record_index)
|
||||
self.query_one(RecordSummaryView).show_record(
|
||||
record,
|
||||
current_section,
|
||||
self._build_record_action_lines(record),
|
||||
self._current_search_query(),
|
||||
)
|
||||
self._sync_detail_pane_state(current_section)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""
|
||||
浏览页挂载后默认将焦点停留在左侧导航。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.call_after_refresh(self.query_one(WorkspaceSidebar).focus)
|
||||
self._schedule_record_detail_load(self.selected_record_index)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
"""
|
||||
浏览页卸载时取消尚未完成的后台任务。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._cancel_background_tasks()
|
||||
|
||||
def action_apply_filter_1(self) -> None:
|
||||
"""
|
||||
应用第一个浏览页筛选条件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._apply_filter_shortcut('1')
|
||||
|
||||
def action_apply_filter_2(self) -> None:
|
||||
"""
|
||||
应用第二个浏览页筛选条件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._apply_filter_shortcut('2')
|
||||
|
||||
def action_apply_filter_3(self) -> None:
|
||||
"""
|
||||
应用第三个浏览页筛选条件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._apply_filter_shortcut('3')
|
||||
|
||||
def action_apply_filter_4(self) -> None:
|
||||
"""
|
||||
应用第四个浏览页筛选条件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._apply_filter_shortcut('4')
|
||||
|
||||
def _open_sidebar_item(self, event: WorkspaceSidebar.Highlighted | WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
根据侧边栏事件打开对应视图,并避免重复打开当前页面。
|
||||
|
||||
:param event: 侧边栏高亮或选中事件
|
||||
:return: None
|
||||
"""
|
||||
item = getattr(event.item, 'item', None)
|
||||
if not isinstance(item, NavigationItem):
|
||||
return
|
||||
if item.view_key == self.active_view:
|
||||
return
|
||||
self.app.open_view(item.view_key)
|
||||
|
||||
@on(WorkspaceSidebar.Selected)
|
||||
def on_sidebar_selected(self, event: WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
响应左侧导航选中事件并切换视图。
|
||||
|
||||
:param event: 导航选中事件
|
||||
:return: None
|
||||
"""
|
||||
self._open_sidebar_item(event)
|
||||
|
||||
@on(WorkspaceSidebar.Highlighted)
|
||||
def on_sidebar_highlighted(self, event: WorkspaceSidebar.Highlighted) -> None:
|
||||
"""
|
||||
响应左侧导航高亮变更并立即切换视图。
|
||||
|
||||
:param event: 导航高亮事件
|
||||
:return: None
|
||||
"""
|
||||
self._open_sidebar_item(event)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""
|
||||
构建浏览页界面结构。
|
||||
|
||||
:return: Textual 组件结果
|
||||
"""
|
||||
yield WorkspaceHeader(self.env, self.active_view)
|
||||
with Horizontal(id='workspace-shell'):
|
||||
yield WorkspaceSidebar(self.env, self.navigation_items, self.active_view)
|
||||
with ScrollableContainer(id='workspace-main'):
|
||||
yield WorkspaceHero(
|
||||
title=self.snapshot.title,
|
||||
subtitle=self.snapshot.subtitle,
|
||||
env=self.env,
|
||||
active_view=self.active_view,
|
||||
summary=self._build_summary_text(),
|
||||
refreshed_at=self.refreshed_at,
|
||||
)
|
||||
if self.snapshot.filters:
|
||||
yield Static(self._build_filter_bar_text(), id='browser-filter-bar', markup=False)
|
||||
with Horizontal(id='browser-body'):
|
||||
yield RecordNavigator(
|
||||
self.snapshot.records,
|
||||
initial_index=self.selected_record_index,
|
||||
query=self._current_search_query(),
|
||||
)
|
||||
with Vertical(id='browser-detail-pane'):
|
||||
yield RecordSummaryView(self._get_record_or_fallback(self.selected_record_index))
|
||||
with Horizontal(id='browser-section-body'):
|
||||
yield SectionNavigator(
|
||||
self._get_sections_for_record(self.selected_record_index, eager=False),
|
||||
initial_index=self.selected_section_index,
|
||||
query=self._current_search_query(),
|
||||
)
|
||||
yield SectionDetailView(
|
||||
self._get_section_or_fallback(
|
||||
self.selected_record_index,
|
||||
self.selected_section_index,
|
||||
),
|
||||
query=self._current_search_query(),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
@on(RecordNavigator.Changed)
|
||||
async def on_record_changed(self, event: RecordNavigator.Changed) -> None:
|
||||
"""
|
||||
响应记录导航高亮变更并更新详情内容。
|
||||
|
||||
:param event: 记录导航高亮事件
|
||||
:return: None
|
||||
"""
|
||||
await self._update_selected_record(event.index)
|
||||
|
||||
@on(SectionNavigator.Changed)
|
||||
def on_section_changed(self, event: SectionNavigator.Changed) -> None:
|
||||
"""
|
||||
响应记录详情分区高亮变更并更新最右侧详情内容。
|
||||
|
||||
:param event: 分区导航高亮事件
|
||||
:return: None
|
||||
"""
|
||||
self._update_selected_section(event.index)
|
||||
@@ -0,0 +1,128 @@
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Static
|
||||
|
||||
from cli.tui.copy import TUI_COPY
|
||||
|
||||
|
||||
class ActionConfirmScreen(ModalScreen[bool]):
|
||||
"""
|
||||
通用动作确认弹窗。
|
||||
|
||||
:param title: 弹窗标题
|
||||
:param lines: 预览文本行
|
||||
:param confirm_label: 确认按钮文本
|
||||
"""
|
||||
|
||||
CSS = """
|
||||
ActionConfirmScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#action-confirm-dialog {
|
||||
width: 96;
|
||||
max-width: 96;
|
||||
border: heavy #38d8ff;
|
||||
background: #06111b;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#action-confirm-title {
|
||||
text-style: bold;
|
||||
color: #e9fcff;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#action-confirm-body {
|
||||
border: round #18425d;
|
||||
background: #081827;
|
||||
color: #d8f7ff;
|
||||
padding: 1 2;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#action-confirm-actions {
|
||||
height: auto;
|
||||
align-horizontal: right;
|
||||
}
|
||||
|
||||
#action-confirm-hint {
|
||||
color: #8fc6d6;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#action-confirm-cancel {
|
||||
margin-right: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding('escape', 'cancel', TUI_COPY.build_confirm_binding_label('cancel'), show=False),
|
||||
Binding('enter', 'submit', TUI_COPY.build_confirm_binding_label('submit'), show=False),
|
||||
]
|
||||
|
||||
def __init__(self, title: str, lines: list[str], confirm_label: str) -> None:
|
||||
super().__init__()
|
||||
self._dialog_title = title
|
||||
self._dialog_lines = lines
|
||||
self._confirm_label = confirm_label
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""
|
||||
构建确认弹窗界面。
|
||||
|
||||
:return: Textual 组件结果
|
||||
"""
|
||||
with Vertical(id='action-confirm-dialog'):
|
||||
yield Static(self._dialog_title, id='action-confirm-title', markup=False)
|
||||
yield Static('\n'.join(self._dialog_lines), id='action-confirm-body', markup=False)
|
||||
yield Static(TUI_COPY.build_action_confirm_hint(), id='action-confirm-hint', markup=False)
|
||||
with Horizontal(id='action-confirm-actions'):
|
||||
yield Button(TUI_COPY.build_action_confirm_cancel_label(), id='action-confirm-cancel')
|
||||
yield Button(self._confirm_label, id='action-confirm-submit', variant='primary')
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""
|
||||
弹窗挂载后默认聚焦确认按钮。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.call_after_refresh(self.query_one('#action-confirm-submit', Button).focus)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""
|
||||
取消并关闭弹窗。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.dismiss(False)
|
||||
|
||||
def action_submit(self) -> None:
|
||||
"""
|
||||
通过键盘确认执行当前动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
submit_button = self.query_one('#action-confirm-submit', Button)
|
||||
submit_button.press()
|
||||
|
||||
@on(Button.Pressed, '#action-confirm-cancel')
|
||||
def on_cancel_pressed(self) -> None:
|
||||
"""
|
||||
响应取消按钮。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.dismiss(False)
|
||||
|
||||
@on(Button.Pressed, '#action-confirm-submit')
|
||||
def on_submit_pressed(self) -> None:
|
||||
"""
|
||||
响应确认按钮。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.dismiss(True)
|
||||
@@ -0,0 +1,253 @@
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Grid, Horizontal, ScrollableContainer
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Footer
|
||||
|
||||
from cli.tui.adapters import DashboardSnapshot
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.keymaps import TUI_KEYMAP_REGISTRY
|
||||
from cli.tui.screens.focus import BaseScreenFocusService, ScreenFocusActionsMixin
|
||||
from cli.tui.screens.summary import STATUS_SUMMARY_BUILDER
|
||||
from cli.tui.widgets import (
|
||||
MetricPanel,
|
||||
NavigationItem,
|
||||
SignalRail,
|
||||
StatusPanel,
|
||||
WorkspaceHeader,
|
||||
WorkspaceHero,
|
||||
WorkspaceSidebar,
|
||||
)
|
||||
|
||||
PRIMARY_METRIC_COUNT = 2
|
||||
|
||||
|
||||
class DashboardScreenSupport:
|
||||
"""
|
||||
首页屏幕支持对象。
|
||||
|
||||
该对象负责首页摘要文本、信号带文本和状态轨迹构建,
|
||||
使 `DashboardScreen` 本体主要保留 Textual 生命周期与界面编排。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_summary_text(snapshot: DashboardSnapshot) -> str:
|
||||
"""
|
||||
构建首页摘要文本。
|
||||
|
||||
:param snapshot: 首页聚合快照
|
||||
:return: 摘要文本
|
||||
"""
|
||||
summary = STATUS_SUMMARY_BUILDER.build(snapshot.panels)
|
||||
return TUI_COPY.build_dashboard_summary_text(
|
||||
total_count=summary.total_count,
|
||||
ok_count=summary.ok_count,
|
||||
warn_count=summary.warn_count,
|
||||
fail_count=summary.fail_count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_status_track(snapshot: DashboardSnapshot) -> str:
|
||||
"""
|
||||
构建首页面板状态轨迹字符串。
|
||||
|
||||
:param snapshot: 首页聚合快照
|
||||
:return: 状态轨迹
|
||||
"""
|
||||
return ''.join(
|
||||
'x' if panel.status == 'fail' else '!' if panel.status == 'warn' else 'o' if panel.status == 'ok' else '-'
|
||||
for panel in snapshot.panels
|
||||
)
|
||||
|
||||
def build_signal_lines(self, snapshot: DashboardSnapshot) -> list[str]:
|
||||
"""
|
||||
构建首页顶部信号带文本。
|
||||
|
||||
:param snapshot: 首页聚合快照
|
||||
:return: 信号文本行
|
||||
"""
|
||||
summary = STATUS_SUMMARY_BUILDER.build(snapshot.panels)
|
||||
return TUI_COPY.build_dashboard_signal_lines(
|
||||
status_track=self.build_status_track(snapshot),
|
||||
fail_count=summary.fail_count,
|
||||
warn_count=summary.warn_count,
|
||||
ok_count=summary.ok_count,
|
||||
total_count=summary.total_count,
|
||||
navigation_shortcut_hint=TUI_KEYMAP_REGISTRY.navigation_shortcut_hint,
|
||||
)
|
||||
|
||||
|
||||
class DashboardScreenFocusService(BaseScreenFocusService):
|
||||
"""
|
||||
首页焦点服务。
|
||||
|
||||
该对象负责定义首页可聚焦组件顺序,以及按左右键和滚动键解析
|
||||
当前目标区域。
|
||||
"""
|
||||
|
||||
def get_focus_order(self, screen: 'DashboardScreen') -> list[WorkspaceSidebar | ScrollableContainer]:
|
||||
"""
|
||||
定义首页可通过左右键切换的焦点顺序。
|
||||
|
||||
:param screen: 当前首页屏幕
|
||||
:return: 焦点顺序列表
|
||||
"""
|
||||
return [
|
||||
screen.query_one(WorkspaceSidebar),
|
||||
screen.query_one('#workspace-main', ScrollableContainer),
|
||||
]
|
||||
|
||||
def get_default_scroll_target(self, screen: 'DashboardScreen') -> ScrollableContainer:
|
||||
"""
|
||||
返回首页默认滚动容器。
|
||||
|
||||
:param screen: 当前首页屏幕
|
||||
:return: 主工作区滚动容器
|
||||
"""
|
||||
return screen.query_one('#workspace-main', ScrollableContainer)
|
||||
|
||||
|
||||
class DashboardScreen(ScreenFocusActionsMixin, Screen[None]):
|
||||
"""
|
||||
TUI 首页巡检屏幕。
|
||||
|
||||
:param snapshot: 首页聚合快照
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding('left', 'focus_left', TUI_COPY.build_internal_binding_label('focus_left'), show=False),
|
||||
Binding('right', 'focus_right', TUI_COPY.build_internal_binding_label('focus_right'), show=False),
|
||||
Binding('j', 'scroll_focus_down', TUI_COPY.build_internal_binding_label('scroll_down'), show=False),
|
||||
Binding('k', 'scroll_focus_up', TUI_COPY.build_internal_binding_label('scroll_up'), show=False),
|
||||
Binding('pagedown', 'scroll_focus_page_down', TUI_COPY.build_internal_binding_label('page_down'), show=False),
|
||||
Binding('pageup', 'scroll_focus_page_up', TUI_COPY.build_internal_binding_label('page_up'), show=False),
|
||||
Binding('home', 'scroll_focus_home', TUI_COPY.build_internal_binding_label('home'), show=False),
|
||||
Binding('end', 'scroll_focus_end', TUI_COPY.build_internal_binding_label('end'), show=False),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
snapshot: DashboardSnapshot,
|
||||
env: str,
|
||||
active_view: str,
|
||||
navigation_items: list[NavigationItem],
|
||||
refreshed_at: str,
|
||||
support: DashboardScreenSupport | None = None,
|
||||
focus_service: DashboardScreenFocusService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化首页巡检屏幕。
|
||||
|
||||
:param snapshot: 首页聚合快照
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前激活视图
|
||||
:param navigation_items: 导航项列表
|
||||
:param refreshed_at: 本次刷新时间
|
||||
:param support: 首页屏幕支持对象
|
||||
:param focus_service: 首页焦点服务
|
||||
:return: None
|
||||
"""
|
||||
self.snapshot = snapshot
|
||||
self.env = env
|
||||
self.active_view = active_view
|
||||
self.navigation_items = navigation_items
|
||||
self.refreshed_at = refreshed_at
|
||||
self.support = support or DashboardScreenSupport()
|
||||
self.focus_service = focus_service or DashboardScreenFocusService()
|
||||
super().__init__()
|
||||
|
||||
def _build_summary_text(self) -> str:
|
||||
"""
|
||||
构建首页摘要文本。
|
||||
|
||||
:return: 摘要文本
|
||||
"""
|
||||
return self.support.build_summary_text(self.snapshot)
|
||||
|
||||
def _build_signal_lines(self) -> list[str]:
|
||||
"""
|
||||
构建首页顶部信号带文本。
|
||||
|
||||
:return: 信号文本行
|
||||
"""
|
||||
return self.support.build_signal_lines(self.snapshot)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""
|
||||
构建首页界面结构。
|
||||
|
||||
:return: Textual 组件结果
|
||||
"""
|
||||
yield WorkspaceHeader(self.env, self.active_view)
|
||||
with Horizontal(id='workspace-shell'):
|
||||
yield WorkspaceSidebar(self.env, self.navigation_items, self.active_view)
|
||||
with ScrollableContainer(id='workspace-main'):
|
||||
yield WorkspaceHero(
|
||||
title=TUI_COPY.build_dashboard_hero_title(),
|
||||
subtitle=TUI_COPY.build_dashboard_hero_subtitle(),
|
||||
env=self.env,
|
||||
active_view=self.active_view,
|
||||
summary=self._build_summary_text(),
|
||||
refreshed_at=self.refreshed_at,
|
||||
)
|
||||
yield SignalRail(self._build_signal_lines())
|
||||
with Grid(id='dashboard-metrics'):
|
||||
for index, metric in enumerate(self.snapshot.metrics):
|
||||
yield MetricPanel(
|
||||
title=metric.title,
|
||||
value=metric.value,
|
||||
status=metric.status,
|
||||
hint=metric.hint,
|
||||
accent=index < PRIMARY_METRIC_COUNT,
|
||||
)
|
||||
with Grid(id='dashboard-grid'):
|
||||
for panel in self.snapshot.panels:
|
||||
yield StatusPanel(
|
||||
title=panel.title,
|
||||
status=panel.status,
|
||||
body='\n'.join(panel.lines),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""
|
||||
首页挂载后默认将焦点停留在左侧导航。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.call_after_refresh(self.query_one(WorkspaceSidebar).focus)
|
||||
|
||||
def _open_sidebar_item(self, event: WorkspaceSidebar.Highlighted | WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
根据侧边栏事件打开对应视图,并避免重复打开当前页面。
|
||||
|
||||
:param event: 侧边栏高亮或选中事件
|
||||
:return: None
|
||||
"""
|
||||
item = getattr(event.item, 'item', None)
|
||||
if not isinstance(item, NavigationItem):
|
||||
return
|
||||
if item.view_key == self.active_view:
|
||||
return
|
||||
self.app.open_view(item.view_key)
|
||||
|
||||
@on(WorkspaceSidebar.Selected)
|
||||
def on_sidebar_selected(self, event: WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
响应左侧导航选中事件并切换视图。
|
||||
|
||||
:param event: 导航选中事件
|
||||
:return: None
|
||||
"""
|
||||
self._open_sidebar_item(event)
|
||||
|
||||
@on(WorkspaceSidebar.Highlighted)
|
||||
def on_sidebar_highlighted(self, event: WorkspaceSidebar.Highlighted) -> None:
|
||||
"""
|
||||
响应左侧导航高亮变更并立即切换视图。
|
||||
|
||||
:param event: 导航高亮事件
|
||||
:return: None
|
||||
"""
|
||||
self._open_sidebar_item(event)
|
||||
@@ -0,0 +1,413 @@
|
||||
import asyncio
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, ScrollableContainer
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Footer
|
||||
|
||||
from cli.tui.actions import (
|
||||
TUI_ACTION_PRESENTATION_SERVICE,
|
||||
TUI_ACTION_REGISTRY,
|
||||
TuiActionResult,
|
||||
TuiActionSpec,
|
||||
)
|
||||
from cli.tui.adapters import DetailPageSnapshot, DetailSectionSnapshot
|
||||
from cli.tui.adapters.models import TUI_ADAPTER_MODEL_RENDERER
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.screens.focus import BaseScreenFocusService, ScreenFocusActionsMixin
|
||||
from cli.tui.screens.interactions import TUI_SCREEN_INTERACTION_SERVICE, ScreenInteractionActionsMixin
|
||||
from cli.tui.screens.summary import STATUS_SUMMARY_BUILDER
|
||||
from cli.tui.widgets import (
|
||||
NavigationItem,
|
||||
SectionDetailView,
|
||||
SectionNavigator,
|
||||
WorkspaceHeader,
|
||||
WorkspaceHero,
|
||||
WorkspaceSidebar,
|
||||
)
|
||||
|
||||
|
||||
class DetailScreenSupport:
|
||||
"""
|
||||
详情页屏幕支持对象。
|
||||
|
||||
该对象负责详情页摘要、搜索词、动作解析与空态分区构建,
|
||||
使 `DetailScreen` 本体主要保留 Textual 事件桥接。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_empty_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建详情页空态兜底分区。
|
||||
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title=TUI_COPY.build_detail_empty_section_copy('title'),
|
||||
status='info',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label=TUI_COPY.build_detail_empty_section_copy('label'),
|
||||
empty_value=TUI_COPY.build_detail_empty_section_copy('value'),
|
||||
detail=TUI_COPY.build_detail_empty_section_copy('detail_page'),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_summary_text(snapshot: DetailPageSnapshot, active_view: str) -> str:
|
||||
"""
|
||||
构建详情页摘要文本。
|
||||
|
||||
:param snapshot: 页面快照
|
||||
:param active_view: 当前激活视图
|
||||
:return: 摘要文本
|
||||
"""
|
||||
summary = STATUS_SUMMARY_BUILDER.build(snapshot.sections)
|
||||
return (
|
||||
TUI_COPY.build_detail_summary_text(
|
||||
total_count=summary.total_count,
|
||||
ok_count=summary.ok_count,
|
||||
warn_count=summary.warn_count,
|
||||
fail_count=summary.fail_count,
|
||||
)
|
||||
+ f' {TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint(active_view)}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def current_search_query(snapshot: DetailPageSnapshot) -> str:
|
||||
"""
|
||||
读取当前详情页搜索词。
|
||||
|
||||
:param snapshot: 页面快照
|
||||
:return: 搜索词
|
||||
"""
|
||||
search_context = snapshot.search
|
||||
return search_context.query if search_context is not None else ''
|
||||
|
||||
@staticmethod
|
||||
def resolve_action(*, active_view: str, slot: str, env: str) -> TuiActionSpec | None:
|
||||
"""
|
||||
解析当前详情页指定槽位对应的动作。
|
||||
|
||||
:param active_view: 当前激活视图
|
||||
:param slot: 动作槽位
|
||||
:param env: 当前运行环境
|
||||
:return: 动作定义
|
||||
"""
|
||||
return TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key=active_view,
|
||||
slot=slot, # type: ignore[arg-type]
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
class DetailScreenFocusService(BaseScreenFocusService):
|
||||
"""
|
||||
详情页焦点服务。
|
||||
|
||||
该对象负责定义详情页可聚焦组件顺序,以及按左右键和滚动键解析
|
||||
当前目标区域。
|
||||
"""
|
||||
|
||||
def get_focus_order(self, screen: 'DetailScreen') -> list[WorkspaceSidebar | SectionNavigator | SectionDetailView]:
|
||||
"""
|
||||
定义详情页可通过左右键切换的焦点顺序。
|
||||
|
||||
:param screen: 当前详情页屏幕
|
||||
:return: 焦点顺序列表
|
||||
"""
|
||||
return [
|
||||
screen.query_one(WorkspaceSidebar),
|
||||
screen.query_one(SectionNavigator),
|
||||
screen.query_one(SectionDetailView),
|
||||
]
|
||||
|
||||
def get_default_scroll_target(
|
||||
self,
|
||||
screen: 'DetailScreen',
|
||||
) -> ScrollableContainer:
|
||||
"""
|
||||
返回详情页默认滚动容器。
|
||||
|
||||
:param screen: 当前详情页屏幕
|
||||
:return: 主工作区滚动容器
|
||||
"""
|
||||
return screen.query_one('#workspace-main', ScrollableContainer)
|
||||
|
||||
|
||||
class DetailScreen(ScreenInteractionActionsMixin, ScreenFocusActionsMixin, Screen[None]):
|
||||
"""
|
||||
TUI 通用详情屏幕。
|
||||
|
||||
:param snapshot: 页面快照
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding('left', 'focus_left', TUI_COPY.build_internal_binding_label('focus_left'), show=False),
|
||||
Binding('right', 'focus_right', TUI_COPY.build_internal_binding_label('focus_right'), show=False),
|
||||
Binding('j', 'scroll_focus_down', TUI_COPY.build_internal_binding_label('scroll_down'), show=False),
|
||||
Binding('k', 'scroll_focus_up', TUI_COPY.build_internal_binding_label('scroll_up'), show=False),
|
||||
Binding('pagedown', 'scroll_focus_page_down', TUI_COPY.build_internal_binding_label('page_down'), show=False),
|
||||
Binding('pageup', 'scroll_focus_page_up', TUI_COPY.build_internal_binding_label('page_up'), show=False),
|
||||
Binding('home', 'scroll_focus_home', TUI_COPY.build_internal_binding_label('home'), show=False),
|
||||
Binding('end', 'scroll_focus_end', TUI_COPY.build_internal_binding_label('end'), show=False),
|
||||
Binding('x', 'trigger_primary_action', TUI_COPY.build_internal_binding_label('action_primary'), show=False),
|
||||
Binding('z', 'trigger_secondary_action', TUI_COPY.build_internal_binding_label('action_secondary'), show=False),
|
||||
Binding('y', 'trigger_global_action', TUI_COPY.build_internal_binding_label('action_global'), show=False),
|
||||
Binding('w', 'trigger_utility_action', TUI_COPY.build_internal_binding_label('action_utility'), show=False),
|
||||
Binding('/', 'open_search', TUI_COPY.build_internal_binding_label('search'), show=False),
|
||||
Binding('backspace', 'clear_search', TUI_COPY.build_internal_binding_label('clear_search'), show=False),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
snapshot: DetailPageSnapshot,
|
||||
env: str,
|
||||
active_view: str,
|
||||
navigation_items: list[NavigationItem],
|
||||
refreshed_at: str,
|
||||
support: DetailScreenSupport | None = None,
|
||||
focus_service: DetailScreenFocusService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化通用详情屏幕。
|
||||
|
||||
:param snapshot: 页面快照
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前激活视图
|
||||
:param navigation_items: 导航项列表
|
||||
:param refreshed_at: 本次刷新时间
|
||||
:param support: 详情页屏幕支持对象
|
||||
:param focus_service: 详情页焦点服务
|
||||
:return: None
|
||||
"""
|
||||
self.snapshot = snapshot
|
||||
self.env = env
|
||||
self.active_view = active_view
|
||||
self.navigation_items = navigation_items
|
||||
self.refreshed_at = refreshed_at
|
||||
self.selected_section_index = 0
|
||||
self._last_action_result: TuiActionResult | None = None
|
||||
self._action_feedback_lines: list[str] = []
|
||||
self._action_task: asyncio.Task[None] | None = None
|
||||
self.support = support or DetailScreenSupport()
|
||||
self.focus_service = focus_service or DetailScreenFocusService()
|
||||
super().__init__()
|
||||
|
||||
@staticmethod
|
||||
def _cancel_task(task: asyncio.Task[None] | None) -> None:
|
||||
"""
|
||||
取消指定后台任务。
|
||||
|
||||
:param task: 待取消任务
|
||||
:return: None
|
||||
"""
|
||||
if task is None or task.done():
|
||||
return
|
||||
task.cancel()
|
||||
|
||||
def _get_section_or_fallback(self, index: int) -> DetailSectionSnapshot:
|
||||
"""
|
||||
获取指定索引的分区快照,缺失时返回兜底分区。
|
||||
|
||||
:param index: 分区索引
|
||||
:return: 分区快照
|
||||
"""
|
||||
if 0 <= index < len(self.snapshot.sections):
|
||||
return self.snapshot.sections[index]
|
||||
return self.support.build_empty_section()
|
||||
|
||||
def _build_summary_text(self) -> str:
|
||||
"""
|
||||
构建详情页摘要文本。
|
||||
|
||||
:return: 摘要文本
|
||||
"""
|
||||
return self.support.build_summary_text(self.snapshot, self.active_view)
|
||||
|
||||
def _current_search_query(self) -> str:
|
||||
return self.support.current_search_query(self.snapshot)
|
||||
|
||||
def _open_search(self) -> None:
|
||||
"""
|
||||
打开当前详情页搜索输入弹窗。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
TUI_SCREEN_INTERACTION_SERVICE.open_search_dialog(
|
||||
self,
|
||||
self.snapshot.search,
|
||||
self._handle_search_submitted,
|
||||
)
|
||||
|
||||
def _handle_search_submitted(self, query: str | None) -> None:
|
||||
"""
|
||||
处理详情页搜索输入结果。
|
||||
|
||||
:param query: 搜索词
|
||||
:return: None
|
||||
"""
|
||||
TUI_SCREEN_INTERACTION_SERVICE.remember_query_and_refresh(self, self.active_view, query)
|
||||
|
||||
def _clear_search(self) -> None:
|
||||
"""
|
||||
清空当前详情页搜索词。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
TUI_SCREEN_INTERACTION_SERVICE.clear_query_and_refresh(
|
||||
self,
|
||||
self.active_view,
|
||||
self._current_search_query(),
|
||||
)
|
||||
|
||||
def _resolve_action(self, slot: str) -> TuiActionSpec | None:
|
||||
"""
|
||||
解析当前详情页指定槽位对应的动作。
|
||||
|
||||
:param slot: 动作槽位
|
||||
:return: 动作定义
|
||||
"""
|
||||
return self.support.resolve_action(active_view=self.active_view, slot=slot, env=self.env)
|
||||
|
||||
def _open_action_confirm(self, slot: str) -> None:
|
||||
"""
|
||||
打开指定槽位动作的确认弹窗。
|
||||
|
||||
:param slot: 动作槽位
|
||||
:return: None
|
||||
"""
|
||||
self._action_task = TUI_SCREEN_INTERACTION_SERVICE.confirm_and_schedule_action(
|
||||
self,
|
||||
self._resolve_action(slot),
|
||||
self._action_task,
|
||||
self._execute_action,
|
||||
)
|
||||
|
||||
async def _execute_action(self, action: TuiActionSpec) -> None:
|
||||
"""
|
||||
执行详情页动作。
|
||||
|
||||
:param action: 动作定义
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _handle_result(result: TuiActionResult, feedback_lines: list[str]) -> None:
|
||||
self._last_action_result = result
|
||||
self._action_feedback_lines = feedback_lines
|
||||
|
||||
await TUI_SCREEN_INTERACTION_SERVICE.execute_action_with_feedback(
|
||||
self,
|
||||
action,
|
||||
self.env,
|
||||
self.active_view,
|
||||
_handle_result,
|
||||
)
|
||||
|
||||
def _update_selected_section(self, index: int) -> None:
|
||||
"""
|
||||
更新当前选中分区,并同步右侧详情视图。
|
||||
|
||||
:param index: 待选中的分区索引
|
||||
:return: None
|
||||
"""
|
||||
if not self.snapshot.sections:
|
||||
detail_view = self.query_one(SectionDetailView)
|
||||
detail_view.show_section(self._get_section_or_fallback(index), query=self._current_search_query())
|
||||
return
|
||||
if not 0 <= index < len(self.snapshot.sections):
|
||||
return
|
||||
self.selected_section_index = index
|
||||
detail_view = self.query_one(SectionDetailView)
|
||||
detail_view.show_section(self.snapshot.sections[index], query=self._current_search_query())
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""
|
||||
构建详情页界面结构。
|
||||
|
||||
:return: Textual 组件结果
|
||||
"""
|
||||
yield WorkspaceHeader(self.env, self.active_view)
|
||||
with Horizontal(id='workspace-shell'):
|
||||
yield WorkspaceSidebar(self.env, self.navigation_items, self.active_view)
|
||||
with ScrollableContainer(id='workspace-main'):
|
||||
yield WorkspaceHero(
|
||||
title=self.snapshot.title,
|
||||
subtitle=self.snapshot.subtitle,
|
||||
env=self.env,
|
||||
active_view=self.active_view,
|
||||
summary=self._build_summary_text(),
|
||||
refreshed_at=self.refreshed_at,
|
||||
)
|
||||
with Horizontal(id='detail-body'):
|
||||
yield SectionNavigator(
|
||||
self.snapshot.sections,
|
||||
initial_index=self.selected_section_index,
|
||||
query=self._current_search_query(),
|
||||
)
|
||||
yield SectionDetailView(
|
||||
self._get_section_or_fallback(self.selected_section_index),
|
||||
query=self._current_search_query(),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""
|
||||
详情页挂载后默认将焦点停留在左侧导航。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.call_after_refresh(self.query_one(WorkspaceSidebar).focus)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
"""
|
||||
详情页卸载时取消尚未完成的后台任务。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._cancel_task(self._action_task)
|
||||
|
||||
def _open_sidebar_item(self, event: WorkspaceSidebar.Highlighted | WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
根据侧边栏事件打开对应视图,并避免重复打开当前页面。
|
||||
|
||||
:param event: 侧边栏高亮或选中事件
|
||||
:return: None
|
||||
"""
|
||||
item = getattr(event.item, 'item', None)
|
||||
if not isinstance(item, NavigationItem):
|
||||
return
|
||||
if item.view_key == self.active_view:
|
||||
return
|
||||
self.app.open_view(item.view_key)
|
||||
|
||||
@on(WorkspaceSidebar.Selected)
|
||||
def on_sidebar_selected(self, event: WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
响应左侧导航选中事件并切换视图。
|
||||
|
||||
:param event: 导航选中事件
|
||||
:return: None
|
||||
"""
|
||||
self._open_sidebar_item(event)
|
||||
|
||||
@on(WorkspaceSidebar.Highlighted)
|
||||
def on_sidebar_highlighted(self, event: WorkspaceSidebar.Highlighted) -> None:
|
||||
"""
|
||||
响应左侧导航高亮变更并立即切换视图。
|
||||
|
||||
:param event: 导航高亮事件
|
||||
:return: None
|
||||
"""
|
||||
self._open_sidebar_item(event)
|
||||
|
||||
@on(SectionNavigator.Changed)
|
||||
def on_section_changed(self, event: SectionNavigator.Changed) -> None:
|
||||
"""
|
||||
响应分区导航高亮变更并更新详情内容。
|
||||
|
||||
:param event: 分区导航高亮事件
|
||||
:return: None
|
||||
"""
|
||||
self._update_selected_section(event.index)
|
||||
@@ -0,0 +1,152 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from textual.containers import ScrollableContainer
|
||||
|
||||
|
||||
class BaseScreenFocusService(ABC):
|
||||
"""
|
||||
TUI 屏幕焦点服务基类。
|
||||
|
||||
该基类统一处理焦点顺序中的左右移动和滚动目标回退逻辑,具体页面
|
||||
只需声明自身的焦点顺序和默认滚动容器。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_focus_order(self, screen: Any) -> list[Any]:
|
||||
"""
|
||||
返回当前页面的焦点顺序。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:return: 焦点组件列表
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_default_scroll_target(self, screen: Any) -> ScrollableContainer:
|
||||
"""
|
||||
返回当前页面的默认滚动容器。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:return: 默认滚动容器
|
||||
"""
|
||||
|
||||
def move_focus(self, screen: Any, step: int) -> None:
|
||||
"""
|
||||
按给定方向移动当前焦点。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param step: 焦点移动步长
|
||||
:return: None
|
||||
"""
|
||||
order = self.get_focus_order(screen)
|
||||
focused = screen.app.focused
|
||||
if focused not in order:
|
||||
order[0].focus()
|
||||
return
|
||||
target_index = max(0, min(order.index(focused) + step, len(order) - 1))
|
||||
order[target_index].focus()
|
||||
|
||||
def get_scroll_target(self, screen: Any) -> Any:
|
||||
"""
|
||||
获取当前应响应滚动动作的焦点区域。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:return: 当前焦点组件或默认滚动容器
|
||||
"""
|
||||
focused = screen.app.focused
|
||||
order = self.get_focus_order(screen)
|
||||
if focused in order:
|
||||
return focused
|
||||
return self.get_default_scroll_target(screen)
|
||||
|
||||
|
||||
class ScreenFocusActionsMixin:
|
||||
"""
|
||||
TUI 屏幕焦点动作混入。
|
||||
|
||||
该混入负责将焦点服务暴露为屏幕上的左右移动与滚动 action,避免
|
||||
各个 Screen 类重复编写同形转发方法。
|
||||
"""
|
||||
|
||||
focus_service: BaseScreenFocusService
|
||||
|
||||
def _move_focus(self, step: int) -> None:
|
||||
"""
|
||||
按给定方向移动当前焦点。
|
||||
|
||||
:param step: 焦点移动步长
|
||||
:return: None
|
||||
"""
|
||||
self.focus_service.move_focus(self, step)
|
||||
|
||||
def _get_scroll_target(self) -> Any:
|
||||
"""
|
||||
获取当前应响应滚动动作的焦点区域。
|
||||
|
||||
:return: 当前焦点组件或主工作区
|
||||
"""
|
||||
return self.focus_service.get_scroll_target(self)
|
||||
|
||||
def action_scroll_focus_down(self) -> None:
|
||||
"""
|
||||
向下滚动当前焦点区域。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._get_scroll_target().scroll_down(animate=False)
|
||||
|
||||
def action_scroll_focus_up(self) -> None:
|
||||
"""
|
||||
向上滚动当前焦点区域。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._get_scroll_target().scroll_up(animate=False)
|
||||
|
||||
def action_scroll_focus_page_down(self) -> None:
|
||||
"""
|
||||
向下翻动当前焦点区域。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._get_scroll_target().scroll_page_down(animate=False)
|
||||
|
||||
def action_scroll_focus_page_up(self) -> None:
|
||||
"""
|
||||
向上翻动当前焦点区域。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._get_scroll_target().scroll_page_up(animate=False)
|
||||
|
||||
def action_scroll_focus_home(self) -> None:
|
||||
"""
|
||||
将当前焦点区域滚动到起始位置。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._get_scroll_target().scroll_home(animate=False)
|
||||
|
||||
def action_scroll_focus_end(self) -> None:
|
||||
"""
|
||||
将当前焦点区域滚动到末尾位置。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._get_scroll_target().scroll_end(animate=False)
|
||||
|
||||
def action_focus_left(self) -> None:
|
||||
"""
|
||||
将焦点向左移动一列。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._move_focus(-1)
|
||||
|
||||
def action_focus_right(self) -> None:
|
||||
"""
|
||||
将焦点向右移动一列。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._move_focus(1)
|
||||
@@ -0,0 +1,331 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from textual.app import SuspendNotSupported
|
||||
|
||||
from cli.tui.actions import (
|
||||
TUI_ACTION_EXECUTION_SERVICE,
|
||||
TUI_ACTION_PRESENTATION_SERVICE,
|
||||
TuiActionResult,
|
||||
TuiActionSpec,
|
||||
)
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.screens.confirm import ActionConfirmScreen
|
||||
from cli.tui.screens.search import SearchInputScreen
|
||||
from cli.tui.search import PageSearchContext
|
||||
from cli.tui.widgets import WorkspaceSidebar
|
||||
|
||||
|
||||
class TuiScreenInteractionService:
|
||||
"""
|
||||
TUI 屏幕交互服务。
|
||||
|
||||
该服务负责搜索弹窗、搜索状态写回、动作确认弹窗和动作执行通知等
|
||||
屏幕级交互流程,减少浏览页与详情页之间的重复控制逻辑。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def open_search_dialog(
|
||||
screen: Any,
|
||||
search_context: PageSearchContext | None,
|
||||
callback: Callable[[str | None], None],
|
||||
) -> None:
|
||||
"""
|
||||
打开搜索输入弹窗。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param search_context: 搜索上下文
|
||||
:param callback: 提交搜索词后的回调
|
||||
:return: None
|
||||
"""
|
||||
if search_context is None:
|
||||
return
|
||||
screen.app.push_screen(
|
||||
SearchInputScreen(
|
||||
'页内搜索',
|
||||
search_context.placeholder,
|
||||
search_context.query,
|
||||
search_context.suggestions,
|
||||
),
|
||||
callback=callback,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def remember_query_and_refresh(screen: Any, active_view: str, query: str | None) -> None:
|
||||
"""
|
||||
记录当前页面搜索词并刷新页面。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param active_view: 当前页面视图标识
|
||||
:param query: 搜索词
|
||||
:return: None
|
||||
"""
|
||||
if query is None:
|
||||
return
|
||||
remember_query = getattr(screen.app, 'remember_browser_query', None)
|
||||
if callable(remember_query):
|
||||
remember_query(active_view, query)
|
||||
screen.app.action_refresh_current_view()
|
||||
|
||||
def clear_query_and_refresh(self, screen: Any, active_view: str, current_query: str) -> None:
|
||||
"""
|
||||
清空当前页面搜索词并刷新页面。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param active_view: 当前页面视图标识
|
||||
:param current_query: 当前搜索词
|
||||
:return: None
|
||||
"""
|
||||
if not current_query:
|
||||
return
|
||||
self.remember_query_and_refresh(screen, active_view, '')
|
||||
|
||||
@staticmethod
|
||||
def open_action_confirm(
|
||||
screen: Any,
|
||||
action: TuiActionSpec | None,
|
||||
callback: Callable[[bool | None, TuiActionSpec], None],
|
||||
) -> None:
|
||||
"""
|
||||
打开动作确认弹窗。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param action: 待执行动作
|
||||
:param callback: 确认回调
|
||||
:return: None
|
||||
"""
|
||||
if action is None:
|
||||
screen.notify(
|
||||
TUI_COPY.build_action_unavailable_message(),
|
||||
title=TUI_COPY.build_action_notification_title(),
|
||||
severity='warning',
|
||||
)
|
||||
return
|
||||
screen.app.push_screen(
|
||||
ActionConfirmScreen(action.preview_title, action.preview_lines, action.label),
|
||||
callback=lambda confirmed, action=action: callback(confirmed, action),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def schedule_action_task(
|
||||
existing_task: asyncio.Task[None] | None,
|
||||
action: TuiActionSpec,
|
||||
execute_callback: Callable[[TuiActionSpec], Awaitable[None]],
|
||||
) -> asyncio.Task[None]:
|
||||
"""
|
||||
安排动作执行任务,并在必要时取消旧任务。
|
||||
|
||||
:param existing_task: 现有动作任务
|
||||
:param action: 待执行动作
|
||||
:param execute_callback: 动作执行协程工厂
|
||||
:return: 新建任务
|
||||
"""
|
||||
if existing_task is not None and not existing_task.done():
|
||||
existing_task.cancel()
|
||||
return asyncio.create_task(execute_callback(action))
|
||||
|
||||
def confirm_and_schedule_action(
|
||||
self,
|
||||
screen: Any,
|
||||
action: TuiActionSpec | None,
|
||||
existing_task: asyncio.Task[None] | None,
|
||||
execute_callback: Callable[[TuiActionSpec], Awaitable[None]],
|
||||
) -> asyncio.Task[None] | None:
|
||||
"""
|
||||
统一处理动作确认结果,并在确认后调度后台执行任务。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param action: 待执行动作
|
||||
:param existing_task: 当前已有动作任务
|
||||
:param execute_callback: 动作执行协程工厂
|
||||
:return: 新建任务或原任务
|
||||
"""
|
||||
if action is None:
|
||||
self.open_action_confirm(screen, action, lambda confirmed, action_spec: None)
|
||||
return existing_task
|
||||
|
||||
scheduled_task = existing_task
|
||||
|
||||
def _handle_confirmed(confirmed: bool | None, action_spec: TuiActionSpec) -> None:
|
||||
nonlocal scheduled_task
|
||||
if not confirmed:
|
||||
return
|
||||
scheduled_task = self.schedule_action_task(
|
||||
scheduled_task,
|
||||
action_spec,
|
||||
execute_callback,
|
||||
)
|
||||
|
||||
self.open_action_confirm(screen, action, _handle_confirmed)
|
||||
return scheduled_task
|
||||
|
||||
async def execute_action(
|
||||
self,
|
||||
screen: Any,
|
||||
action: TuiActionSpec,
|
||||
env: str,
|
||||
active_view: str,
|
||||
*,
|
||||
on_result: Callable[[TuiActionResult, list[str]], Any] | None = None,
|
||||
) -> TuiActionResult:
|
||||
"""
|
||||
执行屏幕动作并统一处理通知、反馈持久化与刷新。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param action: 动作定义
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前页面视图标识
|
||||
:param on_result: 动作结果回调
|
||||
:return: 动作结果
|
||||
"""
|
||||
screen.notify(
|
||||
TUI_COPY.build_action_running_message(action.label),
|
||||
title=TUI_COPY.build_action_notification_title(),
|
||||
)
|
||||
if action.execution_mode == 'external':
|
||||
try:
|
||||
with screen.app.suspend():
|
||||
result = TUI_ACTION_EXECUTION_SERVICE.execute_external(action)
|
||||
except SuspendNotSupported:
|
||||
result = TuiActionResult(
|
||||
spec=action,
|
||||
external_exit_code=1,
|
||||
external_message='当前终端不支持挂起 TUI,无法打开外部交互向导',
|
||||
)
|
||||
else:
|
||||
result = await asyncio.to_thread(TUI_ACTION_EXECUTION_SERVICE.execute, action, env)
|
||||
|
||||
feedback_lines = TUI_ACTION_EXECUTION_SERVICE.build_result_lines(result)
|
||||
remember_feedback = getattr(screen.app, 'remember_action_feedback', None)
|
||||
if callable(remember_feedback):
|
||||
remember_feedback(active_view, feedback_lines)
|
||||
if on_result is not None:
|
||||
maybe_result = on_result(result, feedback_lines)
|
||||
if inspect.isawaitable(maybe_result):
|
||||
await maybe_result
|
||||
screen.notify(
|
||||
TUI_ACTION_PRESENTATION_SERVICE.build_action_result_message(result),
|
||||
title=TUI_COPY.build_action_notification_title(),
|
||||
severity='information' if result.ok else 'error',
|
||||
)
|
||||
if result.ok and action.refresh_view:
|
||||
screen.app.action_refresh_current_view()
|
||||
return result
|
||||
|
||||
async def execute_action_with_feedback(
|
||||
self,
|
||||
screen: Any,
|
||||
action: TuiActionSpec,
|
||||
env: str,
|
||||
active_view: str,
|
||||
feedback_callback: Callable[[TuiActionResult, list[str]], Any],
|
||||
) -> TuiActionResult:
|
||||
"""
|
||||
执行动作并将统一反馈回调注入结果收口流程。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param action: 动作定义
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前页面视图标识
|
||||
:param feedback_callback: 反馈写回回调
|
||||
:return: 动作结果
|
||||
"""
|
||||
return await self.execute_action(
|
||||
screen,
|
||||
action,
|
||||
env,
|
||||
active_view,
|
||||
on_result=feedback_callback,
|
||||
)
|
||||
|
||||
|
||||
class ScreenInteractionActionsMixin:
|
||||
"""
|
||||
屏幕交互动作混入。
|
||||
|
||||
该混入负责把搜索、动作确认和侧栏切屏这类重复桥接 action 收口为
|
||||
通用 Screen 方法,具体页面只需实现对应的私有协作方法。
|
||||
"""
|
||||
|
||||
def _open_search(self) -> None:
|
||||
"""
|
||||
打开当前页面搜索输入弹窗。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _clear_search(self) -> None:
|
||||
"""
|
||||
清空当前页面搜索词。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _open_action_confirm(self, slot: str) -> None:
|
||||
"""
|
||||
打开指定槽位动作的确认弹窗。
|
||||
|
||||
:param slot: 动作槽位
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _open_sidebar_item(self, event: WorkspaceSidebar.Highlighted | WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
根据侧栏事件打开对应视图。
|
||||
|
||||
:param event: 侧栏事件
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def action_open_search(self) -> None:
|
||||
"""
|
||||
打开当前页面搜索输入。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._open_search()
|
||||
|
||||
def action_clear_search(self) -> None:
|
||||
"""
|
||||
清空当前页面搜索词。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._clear_search()
|
||||
|
||||
def action_trigger_primary_action(self) -> None:
|
||||
"""
|
||||
触发当前页面主动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._open_action_confirm('primary')
|
||||
|
||||
def action_trigger_secondary_action(self) -> None:
|
||||
"""
|
||||
触发当前页面次动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._open_action_confirm('secondary')
|
||||
|
||||
def action_trigger_global_action(self) -> None:
|
||||
"""
|
||||
触发当前页面全局动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._open_action_confirm('global')
|
||||
|
||||
def action_trigger_utility_action(self) -> None:
|
||||
"""
|
||||
触发当前页面工具动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._open_action_confirm('utility')
|
||||
|
||||
|
||||
TUI_SCREEN_INTERACTION_SERVICE = TuiScreenInteractionService()
|
||||
@@ -0,0 +1,80 @@
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Input, Static
|
||||
|
||||
from cli.tui.copy import TUI_COPY
|
||||
|
||||
|
||||
class SearchInputScreen(ModalScreen[str | None]):
|
||||
"""
|
||||
通用搜索输入弹窗。
|
||||
|
||||
:param title: 弹窗标题
|
||||
:param placeholder: 输入框提示
|
||||
:param value: 当前搜索词
|
||||
:param suggestions: 候选建议
|
||||
"""
|
||||
|
||||
CSS = """
|
||||
SearchInputScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#search-dialog {
|
||||
width: 88;
|
||||
max-width: 88;
|
||||
border: heavy #38d8ff;
|
||||
background: #06111b;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#search-title {
|
||||
text-style: bold;
|
||||
color: #e9fcff;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#search-suggestions {
|
||||
color: #8fc6d6;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding('escape', 'cancel', TUI_COPY.build_confirm_binding_label('cancel'), show=False),
|
||||
Binding('enter', 'submit', TUI_COPY.build_confirm_binding_label('submit'), show=False),
|
||||
]
|
||||
|
||||
def __init__(self, title: str, placeholder: str, value: str, suggestions: list[str]) -> None:
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._placeholder = placeholder
|
||||
self._value = value
|
||||
self._suggestions = suggestions
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id='search-dialog'):
|
||||
yield Static(self._title, id='search-title', markup=False)
|
||||
yield Input(value=self._value, placeholder=self._placeholder, id='search-input')
|
||||
suggestion_text = '候选建议 · ' + (' '.join(self._suggestions[:6]) if self._suggestions else '暂无建议')
|
||||
yield Static(suggestion_text, id='search-suggestions', markup=False)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.call_after_refresh(self.query_one('#search-input', Input).focus)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
def action_submit(self) -> None:
|
||||
input_widget = self.query_one('#search-input', Input)
|
||||
self.dismiss(str(input_widget.value or '').strip())
|
||||
|
||||
@on(Input.Submitted, '#search-input')
|
||||
def on_input_submitted(self) -> None:
|
||||
self.action_submit()
|
||||
@@ -0,0 +1,59 @@
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class StatusCarrier(Protocol):
|
||||
"""
|
||||
状态载体协议。
|
||||
|
||||
该协议用于描述首页面板、详情分区和浏览记录这类具备 `status`
|
||||
属性的摘要统计对象。
|
||||
"""
|
||||
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatusSummary:
|
||||
"""
|
||||
状态摘要统计结果。
|
||||
|
||||
:param total_count: 总数量
|
||||
:param ok_count: 正常数量
|
||||
:param warn_count: 警告数量
|
||||
:param fail_count: 失败数量
|
||||
"""
|
||||
|
||||
total_count: int
|
||||
ok_count: int
|
||||
warn_count: int
|
||||
fail_count: int
|
||||
|
||||
|
||||
class StatusSummaryBuilder:
|
||||
"""
|
||||
状态摘要构建器。
|
||||
|
||||
该对象负责对带 `status` 属性的集合做统一计数,供 dashboard、
|
||||
detail、browser 三类 screen support 复用。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build(items: Iterable[StatusCarrier]) -> StatusSummary:
|
||||
"""
|
||||
统计状态摘要。
|
||||
|
||||
:param items: 待统计对象集合
|
||||
:return: 状态摘要
|
||||
"""
|
||||
materialized = list(items)
|
||||
return StatusSummary(
|
||||
total_count=len(materialized),
|
||||
ok_count=sum(1 for item in materialized if item.status == 'ok'),
|
||||
warn_count=sum(1 for item in materialized if item.status == 'warn'),
|
||||
fail_count=sum(1 for item in materialized if item.status == 'fail'),
|
||||
)
|
||||
|
||||
|
||||
STATUS_SUMMARY_BUILDER = StatusSummaryBuilder()
|
||||
@@ -0,0 +1,375 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway
|
||||
|
||||
CompletionProviderCallable = Callable[[object, list[str] | None, str], list[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageFilterOption:
|
||||
"""
|
||||
浏览页筛选项定义。
|
||||
|
||||
:param key: 筛选键
|
||||
:param label: 展示名称
|
||||
:param shortcut: 快捷键
|
||||
"""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
shortcut: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageSearchContext:
|
||||
"""
|
||||
浏览页搜索上下文定义。
|
||||
|
||||
:param placeholder: 搜索输入提示
|
||||
:param query: 当前搜索词
|
||||
:param suggestions: 候选建议列表
|
||||
"""
|
||||
|
||||
placeholder: str
|
||||
query: str = ''
|
||||
suggestions: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchSuggestionProviderSpec:
|
||||
"""
|
||||
页面搜索建议提供者定义。
|
||||
|
||||
:param placeholder: 搜索输入提示
|
||||
:param suggestion_provider: 候选建议提供函数
|
||||
"""
|
||||
|
||||
placeholder: str
|
||||
suggestion_provider: Callable[[str], list[str]] | None = None
|
||||
|
||||
|
||||
JOB_FILTER_OPTIONS: tuple[PageFilterOption, ...] = (
|
||||
PageFilterOption('all', '全部', '1'),
|
||||
PageFilterOption('failed', '失败', '2'),
|
||||
PageFilterOption('paused', '暂停', '3'),
|
||||
PageFilterOption('ok', '正常', '4'),
|
||||
)
|
||||
|
||||
CONFIG_FILTER_OPTIONS: tuple[PageFilterOption, ...] = (
|
||||
PageFilterOption('all', '全部', '1'),
|
||||
PageFilterOption('risky', '高风险', '2'),
|
||||
PageFilterOption('mismatch', '值不一致', '3'),
|
||||
PageFilterOption('cache-drift', '缓存异常', '4'),
|
||||
)
|
||||
|
||||
|
||||
class TuiSearchSuggestionProviderRegistry:
|
||||
"""
|
||||
TUI 搜索建议提供者注册表。
|
||||
|
||||
该对象负责管理页面搜索提示文案及其候选建议提供函数,避免搜索服务
|
||||
继续通过字符串反射隐式定位 completion provider。
|
||||
|
||||
:param providers: 页面搜索提供者映射
|
||||
"""
|
||||
|
||||
def __init__(self, providers: dict[str, SearchSuggestionProviderSpec]) -> None:
|
||||
"""
|
||||
初始化搜索建议提供者注册表。
|
||||
|
||||
:param providers: 页面搜索提供者映射
|
||||
:return: None
|
||||
"""
|
||||
self.providers = {self.normalize_view_key(key): value for key, value in providers.items()}
|
||||
|
||||
@staticmethod
|
||||
def normalize_view_key(view_key: str) -> str:
|
||||
"""
|
||||
规范化页面视图标识。
|
||||
|
||||
:param view_key: 原始页面视图标识
|
||||
:return: 规范化后的页面视图标识
|
||||
"""
|
||||
return str(view_key or '').strip().lower()
|
||||
|
||||
def get_provider(self, view_key: str) -> SearchSuggestionProviderSpec | None:
|
||||
"""
|
||||
获取指定页面的搜索建议提供者。
|
||||
|
||||
:param view_key: 页面视图标识
|
||||
:return: 搜索建议提供者定义
|
||||
"""
|
||||
return self.providers.get(self.normalize_view_key(view_key))
|
||||
|
||||
|
||||
class CompletionSuggestionProviderFactory:
|
||||
"""
|
||||
基于 completion 注册表构建 TUI 搜索建议提供函数。
|
||||
|
||||
:param completion_provider_gateway: completion 提供器对外网关
|
||||
"""
|
||||
|
||||
def __init__(self, completion_provider_gateway: CompletionProviderGateway) -> None:
|
||||
"""
|
||||
初始化 completion 搜索建议工厂。
|
||||
|
||||
:param completion_provider_gateway: completion 提供器对外网关
|
||||
:return: None
|
||||
"""
|
||||
self.completion_provider_gateway = completion_provider_gateway
|
||||
|
||||
def build(self, provider: CompletionProviderCallable) -> Callable[[str], list[str]]:
|
||||
"""
|
||||
根据显式 completion provider 构建建议提供函数。
|
||||
|
||||
:param provider: completion provider 可调用对象
|
||||
:return: 搜索建议提供函数
|
||||
"""
|
||||
|
||||
def provide(query: str) -> list[str]:
|
||||
"""
|
||||
调用 completion provider 生成候选建议。
|
||||
|
||||
:param query: 当前搜索词
|
||||
:return: 候选建议列表
|
||||
"""
|
||||
candidates = provider(None, [], str(query or '').strip())
|
||||
return [str(candidate).strip() for candidate in candidates if str(candidate).strip()]
|
||||
|
||||
return provide
|
||||
|
||||
|
||||
def build_default_search_provider_registry(
|
||||
completion_provider_gateway: CompletionProviderGateway,
|
||||
) -> TuiSearchSuggestionProviderRegistry:
|
||||
"""
|
||||
构建默认 TUI 搜索建议提供者注册表。
|
||||
|
||||
:param completion_provider_gateway: completion 提供器对外网关
|
||||
:return: 搜索建议提供者注册表
|
||||
"""
|
||||
provider_factory = CompletionSuggestionProviderFactory(completion_provider_gateway)
|
||||
return TuiSearchSuggestionProviderRegistry(
|
||||
providers={
|
||||
'jobs': SearchSuggestionProviderSpec(
|
||||
'按任务名搜索',
|
||||
provider_factory.build(completion_provider_gateway.complete_job_names),
|
||||
),
|
||||
'configs': SearchSuggestionProviderSpec(
|
||||
'按配置键搜索',
|
||||
provider_factory.build(completion_provider_gateway.complete_config_keys),
|
||||
),
|
||||
'cache': SearchSuggestionProviderSpec(
|
||||
'按缓存名搜索',
|
||||
provider_factory.build(completion_provider_gateway.complete_cache_names),
|
||||
),
|
||||
'gen': SearchSuggestionProviderSpec(
|
||||
'按业务表名搜索',
|
||||
provider_factory.build(completion_provider_gateway.complete_gen_table_names),
|
||||
),
|
||||
'database': SearchSuggestionProviderSpec(
|
||||
'按 revision 搜索',
|
||||
provider_factory.build(completion_provider_gateway.complete_alembic_revisions),
|
||||
),
|
||||
'app': SearchSuggestionProviderSpec('按分区或内容搜索'),
|
||||
'ops': SearchSuggestionProviderSpec('按分区或内容搜索'),
|
||||
'crypto': SearchSuggestionProviderSpec('按分区或内容搜索'),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TuiSearchHighlighter:
|
||||
"""
|
||||
TUI 搜索高亮器。
|
||||
|
||||
该对象负责对终端文本做轻量关键字高亮。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def highlight(text: str, query: str, *, left_tag: str = '【', right_tag: str = '】') -> str:
|
||||
"""
|
||||
对给定文本中的搜索词做轻量高亮。
|
||||
|
||||
:param text: 原始文本
|
||||
:param query: 搜索词
|
||||
:param left_tag: 左高亮标记
|
||||
:param right_tag: 右高亮标记
|
||||
:return: 高亮后的文本
|
||||
"""
|
||||
raw_text = str(text or '')
|
||||
normalized_query = str(query or '').strip()
|
||||
if not raw_text or not normalized_query:
|
||||
return raw_text
|
||||
lower_text = raw_text.lower()
|
||||
lower_query = normalized_query.lower()
|
||||
result: list[str] = []
|
||||
cursor = 0
|
||||
query_length = len(normalized_query)
|
||||
while True:
|
||||
index = lower_text.find(lower_query, cursor)
|
||||
if index < 0:
|
||||
result.append(raw_text[cursor:])
|
||||
break
|
||||
result.append(raw_text[cursor:index])
|
||||
result.append(f'{left_tag}{raw_text[index : index + query_length]}{right_tag}')
|
||||
cursor = index + query_length
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
class TuiSearchService:
|
||||
"""
|
||||
TUI 搜索服务。
|
||||
|
||||
该对象负责筛选项解析、筛选条文案构建、搜索上下文装配和详情分区过滤。
|
||||
"""
|
||||
|
||||
def __init__(self, provider_registry: TuiSearchSuggestionProviderRegistry) -> None:
|
||||
"""
|
||||
初始化 TUI 搜索服务。
|
||||
|
||||
:param provider_registry: 页面搜索建议提供者注册表
|
||||
:return: None
|
||||
"""
|
||||
self.provider_registry = provider_registry
|
||||
|
||||
@staticmethod
|
||||
def resolve_filter_option(
|
||||
options: list[PageFilterOption] | tuple[PageFilterOption, ...],
|
||||
filter_key: str | None,
|
||||
) -> PageFilterOption | None:
|
||||
"""
|
||||
从筛选项列表中解析指定键对应的筛选定义。
|
||||
|
||||
:param options: 筛选项列表
|
||||
:param filter_key: 筛选键
|
||||
:return: 匹配到的筛选项
|
||||
"""
|
||||
normalized_key = str(filter_key or '').strip().lower()
|
||||
if not normalized_key:
|
||||
return None
|
||||
for option in options:
|
||||
if option.key == normalized_key:
|
||||
return option
|
||||
return None
|
||||
|
||||
def build_filter_bar_text(
|
||||
self,
|
||||
options: list[PageFilterOption] | tuple[PageFilterOption, ...],
|
||||
active_filter_key: str | None,
|
||||
*,
|
||||
search_query: str | None = None,
|
||||
search_placeholder: str | None = None,
|
||||
search_suggestions: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
构建浏览页顶部筛选条文本。
|
||||
|
||||
:param options: 筛选项列表
|
||||
:param active_filter_key: 当前激活筛选键
|
||||
:param search_query: 当前搜索词
|
||||
:param search_placeholder: 搜索提示文本
|
||||
:param search_suggestions: 搜索候选建议
|
||||
:return: 筛选条文本
|
||||
"""
|
||||
if not options and not search_placeholder:
|
||||
return ''
|
||||
lines: list[str] = []
|
||||
normalized_active = str(active_filter_key or '').strip().lower()
|
||||
if options:
|
||||
rendered_items = [
|
||||
f'[{option.shortcut}] {option.label}{" *" if option.key == normalized_active else ""}'
|
||||
for option in options
|
||||
]
|
||||
active_option = self.resolve_filter_option(options, normalized_active)
|
||||
active_label = active_option.label if active_option is not None else '未指定'
|
||||
lines.extend(
|
||||
[
|
||||
f'筛选器 · {" ".join(rendered_items)}',
|
||||
f'当前筛选 · {active_label}',
|
||||
]
|
||||
)
|
||||
if search_placeholder:
|
||||
normalized_query = str(search_query or '').strip()
|
||||
lines.append(f'搜索器 · [/] {search_placeholder}')
|
||||
lines.append(f'当前搜索 · {normalized_query or "未指定"} | [Backspace] 清空')
|
||||
if search_suggestions:
|
||||
lines.append(f'候选建议 · {" ".join(search_suggestions[:4])}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def resolve_search_context(
|
||||
self,
|
||||
view_key: str,
|
||||
query: str = '',
|
||||
*,
|
||||
default_suggestions: list[str] | None = None,
|
||||
) -> PageSearchContext | None:
|
||||
"""
|
||||
根据页面视图解析搜索上下文,并复用 completion provider 生成候选建议。
|
||||
|
||||
:param view_key: 当前页面视图标识
|
||||
:param query: 当前搜索词
|
||||
:param default_suggestions: 缺省候选建议
|
||||
:return: 搜索上下文
|
||||
"""
|
||||
normalized_view = str(view_key).strip().lower()
|
||||
provider_spec = self.provider_registry.get_provider(normalized_view)
|
||||
if provider_spec is None:
|
||||
return None
|
||||
suggestions = self.resolve_search_suggestions(provider_spec, query)
|
||||
if not suggestions and default_suggestions:
|
||||
normalized_query = str(query or '').strip().lower()
|
||||
suggestions = [
|
||||
suggestion
|
||||
for suggestion in default_suggestions
|
||||
if suggestion and (not normalized_query or normalized_query in suggestion.lower())
|
||||
]
|
||||
return PageSearchContext(
|
||||
placeholder=provider_spec.placeholder,
|
||||
query=str(query or '').strip(),
|
||||
suggestions=suggestions[:6],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def resolve_search_suggestions(provider_spec: SearchSuggestionProviderSpec, query: str) -> list[str]:
|
||||
"""
|
||||
调用搜索建议提供函数生成当前搜索词的候选建议。
|
||||
|
||||
:param provider_spec: 搜索建议提供者定义
|
||||
:param query: 当前搜索词
|
||||
:return: 候选建议列表
|
||||
"""
|
||||
normalized_query = str(query or '').strip()
|
||||
if not normalized_query or not callable(provider_spec.suggestion_provider):
|
||||
return []
|
||||
try:
|
||||
candidates = provider_spec.suggestion_provider(normalized_query)
|
||||
except Exception:
|
||||
return []
|
||||
return [str(candidate).strip() for candidate in candidates if str(candidate).strip()]
|
||||
|
||||
@staticmethod
|
||||
def filter_detail_sections(sections: list[object], query: str) -> list[object]:
|
||||
"""
|
||||
按搜索词过滤详情页分区列表。
|
||||
|
||||
:param sections: 原始分区列表
|
||||
:param query: 当前搜索词
|
||||
:return: 过滤后的分区列表
|
||||
"""
|
||||
normalized_query = str(query or '').strip().lower()
|
||||
if not normalized_query:
|
||||
return sections
|
||||
filtered_sections: list[object] = []
|
||||
for section in sections:
|
||||
title = str(getattr(section, 'title', '') or '').strip().lower()
|
||||
lines = getattr(section, 'lines', [])
|
||||
joined_lines = ' '.join(str(line).strip().lower() for line in lines if str(line).strip())
|
||||
if normalized_query in title or normalized_query in joined_lines:
|
||||
filtered_sections.append(section)
|
||||
return filtered_sections
|
||||
|
||||
|
||||
TUI_SEARCH_HIGHLIGHTER = TuiSearchHighlighter()
|
||||
TUI_SEARCH_PROVIDER_REGISTRY = build_default_search_provider_registry(COMPLETION_PROVIDER_GATEWAY)
|
||||
TUI_SEARCH_SERVICE = TuiSearchService(TUI_SEARCH_PROVIDER_REGISTRY)
|
||||
@@ -0,0 +1,27 @@
|
||||
from cli.tui.widgets.status_panel import MetricPanel, SignalRail, StatusPanel
|
||||
from cli.tui.widgets.workspace import (
|
||||
NavigationItem,
|
||||
RecordDetailView,
|
||||
RecordNavigator,
|
||||
RecordSummaryView,
|
||||
SectionDetailView,
|
||||
SectionNavigator,
|
||||
WorkspaceHeader,
|
||||
WorkspaceHero,
|
||||
WorkspaceSidebar,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'MetricPanel',
|
||||
'NavigationItem',
|
||||
'RecordDetailView',
|
||||
'RecordNavigator',
|
||||
'RecordSummaryView',
|
||||
'SectionDetailView',
|
||||
'SectionNavigator',
|
||||
'SignalRail',
|
||||
'StatusPanel',
|
||||
'WorkspaceHeader',
|
||||
'WorkspaceHero',
|
||||
'WorkspaceSidebar',
|
||||
]
|
||||
@@ -0,0 +1,323 @@
|
||||
from textual.widgets import Static
|
||||
|
||||
from cli.tui.copy import TUI_COPY
|
||||
|
||||
|
||||
class StatusPanelRenderingSupport:
|
||||
"""
|
||||
状态面板渲染支持对象。
|
||||
|
||||
该对象负责状态类名解析、结构化正文渲染和信号带脉冲文本构建,
|
||||
供状态面板、指标卡与信号带组件复用。
|
||||
"""
|
||||
|
||||
PULSE_FRAMES = ['·', '•', '◉', '•']
|
||||
|
||||
@staticmethod
|
||||
def resolve_status_class(status: str) -> str:
|
||||
"""
|
||||
根据状态值解析样式类。
|
||||
|
||||
:param status: 面板状态
|
||||
:return: 状态样式类名
|
||||
"""
|
||||
normalized_status = str(status).strip().lower()
|
||||
if normalized_status in {'ok', 'success', 'healthy'}:
|
||||
return 'is-ok'
|
||||
if normalized_status in {'fail', 'error', 'down'}:
|
||||
return 'is-fail'
|
||||
if normalized_status in {'warn', 'warning', 'degraded'}:
|
||||
return 'is-warn'
|
||||
return 'is-info'
|
||||
|
||||
def render_structured_body(self, body: str) -> str:
|
||||
"""
|
||||
将结构化正文文本渲染为更有层次的终端块内容。
|
||||
|
||||
支持以下约定:
|
||||
- `## ` 开头:分组标题
|
||||
- `> ` 开头:说明或子项
|
||||
- 空字符串:保留空行
|
||||
|
||||
:param body: 原始正文文本
|
||||
:return: 渲染后的正文文本
|
||||
"""
|
||||
rendered_lines: list[str] = []
|
||||
for raw_line in body.splitlines():
|
||||
stripped = raw_line.strip()
|
||||
if not stripped:
|
||||
if rendered_lines and rendered_lines[-1] != '':
|
||||
rendered_lines.append('')
|
||||
continue
|
||||
if stripped.startswith('## '):
|
||||
title = stripped[3:].strip()
|
||||
if rendered_lines and rendered_lines[-1] != '':
|
||||
rendered_lines.append('')
|
||||
rendered_lines.append(f'【{title}】')
|
||||
rendered_lines.append('─' * max(10, min(len(title) * 2, 28)))
|
||||
continue
|
||||
if stripped.startswith('> '):
|
||||
rendered_lines.append(f'│ {stripped[2:].strip()}')
|
||||
continue
|
||||
rendered_lines.append(f'• {stripped}')
|
||||
|
||||
while rendered_lines and rendered_lines[0] == '':
|
||||
rendered_lines.pop(0)
|
||||
while rendered_lines and rendered_lines[-1] == '':
|
||||
rendered_lines.pop()
|
||||
return '\n'.join(rendered_lines) if rendered_lines else f'- {TUI_COPY.build_status_panel_empty_text()}'
|
||||
|
||||
def build_signal_rail_text(self, lines: list[str], pulse_index: int) -> str:
|
||||
"""
|
||||
构建信号带渲染文本。
|
||||
|
||||
:param lines: 信号文本行
|
||||
:param pulse_index: 当前脉冲帧索引
|
||||
:return: 渲染文本
|
||||
"""
|
||||
pulse = self.PULSE_FRAMES[pulse_index % len(self.PULSE_FRAMES)]
|
||||
body = '\n'.join(line for line in lines if line.strip())
|
||||
return TUI_COPY.build_signal_rail_text(pulse, body or TUI_COPY.build_signal_rail_empty_text())
|
||||
|
||||
|
||||
STATUS_PANEL_RENDERING = StatusPanelRenderingSupport()
|
||||
|
||||
|
||||
class StatusPanel(Static):
|
||||
"""
|
||||
TUI 状态面板组件。
|
||||
|
||||
:param title: 面板标题
|
||||
:param status: 面板状态
|
||||
:param body: 面板正文
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
StatusPanel {
|
||||
border: double #245874;
|
||||
padding: 1 2;
|
||||
margin: 0;
|
||||
height: 17;
|
||||
min-height: 17;
|
||||
background: #08131f;
|
||||
color: #def8ff;
|
||||
}
|
||||
|
||||
StatusPanel.is-ok {
|
||||
border: double #22c983;
|
||||
background: #081b17;
|
||||
}
|
||||
|
||||
StatusPanel.is-fail {
|
||||
border: double #ff6b7a;
|
||||
background: #241118;
|
||||
}
|
||||
|
||||
StatusPanel.is-warn {
|
||||
border: double #f2b84b;
|
||||
background: #261d0f;
|
||||
}
|
||||
|
||||
StatusPanel.is-info {
|
||||
border: double #38d8ff;
|
||||
background: #081825;
|
||||
}
|
||||
|
||||
StatusPanel:focus {
|
||||
border: heavy #6df7ff;
|
||||
background: #10364b;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
status: str,
|
||||
body: str,
|
||||
rendering: StatusPanelRenderingSupport | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化状态面板。
|
||||
|
||||
:param title: 面板标题
|
||||
:param status: 面板状态
|
||||
:param body: 面板正文
|
||||
:param rendering: 状态面板渲染支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.title = title
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.rendering = rendering or STATUS_PANEL_RENDERING
|
||||
super().__init__(self._build_render_text(), markup=False)
|
||||
self.can_focus = True
|
||||
self.add_class(self.rendering.resolve_status_class(self.status))
|
||||
|
||||
def _build_render_text(self) -> str:
|
||||
"""
|
||||
构建面板渲染文本。
|
||||
|
||||
:return: 渲染文本
|
||||
"""
|
||||
rendered_body = self.rendering.render_structured_body(self.body)
|
||||
normalized_status = TUI_COPY.render_status_label(self.status)
|
||||
return TUI_COPY.build_status_panel_text(
|
||||
self.title,
|
||||
TUI_COPY.render_status_code(self.status),
|
||||
normalized_status,
|
||||
rendered_body,
|
||||
)
|
||||
|
||||
|
||||
class MetricPanel(Static):
|
||||
"""
|
||||
TUI 首页驾驶舱指标卡组件。
|
||||
|
||||
:param title: 指标标题
|
||||
:param value: 指标主值
|
||||
:param status: 指标状态
|
||||
:param hint: 指标补充说明
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
MetricPanel {
|
||||
border: double #245874;
|
||||
padding: 1 2;
|
||||
height: 12;
|
||||
min-height: 12;
|
||||
background: #08131f;
|
||||
color: #effcff;
|
||||
}
|
||||
|
||||
MetricPanel.is-primary {
|
||||
border: heavy #6df7ff;
|
||||
height: 12;
|
||||
min-height: 12;
|
||||
background: #0a1725;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
MetricPanel.is-ok {
|
||||
border: double #22c983;
|
||||
background: #081b17;
|
||||
}
|
||||
|
||||
MetricPanel.is-fail {
|
||||
border: double #ff6b7a;
|
||||
background: #241118;
|
||||
}
|
||||
|
||||
MetricPanel.is-warn {
|
||||
border: double #f2b84b;
|
||||
background: #261d0f;
|
||||
}
|
||||
|
||||
MetricPanel.is-info {
|
||||
border: double #38d8ff;
|
||||
background: #081825;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
value: str,
|
||||
status: str,
|
||||
hint: str,
|
||||
*,
|
||||
accent: bool = False,
|
||||
rendering: StatusPanelRenderingSupport | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化指标卡。
|
||||
|
||||
:param title: 指标标题
|
||||
:param value: 指标主值
|
||||
:param status: 指标状态
|
||||
:param hint: 指标补充说明
|
||||
:param accent: 是否作为主指标高亮展示
|
||||
:param rendering: 状态面板渲染支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.status = status
|
||||
self.hint = hint
|
||||
self.rendering = rendering or STATUS_PANEL_RENDERING
|
||||
super().__init__(self._build_render_text(), markup=False)
|
||||
self.add_class(self.rendering.resolve_status_class(self.status))
|
||||
if accent:
|
||||
self.add_class('is-primary')
|
||||
|
||||
def _build_render_text(self) -> str:
|
||||
"""
|
||||
构建指标卡渲染文本。
|
||||
|
||||
:return: 渲染文本
|
||||
"""
|
||||
normalized_status = TUI_COPY.render_status_label(self.status)
|
||||
return TUI_COPY.build_metric_panel_text(
|
||||
self.title,
|
||||
self.value,
|
||||
TUI_COPY.render_status_code(self.status),
|
||||
normalized_status,
|
||||
self.hint,
|
||||
)
|
||||
|
||||
|
||||
class SignalRail(Static):
|
||||
"""
|
||||
首页信号带组件,用于展示简洁的系统态势条目。
|
||||
|
||||
:param lines: 信号文本行
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
SignalRail {
|
||||
border: double #1db9d1;
|
||||
padding: 1 2;
|
||||
margin-top: 1;
|
||||
margin-bottom: 1;
|
||||
min-height: 6;
|
||||
background: #06111b;
|
||||
color: #e9fdff;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, lines: list[str], rendering: StatusPanelRenderingSupport | None = None) -> None:
|
||||
"""
|
||||
初始化首页信号带。
|
||||
|
||||
:param lines: 信号文本行
|
||||
:param rendering: 状态面板渲染支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.lines = lines
|
||||
self._pulse_index = 0
|
||||
self.rendering = rendering or STATUS_PANEL_RENDERING
|
||||
super().__init__(self._build_render_text(), markup=False)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""
|
||||
挂载后启动轻量脉冲刷新。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.set_interval(0.8, self._advance_pulse, name='signal rail pulse')
|
||||
|
||||
def _advance_pulse(self) -> None:
|
||||
"""
|
||||
推进信号带脉冲状态。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._pulse_index = (self._pulse_index + 1) % 4
|
||||
self.update(self._build_render_text())
|
||||
|
||||
def _build_render_text(self) -> str:
|
||||
"""
|
||||
构建信号带渲染文本。
|
||||
|
||||
:return: 渲染文本
|
||||
"""
|
||||
return self.rendering.build_signal_rail_text(self.lines, self._pulse_index)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user