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

580 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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