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()