diff --git a/backend/app/admin/api/v1/monitor/redis.py b/backend/app/admin/api/v1/monitor/redis.py index 2d67037f..54234af1 100644 --- a/backend/app/admin/api/v1/monitor/redis.py +++ b/backend/app/admin/api/v1/monitor/redis.py @@ -1,16 +1,40 @@ from fastapi import APIRouter -from backend.common.response.response_schema import ResponseModel, response_base +from backend.app.admin.schema.monitor import RedisCommandStat, RedisMonitorInfo, RedisServerInfo +from backend.common.response.response_schema import ResponseSchemaModel, response_base from backend.common.security.jwt import DependsJwtAuth -from backend.utils.redis_info import redis_info +from backend.database.redis import redis_client +from backend.utils.format import fmt_seconds router = APIRouter() @router.get('', summary='redis 监控', dependencies=[DependsJwtAuth]) -async def get_redis_info() -> ResponseModel: - data = { - 'info': await redis_info.get_info(), - 'stats': await redis_info.get_stats(), - } +async def get_redis_info() -> ResponseSchemaModel[RedisMonitorInfo]: + info = await redis_client.info() + db_size = await redis_client.dbsize() + + uptime_formatted = fmt_seconds(int(info.get('uptime_in_seconds', 0))) + + server_info = RedisServerInfo( + redis_version=str(info.get('redis_version', '')), + redis_mode=str(info.get('redis_mode', '')), + os=str(info.get('os', '')), + arch_bits=str(info.get('arch_bits', '')), + tcp_port=str(info.get('tcp_port', '')), + uptime_in_seconds=uptime_formatted, + connected_clients=str(info.get('connected_clients', '')), + used_memory_human=str(info.get('used_memory_human', '')), + used_memory_peak_human=str(info.get('used_memory_peak_human', '')), + maxmemory_human=str(info.get('maxmemory_human', '0B')), + keys_num=str(db_size), + ) + + command_stats = await redis_client.info('commandstats') + stats_list = [] + for key, value in command_stats.items(): + if isinstance(value, dict): + stats_list.append(RedisCommandStat(name=key.split('_')[-1], value=str(value.get('calls', '0')))) + + data = RedisMonitorInfo(info=server_info, stats=stats_list) return response_base.success(data=data) diff --git a/backend/app/admin/api/v1/monitor/server.py b/backend/app/admin/api/v1/monitor/server.py index be7c2d43..2b0b80dc 100644 --- a/backend/app/admin/api/v1/monitor/server.py +++ b/backend/app/admin/api/v1/monitor/server.py @@ -1,21 +1,134 @@ +import os +import platform +import socket +import sys + +from datetime import datetime +from datetime import timezone as tz + +import psutil + from fastapi import APIRouter from starlette.concurrency import run_in_threadpool -from backend.common.response.response_schema import ResponseModel, response_base +from backend.app.admin.schema.monitor import ( + CpuInfo, + DiskInfo, + MemInfo, + ServerMonitorInfo, + ServiceInfo, + SysInfo, +) +from backend.common.response.response_schema import ResponseSchemaModel, response_base from backend.common.security.jwt import DependsJwtAuth -from backend.utils.server_info import server_info +from backend.utils.format import fmt_bytes, fmt_seconds +from backend.utils.timezone import timezone router = APIRouter() @router.get('', summary='server 监控', dependencies=[DependsJwtAuth]) -async def get_server_info() -> ResponseModel: - data = { - # 扔到线程池,避免阻塞 - 'cpu': await run_in_threadpool(server_info.get_cpu_info), - 'mem': await run_in_threadpool(server_info.get_mem_info), - 'sys': await run_in_threadpool(server_info.get_sys_info), - 'disk': await run_in_threadpool(server_info.get_disk_info), - 'service': await run_in_threadpool(server_info.get_service_info), - } +async def get_server_info() -> ResponseSchemaModel[ServerMonitorInfo]: # noqa: C901 + def get_all_info() -> ServerMonitorInfo: # noqa: C901 + # CPU 信息 + cpu_data = { + 'usage': round(psutil.cpu_percent(interval=0.1), 2), + 'logical_num': psutil.cpu_count(logical=True) or 0, + 'physical_num': psutil.cpu_count(logical=False) or 0, + 'max_freq': 0.0, + 'min_freq': 0.0, + 'current_freq': 0.0, + } + + try: + if hasattr(psutil, 'cpu_freq'): + cpu_freq = psutil.cpu_freq() + if cpu_freq: + cpu_data.update({ + 'max_freq': round(cpu_freq.max, 2), + 'min_freq': round(cpu_freq.min, 2), + 'current_freq': round(cpu_freq.current, 2), + }) + except Exception: + pass + + cpu = CpuInfo(**cpu_data) + + # 内存信息 + mem = psutil.virtual_memory() + gb_factor = 1024**3 + mem_info = MemInfo( + total=round(mem.total / gb_factor, 2), + used=round(mem.used / gb_factor, 2), + free=round(mem.available / gb_factor, 2), + usage=round(mem.percent, 2), + ) + + # 系统信息 + hostname = socket.gethostname() + ip = '127.0.0.1' + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.settimeout(0.5) + s.connect(('8.8.8.8', 80)) + ip = s.getsockname()[0] + except (TimeoutError, socket.gaierror, OSError): + pass + sys_info = SysInfo(name=hostname, ip=ip, os=platform.system(), arch=platform.machine()) + + # 磁盘信息 + disk_list = [] + exclude_fstypes = {'overlay', 'overlay2', 'tmpfs', 'devtmpfs', 'shm', 'proc', 'sysfs', 'cgroup', 'cgroup2'} + seen_devices = set() + for partition in psutil.disk_partitions(all=False): + # 跳过虚拟文件系统 + if partition.fstype.lower() in exclude_fstypes: + continue + # 跳过重复设备(同一设备的不同挂载点) + if partition.device in seen_devices: + continue + try: + usage = psutil.disk_usage(partition.mountpoint) + if usage: + seen_devices.add(partition.device) + disk_list.append( + DiskInfo( + dir=partition.mountpoint, + type=partition.fstype, + device=partition.device, + total=fmt_bytes(usage.total), + free=fmt_bytes(usage.free), + used=fmt_bytes(usage.used), + usage=f'{usage.percent:.2f}%', + ) + ) + except (PermissionError, OSError): + continue + + # 服务信息 + process = psutil.Process(os.getpid()) + proc_mem = process.memory_info() + try: + create_time = datetime.fromtimestamp(process.create_time(), tz=tz.utc) + start_time = timezone.from_datetime(create_time) + except (psutil.NoSuchProcess, OSError): + start_time = timezone.now() + + elapsed = fmt_seconds(round((timezone.now() - start_time).total_seconds())) + + service = ServiceInfo( + name='Python3', + version=platform.python_version(), + home=sys.executable, + cpu_usage=f'{process.cpu_percent(interval=0.1):.2f}%', + mem_vms=fmt_bytes(proc_mem.vms), + mem_rss=fmt_bytes(proc_mem.rss), + mem_free=fmt_bytes(proc_mem.vms - proc_mem.rss), + startup=timezone.to_str(start_time), + elapsed=elapsed, + ) + + return ServerMonitorInfo(cpu=cpu, mem=mem_info, sys=sys_info, disk=disk_list, service=service) + + data = await run_in_threadpool(get_all_info) return response_base.success(data=data) diff --git a/backend/app/admin/schema/monitor.py b/backend/app/admin/schema/monitor.py new file mode 100644 index 00000000..28f1f3d8 --- /dev/null +++ b/backend/app/admin/schema/monitor.py @@ -0,0 +1,98 @@ +from pydantic import Field + +from backend.common.schema import SchemaBase + + +class CpuInfo(SchemaBase): + """CPU 信息""" + + usage: float = Field(description='CPU 使用率 (%)') + logical_num: int = Field(description='逻辑核心数') + physical_num: int = Field(description='物理核心数') + max_freq: float = Field(description='最大频率 (MHz)') + min_freq: float = Field(description='最小频率 (MHz)') + current_freq: float = Field(description='当前频率 (MHz)') + + +class MemInfo(SchemaBase): + """内存信息""" + + total: float = Field(description='总内存 (GB)') + used: float = Field(description='已使用内存 (GB)') + free: float = Field(description='可用内存 (GB)') + usage: float = Field(description='内存使用率 (%)') + + +class SysInfo(SchemaBase): + """系统信息""" + + name: str = Field(description='主机名') + ip: str = Field(description='IP 地址') + os: str = Field(description='操作系统') + arch: str = Field(description='系统架构') + + +class DiskInfo(SchemaBase): + """磁盘信息""" + + dir: str = Field(description='挂载点') + type: str = Field(description='文件系统类型') + device: str = Field(description='设备名称') + total: str = Field(description='总容量') + free: str = Field(description='可用容量') + used: str = Field(description='已使用容量') + usage: str = Field(description='使用率') + + +class ServiceInfo(SchemaBase): + """服务信息""" + + name: str = Field(description='服务名称') + version: str = Field(description='版本') + home: str = Field(description='安装路径') + cpu_usage: str = Field(description='CPU 使用率') + mem_vms: str = Field(description='虚拟内存') + mem_rss: str = Field(description='物理内存') + mem_free: str = Field(description='可用内存') + startup: str = Field(description='启动时间') + elapsed: str = Field(description='运行时长') + + +class ServerMonitorInfo(SchemaBase): + """服务器监控信息""" + + cpu: CpuInfo = Field(description='CPU 信息') + mem: MemInfo = Field(description='内存信息') + sys: SysInfo = Field(description='系统信息') + disk: list[DiskInfo] = Field(description='磁盘信息列表') + service: ServiceInfo = Field(description='服务信息') + + +class RedisServerInfo(SchemaBase): + """Redis 服务器信息""" + + redis_version: str = Field(description='Redis 版本') + redis_mode: str = Field(description='运行模式') + os: str = Field(description='操作系统') + arch_bits: str = Field(description='架构位数') + tcp_port: str = Field(description='TCP 端口') + uptime_in_seconds: str = Field(description='运行时长') + connected_clients: str = Field(description='已连接客户端数') + used_memory_human: str = Field(description='已使用内存') + used_memory_peak_human: str = Field(description='内存使用峰值') + maxmemory_human: str = Field(description='最大内存限制') + keys_num: str = Field(description='键总数') + + +class RedisCommandStat(SchemaBase): + """Redis 命令统计""" + + name: str = Field(description='命令名称') + value: str = Field(description='调用次数') + + +class RedisMonitorInfo(SchemaBase): + """Redis 监控信息""" + + info: RedisServerInfo = Field(description='服务器信息') + stats: list[RedisCommandStat] = Field(description='命令统计列表') diff --git a/backend/utils/format.py b/backend/utils/format.py new file mode 100644 index 00000000..5c2fb729 --- /dev/null +++ b/backend/utils/format.py @@ -0,0 +1,24 @@ +def fmt_seconds(seconds: int) -> str: + """格式化秒数为可读的时间字符串""" + days, rem = divmod(int(seconds), 86400) + hours, rem = divmod(rem, 3600) + minutes, secs = divmod(rem, 60) + parts = [] + if days: + parts.append(f'{days} 天') + if hours: + parts.append(f'{hours} 小时') + if minutes: + parts.append(f'{minutes} 分钟') + if secs: + parts.append(f'{secs} 秒') + return ' '.join(parts) if parts else '0 秒' + + +def fmt_bytes(size: float) -> str: + s, factor = size, 1024 + for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']: + if abs(s) < factor: + return f'{s:.2f} {unit}B' + s /= factor + return f'{s:.2f} YB' diff --git a/backend/utils/redis_info.py b/backend/utils/redis_info.py deleted file mode 100644 index c662b732..00000000 --- a/backend/utils/redis_info.py +++ /dev/null @@ -1,52 +0,0 @@ -from backend.database.redis import redis_client -from backend.utils.server_info import server_info - - -class RedisInfo: - @staticmethod - async def get_info() -> dict[str, str]: - """获取 Redis 服务器信息""" - - # 获取原始信息 - info = await redis_client.info() - - # 格式化信息 - fmt_info: dict[str, str] = {} - for key, value in info.items(): - if isinstance(value, dict): - # 将字典格式化为字符串 - fmt_info[key] = ','.join(f'{k}={v}' for k, v in value.items()) - else: - fmt_info[key] = str(value) - - # 添加数据库大小信息 - db_size = await redis_client.dbsize() - fmt_info['keys_num'] = str(db_size) - - # 格式化运行时间 - uptime = int(fmt_info.get('uptime_in_seconds', '0')) - fmt_info['uptime_in_seconds'] = server_info.fmt_seconds(uptime) - - return fmt_info - - @staticmethod - async def get_stats() -> list[dict[str, str]]: - """获取 Redis 命令统计信息""" - - # 获取命令统计信息 - command_stats = await redis_client.info('commandstats') - - # 格式化统计信息 - stats_list: list[dict[str, str]] = [] - for key, value in command_stats.items(): - if not isinstance(value, dict): - continue - - command_name = key.split('_')[-1] - call_count = str(value.get('calls', '0')) - stats_list.append({'name': command_name, 'value': call_count}) - - return stats_list - - -redis_info: RedisInfo = RedisInfo() diff --git a/backend/utils/server_info.py b/backend/utils/server_info.py deleted file mode 100644 index 973e3676..00000000 --- a/backend/utils/server_info.py +++ /dev/null @@ -1,168 +0,0 @@ -import os -import platform -import socket -import sys - -from datetime import datetime, timedelta -from datetime import timezone as tz - -import psutil - -from backend.utils.timezone import timezone - - -class ServerInfo: - @staticmethod - def format_bytes(size: float) -> str: - """ - 格式化字节大小 - - :param size: 字节大小 - :return: - """ - factor = 1024 - for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']: - if abs(size) < factor: - return f'{size:.2f} {unit}B' - size /= factor - return f'{size:.2f} YB' - - @staticmethod - def fmt_seconds(seconds: int) -> str: - """ - 格式化秒数为可读的时间字符串 - - :param seconds: 秒数 - :return: - """ - days, rem = divmod(int(seconds), 86400) - hours, rem = divmod(rem, 3600) - minutes, seconds = divmod(rem, 60) - - parts = [] - if days: - parts.append(f'{days} 天') - if hours: - parts.append(f'{hours} 小时') - if minutes: - parts.append(f'{minutes} 分钟') - if seconds: - parts.append(f'{seconds} 秒') - - return ' '.join(parts) if parts else '0 秒' - - @staticmethod - def fmt_timedelta(td: timedelta) -> str: - """ - 格式化时间差 - - :param td: 时间差对象 - :return: - """ - return ServerInfo.fmt_seconds(round(td.total_seconds())) - - @staticmethod - def get_cpu_info() -> dict[str, float | int]: - """获取 CPU 信息""" - cpu_info = { - 'usage': round(psutil.cpu_percent(interval=0.1), 2), # % - 'logical_num': psutil.cpu_count(logical=True) or 0, - 'physical_num': psutil.cpu_count(logical=False) or 0, - 'max_freq': 0.0, - 'min_freq': 0.0, - 'current_freq': 0.0, - } - - try: - if hasattr(psutil, 'cpu_freq'): - cpu_freq = psutil.cpu_freq() - if cpu_freq: # Some systems return None - cpu_info.update({ - 'max_freq': round(cpu_freq.max, 2), - 'min_freq': round(cpu_freq.min, 2), - 'current_freq': round(cpu_freq.current, 2), - }) - except Exception: - pass - - return cpu_info - - @staticmethod - def get_mem_info() -> dict[str, float]: - """获取内存信息""" - mem = psutil.virtual_memory() - gb_factor = 1024**3 - return { - 'total': round(mem.total / gb_factor, 2), - 'used': round(mem.used / gb_factor, 2), - 'free': round(mem.available / gb_factor, 2), - 'usage': round(mem.percent, 2), - } - - @staticmethod - def get_sys_info() -> dict[str, str]: - """获取服务器信息""" - hostname = socket.gethostname() - ip = '127.0.0.1' - - try: - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: - s.settimeout(0.5) - s.connect(('8.8.8.8', 80)) - ip = s.getsockname()[0] - except (TimeoutError, socket.gaierror, OSError): - pass - - return { - 'name': hostname, - 'ip': ip, - 'os': platform.system(), - 'arch': platform.machine(), - } - - @staticmethod - def get_disk_info() -> list[dict[str, str]]: - """获取磁盘信息""" - disk_info = [] - for partition in psutil.disk_partitions(all=False): - usage = psutil.disk_usage(partition.mountpoint) - if usage: - disk_info.append({ - 'dir': partition.mountpoint, - 'type': partition.fstype, - 'device': partition.device, - 'total': ServerInfo.format_bytes(usage.total), - 'free': ServerInfo.format_bytes(usage.free), - 'used': ServerInfo.format_bytes(usage.used), - 'usage': f'{usage.percent:.2f}%', - }) - return disk_info - - @staticmethod - def get_service_info() -> dict[str, str | datetime]: - """获取服务信息""" - process = psutil.Process(os.getpid()) - mem_info = process.memory_info() - - try: - create_time = datetime.fromtimestamp(process.create_time(), tz=tz.utc) - start_time = timezone.from_datetime(create_time) - except (psutil.NoSuchProcess, OSError): - start_time = timezone.now() - - elapsed = ServerInfo.fmt_timedelta(timezone.now() - start_time) - - return { - 'name': 'Python3', - 'version': platform.python_version(), - 'home': sys.executable, - 'cpu_usage': f'{process.cpu_percent(interval=0.1):.2f}%', - 'mem_vms': ServerInfo.format_bytes(mem_info.vms), - 'mem_rss': ServerInfo.format_bytes(mem_info.rss), - 'mem_free': ServerInfo.format_bytes(mem_info.vms - mem_info.rss), - 'startup': timezone.to_str(start_time), - 'elapsed': elapsed, - } - - -server_info: ServerInfo = ServerInfo()