权限监控与管理界面全面美化

重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效:
- 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式
- 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充
- 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮
- IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格
- 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片
- 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器
- 角色概览卡片:加进度条可视化,hover 微动效
- 接口权限矩阵:工具栏分离布局,表格圆角包裹
- 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-20 14:36:35 +08:00
parent e95eeed90e
commit 6cefa620e4
68 changed files with 6927 additions and 984 deletions
+129
View File
@@ -0,0 +1,129 @@
"""IP 属地解析服务。
优先使用 ip2region xdb 离线库;缺少依赖或数据文件时降级为基础地址分类。
"""
from __future__ import annotations
import ipaddress
import logging
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from app.core.config import settings
logger = logging.getLogger("ctms.ip_location")
@dataclass(frozen=True)
class IpLocation:
location: str
country: str = ""
province: str = ""
city: str = ""
isp: str = ""
def _default_xdb_path(version: int) -> Path:
filename = "ip2region_v6.xdb" if version == 6 else "ip2region_v4.xdb"
return Path(__file__).resolve().parents[1] / "data" / filename
def _normalize_part(value: str | None) -> str:
part = (value or "").strip()
return "" if part in {"", "0", "-"} else part
def _format_location(country: str, province: str, city: str, isp: str) -> str:
parts = [part for part in [country, province, city, isp] if part]
return " / ".join(parts) if parts else "公网"
class Ip2RegionResolver:
def __init__(self, db_path: Optional[str] = None, ipv6_db_path: Optional[str] = None) -> None:
self.db_paths = {
4: Path(db_path or settings.IP2REGION_XDB_PATH or _default_xdb_path(4)),
6: Path(ipv6_db_path or settings.IP2REGION_IPV6_XDB_PATH or _default_xdb_path(6)),
}
self._searchers = {}
self._load_failed: set[int] = set()
def lookup(self, ip: str | None) -> IpLocation:
addr = self._classify_address(ip)
if addr is None:
return IpLocation(location="未知")
if addr.is_loopback:
return IpLocation(location="本机")
if addr.is_private or addr.is_link_local:
return IpLocation(location="局域网")
searcher = self._get_searcher(addr.version)
if not searcher:
return IpLocation(location="公网")
try:
region = searcher.search(str(addr))
except Exception:
logger.exception("Failed to lookup IP location: %s", addr)
return IpLocation(location="公网")
return self._parse_region(region)
@staticmethod
def _classify_address(ip: str | None):
if not ip:
return None
try:
return ipaddress.ip_address(ip)
except ValueError:
return None
def _get_searcher(self, version: int):
if version in self._searchers or version in self._load_failed:
return self._searchers.get(version)
db_path = self.db_paths[version]
if not db_path.exists():
self._load_failed.add(version)
logger.info("ip2region xdb file not found: %s", db_path)
return None
try:
try:
import ip2region.searcher as searcher
import ip2region.util as util
except ModuleNotFoundError:
vendor_path = Path(__file__).resolve().parents[1] / "vendor"
if str(vendor_path) not in sys.path:
sys.path.insert(0, str(vendor_path))
import ip2region.searcher as searcher
import ip2region.util as util
ip_version = util.IPv6 if version == 6 else util.IPv4
buffer = util.load_content_from_file(str(db_path))
self._searchers[version] = searcher.new_with_buffer(ip_version, buffer)
except Exception:
self._load_failed.add(version)
logger.exception("Failed to initialize ip2region searcher: %s", db_path)
return self._searchers.get(version)
@staticmethod
def _parse_region(region: str | None) -> IpLocation:
parts = [*str(region or "").split("|"), "", "", "", ""]
country = _normalize_part(parts[0])
province = _normalize_part(parts[1])
city = _normalize_part(parts[2])
isp = _normalize_part(parts[3])
return IpLocation(
location=_format_location(country, province, city, isp),
country=country,
province=province,
city=city,
isp=isp,
)
_resolver = Ip2RegionResolver()
def resolve_ip_location(ip: str | None) -> IpLocation:
return _resolver.lookup(ip)
@@ -0,0 +1,124 @@
"""权限访问日志后台写入器
使用 asyncio.Queue 缓冲日志条目,批量写入数据库。
设计原则:
- enqueue() 非阻塞,不影响请求性能
- 队列满时丢弃(监控不应拖慢被监控系统)
- 批量写入减少数据库压力
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timezone
from app.db.session import SessionLocal
from app.models.permission_access_log import PermissionAccessLog
logger = logging.getLogger("ctms.permission_log_writer")
BATCH_SIZE = 50
FLUSH_INTERVAL = 5.0
QUEUE_MAX_SIZE = 10000
class PermissionLogWriter:
def __init__(self):
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
self._task: asyncio.Task | None = None
def enqueue(self, entry: dict) -> None:
try:
self._queue.put_nowait(entry)
except asyncio.QueueFull:
logger.warning("Permission log queue full, dropping entry")
async def start(self) -> None:
self._task = asyncio.create_task(self._flush_loop())
logger.info("PermissionLogWriter started")
async def stop(self) -> None:
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
await self._drain()
logger.info("PermissionLogWriter stopped")
async def _flush_loop(self) -> None:
while True:
batch = await self._collect_batch()
if batch:
await self._write_batch(batch)
async def _collect_batch(self) -> list[dict]:
batch: list[dict] = []
try:
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
batch.append(first)
except asyncio.TimeoutError:
return batch
while len(batch) < BATCH_SIZE:
try:
item = self._queue.get_nowait()
batch.append(item)
except asyncio.QueueEmpty:
break
return batch
async def _write_batch(self, batch: list[dict]) -> None:
try:
async with SessionLocal() as session:
for entry in batch:
log = PermissionAccessLog(
id=uuid.uuid4(),
study_id=entry["study_id"],
user_id=entry["user_id"],
endpoint_key=entry["endpoint_key"],
role=entry["role"],
allowed=entry["allowed"],
elapsed_ms=entry["elapsed_ms"],
ip_address=entry.get("ip_address"),
created_at=entry.get("created_at", datetime.now(timezone.utc)),
)
session.add(log)
await session.commit()
except Exception:
logger.exception("Failed to write permission access log batch (%d entries)", len(batch))
async def _drain(self) -> None:
batch: list[dict] = []
while not self._queue.empty():
try:
batch.append(self._queue.get_nowait())
except asyncio.QueueEmpty:
break
if batch:
await self._write_batch(batch)
_writer: PermissionLogWriter | None = None
def get_log_writer() -> PermissionLogWriter | None:
return _writer
async def start_log_writer() -> PermissionLogWriter:
global _writer
_writer = PermissionLogWriter()
await _writer.start()
return _writer
async def stop_log_writer() -> None:
global _writer
if _writer:
await _writer.stop()
_writer = None
@@ -0,0 +1,82 @@
"""权限指标小时聚合任务
每小时从 permission_access_logs 聚合数据写入 permission_metric_snapshots。
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from app.db.session import SessionLocal
from app.models.permission_access_log import PermissionAccessLog
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
logger = logging.getLogger("ctms.permission_metric_aggregator")
async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
async with SessionLocal() as session:
result = await session.execute(
select(
func.count().label("total"),
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
).where(
PermissionAccessLog.created_at >= bucket_start,
PermissionAccessLog.created_at < bucket_end,
)
)
row = result.one()
if row.total == 0:
return
from app.core.permission_monitor import get_permission_monitor
monitor = get_permission_monitor()
cache_metrics = monitor.metrics.cache_metrics
snapshot = PermissionMetricSnapshot(
id=uuid.uuid4(),
bucket_time=bucket_start,
total_checks=row.total,
allowed_checks=row.allowed,
denied_checks=row.denied,
avg_elapsed_ms=float(row.avg_ms),
max_elapsed_ms=float(row.max_ms),
cache_hits=cache_metrics.cache_hits,
cache_misses=cache_metrics.cache_misses,
error_count=monitor.metrics.check_metrics.errors,
)
session.add(snapshot)
await session.commit()
logger.info("Aggregated permission metrics for bucket %s: %d checks", bucket_start, row.total)
async def run_hourly_metric_aggregation(stop_event: asyncio.Event) -> None:
logger.info("Permission metric aggregator started")
while not stop_event.is_set():
now = datetime.now(timezone.utc)
next_hour = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
wait_seconds = (next_hour - now).total_seconds()
try:
await asyncio.wait_for(stop_event.wait(), timeout=wait_seconds)
break
except asyncio.TimeoutError:
pass
bucket_end = next_hour
bucket_start = bucket_end - timedelta(hours=1)
try:
await _aggregate_hour(bucket_start, bucket_end)
except Exception:
logger.exception("Failed to aggregate permission metrics for %s", bucket_start)
logger.info("Permission metric aggregator stopped")
@@ -0,0 +1,117 @@
"""底层安全访问日志后台写入器"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timezone
from app.db.session import SessionLocal
from app.models.security_access_log import SecurityAccessLog
logger = logging.getLogger("ctms.security_access_log_writer")
BATCH_SIZE = 100
FLUSH_INTERVAL = 3.0
QUEUE_MAX_SIZE = 20000
class SecurityAccessLogWriter:
def __init__(self) -> None:
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
self._task: asyncio.Task | None = None
def enqueue(self, entry: dict) -> None:
try:
self._queue.put_nowait(entry)
except asyncio.QueueFull:
logger.warning("Security access log queue full, dropping entry")
async def start(self) -> None:
self._task = asyncio.create_task(self._flush_loop())
logger.info("SecurityAccessLogWriter started")
async def stop(self) -> None:
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
await self._drain()
logger.info("SecurityAccessLogWriter stopped")
async def _flush_loop(self) -> None:
while True:
batch = await self._collect_batch()
if batch:
await self._write_batch(batch)
async def _collect_batch(self) -> list[dict]:
batch: list[dict] = []
try:
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
batch.append(first)
except asyncio.TimeoutError:
return batch
while len(batch) < BATCH_SIZE:
try:
batch.append(self._queue.get_nowait())
except asyncio.QueueEmpty:
break
return batch
async def _write_batch(self, batch: list[dict]) -> None:
try:
async with SessionLocal() as session:
for entry in batch:
session.add(
SecurityAccessLog(
id=uuid.uuid4(),
method=entry["method"],
path=entry["path"],
status_code=entry["status_code"],
elapsed_ms=entry["elapsed_ms"],
client_ip=entry.get("client_ip"),
user_agent=entry.get("user_agent"),
auth_status=entry["auth_status"],
user_identifier=entry.get("user_identifier"),
created_at=entry.get("created_at", datetime.now(timezone.utc)),
)
)
await session.commit()
except Exception:
logger.exception("Failed to write security access log batch (%d entries)", len(batch))
async def _drain(self) -> None:
batch: list[dict] = []
while not self._queue.empty():
try:
batch.append(self._queue.get_nowait())
except asyncio.QueueEmpty:
break
if batch:
await self._write_batch(batch)
_writer: SecurityAccessLogWriter | None = None
def get_security_log_writer() -> SecurityAccessLogWriter | None:
return _writer
async def start_security_log_writer() -> SecurityAccessLogWriter:
global _writer
_writer = SecurityAccessLogWriter()
await _writer.start()
return _writer
async def stop_security_log_writer() -> None:
global _writer
if _writer:
await _writer.stop()
_writer = None