Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/cli/tui/adapters/cache.py
T

563 lines
21 KiB
Python

import asyncio
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.queries import TUI_RUNTIME_QUERIES, TuiRuntimeQueryService
from cli.utils import 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,
query_service: TuiRuntimeQueryService | None = None,
) -> None:
"""
初始化缓存浏览页分区构建器。
:param page_adapter: 缓存浏览页适配器
:param row_extractor: 缓存浏览行提取器
:return: None
"""
self.page_adapter = page_adapter
self.row_extractor = row_extractor or CacheRowExtractor()
self.query_service = query_service or TUI_RUNTIME_QUERIES
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)
async 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, ttl_payload = await asyncio.gather(
self.query_service.get_cache_value(cache_name, cache_key),
self.query_service.get_cache_ttl(cache_name, cache_key),
)
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
async 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 = await self.query_service.get_cache_keys(cache_name)
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),
*(await 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,
query_service: TuiRuntimeQueryService | 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.query_service = query_service or TUI_RUNTIME_QUERIES
self.section_builder = section_builder or CacheSectionBuilder(
self,
self.row_extractor,
self.query_service,
)
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()
]
async def collect_snapshot(self, env: str, query: str = '') -> BrowserPageSnapshot:
"""
采集缓存浏览页只读快照。
:param env: 当前运行环境
:param query: 当前搜索词
:return: 浏览页快照
"""
search_context = self.resolve_search_context(query)
payload = await self.query_service.get_cache_stats()
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()