优化系统健康评分与缓存监测
This commit is contained in:
@@ -15,7 +15,11 @@ from sqlalchemy import func, select, desc
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, is_system_admin
|
from app.core.deps import get_current_user, get_db_session, is_system_admin
|
||||||
from app.core.permission_monitor import get_permission_monitor
|
from app.core.permission_monitor import (
|
||||||
|
CACHE_HIT_RATE_HEALTH_MIN_ACCESSES,
|
||||||
|
CACHE_METRICS_WINDOW_SECONDS,
|
||||||
|
get_permission_monitor,
|
||||||
|
)
|
||||||
from app.models.permission_access_log import PermissionAccessLog
|
from app.models.permission_access_log import PermissionAccessLog
|
||||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
||||||
from app.models.security_access_log import SecurityAccessLog
|
from app.models.security_access_log import SecurityAccessLog
|
||||||
@@ -78,7 +82,7 @@ async def get_permission_metrics(
|
|||||||
row = result.one()
|
row = result.one()
|
||||||
total = row.total_checks or 0
|
total = row.total_checks or 0
|
||||||
monitor = get_permission_monitor()
|
monitor = get_permission_monitor()
|
||||||
cache_metrics = monitor.metrics.cache_metrics.to_dict()
|
cache_metrics = monitor.metrics.cache_metrics.window_stats()
|
||||||
return {
|
return {
|
||||||
"window_hours": hours,
|
"window_hours": hours,
|
||||||
"check_metrics": {
|
"check_metrics": {
|
||||||
@@ -178,33 +182,87 @@ async def permission_system_health(
|
|||||||
deny_rate = round(row.denied / total * 100, 2) if total else 0
|
deny_rate = round(row.denied / total * 100, 2) if total else 0
|
||||||
|
|
||||||
monitor = get_permission_monitor()
|
monitor = get_permission_monitor()
|
||||||
cache_metrics = monitor.metrics.cache_metrics
|
cache_metrics = monitor.metrics.cache_metrics.window_stats()
|
||||||
|
now_timestamp = datetime.now(timezone.utc).timestamp()
|
||||||
|
recent_alerts = [
|
||||||
|
alert
|
||||||
|
for alert in monitor.get_alerts(limit=1000)
|
||||||
|
if alert["timestamp"] >= now_timestamp - 3600
|
||||||
|
]
|
||||||
|
|
||||||
health_score = 100
|
deductions: dict[str, int] = {
|
||||||
issues = []
|
"performance": 0,
|
||||||
|
"deny_rate": 0,
|
||||||
|
"cache": 0,
|
||||||
|
"alerts": 0,
|
||||||
|
}
|
||||||
|
issues: list[str] = []
|
||||||
|
|
||||||
|
if avg_ms > 200:
|
||||||
|
deductions["performance"] = 40
|
||||||
|
elif avg_ms > 50:
|
||||||
|
deductions["performance"] = 25
|
||||||
|
elif avg_ms > 10:
|
||||||
|
deductions["performance"] = 10
|
||||||
|
if deductions["performance"]:
|
||||||
|
issues.append(f"权限检查平均响应时间过长({avg_ms:.2f}ms)")
|
||||||
|
|
||||||
if avg_ms > 10:
|
|
||||||
health_score -= 10
|
|
||||||
issues.append("权限检查响应时间过长")
|
|
||||||
if deny_rate > 50:
|
if deny_rate > 50:
|
||||||
health_score -= 5
|
deductions["deny_rate"] = 20
|
||||||
issues.append("权限拒绝率过高")
|
elif deny_rate > 20:
|
||||||
if (
|
deductions["deny_rate"] = 10
|
||||||
cache_metrics.total_accesses >= 10
|
if deductions["deny_rate"]:
|
||||||
and cache_metrics.hit_rate < 50
|
issues.append(f"权限拒绝率偏高({deny_rate:.2f}%)")
|
||||||
):
|
|
||||||
health_score -= 10
|
cache_sample_sufficient = (
|
||||||
issues.append("缓存命中率过低")
|
cache_metrics["total_accesses"] >= CACHE_HIT_RATE_HEALTH_MIN_ACCESSES
|
||||||
|
)
|
||||||
|
if cache_sample_sufficient:
|
||||||
|
if cache_metrics["hit_rate"] < 20:
|
||||||
|
deductions["cache"] = 25
|
||||||
|
elif cache_metrics["hit_rate"] < 50:
|
||||||
|
deductions["cache"] = 20
|
||||||
|
elif cache_metrics["hit_rate"] < 80:
|
||||||
|
deductions["cache"] = 10
|
||||||
|
if deductions["cache"]:
|
||||||
|
issues.append(f"近 1 小时缓存命中率偏低({cache_metrics['hit_rate']:.2f}%)")
|
||||||
|
|
||||||
|
error_alert_count = sum(alert["level"] == "error" for alert in recent_alerts)
|
||||||
|
warning_alert_count = sum(alert["level"] == "warning" for alert in recent_alerts)
|
||||||
|
if error_alert_count:
|
||||||
|
deductions["alerts"] = 15
|
||||||
|
issues.append(f"近 1 小时发生 {error_alert_count} 条错误告警")
|
||||||
|
elif warning_alert_count:
|
||||||
|
deductions["alerts"] = 5
|
||||||
|
issues.append(f"近 1 小时发生 {warning_alert_count} 条警告")
|
||||||
|
|
||||||
|
health_score = max(0, 100 - sum(deductions.values()))
|
||||||
|
sample_sufficient = total >= 10
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy" if health_score >= 80 else "degraded" if health_score >= 50 else "unhealthy",
|
"status": "healthy" if health_score >= 80 else "degraded" if health_score >= 50 else "unhealthy",
|
||||||
"health_score": max(0, health_score),
|
"health_score": health_score,
|
||||||
"issues": issues,
|
"issues": issues,
|
||||||
|
"sample_sufficient": sample_sufficient,
|
||||||
|
"score_breakdown": {
|
||||||
|
"performance": {"weight": 40, "deduction": deductions["performance"]},
|
||||||
|
"deny_rate": {"weight": 20, "deduction": deductions["deny_rate"]},
|
||||||
|
"cache": {
|
||||||
|
"weight": 25,
|
||||||
|
"deduction": deductions["cache"],
|
||||||
|
"sample_sufficient": cache_sample_sufficient,
|
||||||
|
"scope": "sliding_window",
|
||||||
|
"window_seconds": CACHE_METRICS_WINDOW_SECONDS,
|
||||||
|
},
|
||||||
|
"alerts": {"weight": 15, "deduction": deductions["alerts"]},
|
||||||
|
},
|
||||||
"last_hour": {
|
"last_hour": {
|
||||||
"total_checks": total,
|
"total_checks": total,
|
||||||
"denied_checks": row.denied or 0,
|
"denied_checks": row.denied or 0,
|
||||||
"deny_rate": deny_rate,
|
"deny_rate": deny_rate,
|
||||||
"avg_elapsed_ms": round(avg_ms, 2),
|
"avg_elapsed_ms": round(avg_ms, 2),
|
||||||
|
"error_alerts": error_alert_count,
|
||||||
|
"warning_alerts": warning_alert_count,
|
||||||
},
|
},
|
||||||
"cache_stats": monitor.get_cache_stats(),
|
"cache_stats": monitor.get_cache_stats(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,12 @@ class PermissionCache:
|
|||||||
if cache_key in self._member_role_cache:
|
if cache_key in self._member_role_cache:
|
||||||
cached_role, timestamp = self._member_role_cache[cache_key]
|
cached_role, timestamp = self._member_role_cache[cache_key]
|
||||||
if not self._is_expired(timestamp, ttl):
|
if not self._is_expired(timestamp, ttl):
|
||||||
|
from app.core.permission_monitor import get_permission_monitor
|
||||||
|
get_permission_monitor().record_cache_hit("member_roles")
|
||||||
return cached_role
|
return cached_role
|
||||||
|
|
||||||
|
from app.core.permission_monitor import get_permission_monitor
|
||||||
|
get_permission_monitor().record_cache_miss("member_roles")
|
||||||
membership = await member_crud.get_member(db, study_id, user_id)
|
membership = await member_crud.get_member(db, study_id, user_id)
|
||||||
role = membership.role_in_study if membership and membership.is_active else None
|
role = membership.role_in_study if membership and membership.is_active else None
|
||||||
|
|
||||||
@@ -102,6 +106,17 @@ class PermissionCache:
|
|||||||
self._member_role_cache.clear()
|
self._member_role_cache.clear()
|
||||||
|
|
||||||
def get_cache_stats(self) -> dict[str, Any]:
|
def get_cache_stats(self) -> dict[str, Any]:
|
||||||
|
now = time.time()
|
||||||
|
self._project_permissions_cache = {
|
||||||
|
key: value
|
||||||
|
for key, value in self._project_permissions_cache.items()
|
||||||
|
if now - value[1] <= self.default_ttl
|
||||||
|
}
|
||||||
|
self._member_role_cache = {
|
||||||
|
key: value
|
||||||
|
for key, value in self._member_role_cache.items()
|
||||||
|
if now - value[1] <= self.default_ttl
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"project_permissions_count": len(self._project_permissions_cache),
|
"project_permissions_count": len(self._project_permissions_cache),
|
||||||
"member_role_count": len(self._member_role_cache),
|
"member_role_count": len(self._member_role_cache),
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ permission_access_logs 表中,通过 /metrics 端点实时聚合查询。
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.core.permission_cache import get_permission_cache
|
from app.core.permission_cache import get_permission_cache
|
||||||
|
|
||||||
|
|
||||||
CACHE_HIT_RATE_HEALTH_MIN_ACCESSES = 10
|
CACHE_HIT_RATE_HEALTH_MIN_ACCESSES = 50
|
||||||
|
CACHE_METRICS_WINDOW_SECONDS = 3600
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -25,6 +27,11 @@ class CacheMetrics:
|
|||||||
cache_hits: int = 0
|
cache_hits: int = 0
|
||||||
cache_misses: int = 0
|
cache_misses: int = 0
|
||||||
cache_invalidations: int = 0
|
cache_invalidations: int = 0
|
||||||
|
_access_events: deque[tuple[float, str, bool]] = field(
|
||||||
|
default_factory=deque,
|
||||||
|
repr=False,
|
||||||
|
)
|
||||||
|
_invalidation_events: deque[float] = field(default_factory=deque, repr=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hit_rate(self) -> float:
|
def hit_rate(self) -> float:
|
||||||
@@ -48,6 +55,53 @@ class CacheMetrics:
|
|||||||
"miss_rate": round(self.miss_rate, 2),
|
"miss_rate": round(self.miss_rate, 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def record_access(self, cache_name: str, *, hit: bool) -> None:
|
||||||
|
self.total_accesses += 1
|
||||||
|
if hit:
|
||||||
|
self.cache_hits += 1
|
||||||
|
else:
|
||||||
|
self.cache_misses += 1
|
||||||
|
self._access_events.append((time.time(), cache_name, hit))
|
||||||
|
|
||||||
|
def record_invalidation(self) -> None:
|
||||||
|
self.cache_invalidations += 1
|
||||||
|
self._invalidation_events.append(time.time())
|
||||||
|
|
||||||
|
def window_stats(self, seconds: int = CACHE_METRICS_WINDOW_SECONDS) -> dict[str, Any]:
|
||||||
|
cutoff = time.time() - seconds
|
||||||
|
while self._access_events and self._access_events[0][0] < cutoff:
|
||||||
|
self._access_events.popleft()
|
||||||
|
while self._invalidation_events and self._invalidation_events[0] < cutoff:
|
||||||
|
self._invalidation_events.popleft()
|
||||||
|
|
||||||
|
def summarize(cache_name: str | None = None) -> dict[str, Any]:
|
||||||
|
events = [
|
||||||
|
event for event in self._access_events
|
||||||
|
if cache_name is None or event[1] == cache_name
|
||||||
|
]
|
||||||
|
hits = sum(event[2] for event in events)
|
||||||
|
total = len(events)
|
||||||
|
misses = total - hits
|
||||||
|
return {
|
||||||
|
"total_accesses": total,
|
||||||
|
"cache_hits": hits,
|
||||||
|
"cache_misses": misses,
|
||||||
|
"cache_invalidations": len(self._invalidation_events),
|
||||||
|
"hit_rate": round(hits / total * 100, 2) if total else 0.0,
|
||||||
|
"miss_rate": round(misses / total * 100, 2) if total else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = summarize()
|
||||||
|
stats.update({
|
||||||
|
"window_seconds": seconds,
|
||||||
|
"scope": "sliding_window",
|
||||||
|
"by_cache": {
|
||||||
|
"project_permissions": summarize("project_permissions"),
|
||||||
|
"member_roles": summarize("member_roles"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PermissionSystemMetrics:
|
class PermissionSystemMetrics:
|
||||||
@@ -70,16 +124,14 @@ class PermissionMonitor:
|
|||||||
self.metrics = PermissionSystemMetrics()
|
self.metrics = PermissionSystemMetrics()
|
||||||
self._alerts: list[dict[str, Any]] = []
|
self._alerts: list[dict[str, Any]] = []
|
||||||
|
|
||||||
def record_cache_hit(self) -> None:
|
def record_cache_hit(self, cache_name: str = "project_permissions") -> None:
|
||||||
self.metrics.cache_metrics.total_accesses += 1
|
self.metrics.cache_metrics.record_access(cache_name, hit=True)
|
||||||
self.metrics.cache_metrics.cache_hits += 1
|
|
||||||
|
|
||||||
def record_cache_miss(self) -> None:
|
def record_cache_miss(self, cache_name: str = "project_permissions") -> None:
|
||||||
self.metrics.cache_metrics.total_accesses += 1
|
self.metrics.cache_metrics.record_access(cache_name, hit=False)
|
||||||
self.metrics.cache_metrics.cache_misses += 1
|
|
||||||
|
|
||||||
def record_cache_invalidation(self) -> None:
|
def record_cache_invalidation(self) -> None:
|
||||||
self.metrics.cache_metrics.cache_invalidations += 1
|
self.metrics.cache_metrics.record_invalidation()
|
||||||
|
|
||||||
def record_error_alert(self, error: Exception) -> None:
|
def record_error_alert(self, error: Exception) -> None:
|
||||||
self._add_alert(
|
self._add_alert(
|
||||||
@@ -119,12 +171,14 @@ class PermissionMonitor:
|
|||||||
cache = get_permission_cache()
|
cache = get_permission_cache()
|
||||||
return {
|
return {
|
||||||
"cache_items": cache.get_cache_stats(),
|
"cache_items": cache.get_cache_stats(),
|
||||||
"cache_metrics": self.metrics.cache_metrics.to_dict(),
|
"cache_metrics": self.metrics.cache_metrics.window_stats(),
|
||||||
|
"cache_metrics_lifetime": self.metrics.cache_metrics.to_dict(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_metrics(self) -> dict[str, Any]:
|
def get_metrics(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"cache_metrics": self.metrics.cache_metrics.to_dict(),
|
"cache_metrics": self.metrics.cache_metrics.window_stats(),
|
||||||
|
"cache_metrics_lifetime": self.metrics.cache_metrics.to_dict(),
|
||||||
"uptime_seconds": self.metrics.uptime_seconds,
|
"uptime_seconds": self.metrics.uptime_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
|
|||||||
|
|
||||||
from app.core.permission_monitor import get_permission_monitor
|
from app.core.permission_monitor import get_permission_monitor
|
||||||
monitor = get_permission_monitor()
|
monitor = get_permission_monitor()
|
||||||
cache_metrics = monitor.metrics.cache_metrics
|
cache_metrics = monitor.metrics.cache_metrics.window_stats(3600)
|
||||||
|
|
||||||
snapshot = PermissionMetricSnapshot(
|
snapshot = PermissionMetricSnapshot(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
@@ -50,8 +50,8 @@ async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
|
|||||||
denied_checks=row.denied,
|
denied_checks=row.denied,
|
||||||
avg_elapsed_ms=float(row.avg_ms),
|
avg_elapsed_ms=float(row.avg_ms),
|
||||||
max_elapsed_ms=float(row.max_ms),
|
max_elapsed_ms=float(row.max_ms),
|
||||||
cache_hits=cache_metrics.cache_hits,
|
cache_hits=cache_metrics["cache_hits"],
|
||||||
cache_misses=cache_metrics.cache_misses,
|
cache_misses=cache_metrics["cache_misses"],
|
||||||
error_count=0,
|
error_count=0,
|
||||||
)
|
)
|
||||||
session.add(snapshot)
|
session.add(snapshot)
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ async def test_cache_metrics():
|
|||||||
assert metrics.miss_rate == pytest.approx(25.0, 0.1)
|
assert metrics.miss_rate == pytest.approx(25.0, 0.1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cache_metrics_one_hour_window_and_cache_types():
|
||||||
|
monitor = PermissionMonitor()
|
||||||
|
monitor.record_cache_hit("project_permissions")
|
||||||
|
monitor.record_cache_miss("project_permissions")
|
||||||
|
monitor.record_cache_hit("member_roles")
|
||||||
|
|
||||||
|
stats = monitor.metrics.cache_metrics.window_stats(3600)
|
||||||
|
|
||||||
|
assert stats["total_accesses"] == 3
|
||||||
|
assert stats["hit_rate"] == pytest.approx(66.67, 0.01)
|
||||||
|
assert stats["scope"] == "sliding_window"
|
||||||
|
assert stats["by_cache"]["project_permissions"]["total_accesses"] == 2
|
||||||
|
assert stats["by_cache"]["project_permissions"]["hit_rate"] == 50
|
||||||
|
assert stats["by_cache"]["member_roles"]["total_accesses"] == 1
|
||||||
|
assert stats["by_cache"]["member_roles"]["hit_rate"] == 100
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cache_invalidation_tracking():
|
async def test_cache_invalidation_tracking():
|
||||||
"""测试缓存失效追踪"""
|
"""测试缓存失效追踪"""
|
||||||
|
|||||||
@@ -258,9 +258,14 @@ async def test_permission_system_health_degraded(db_session):
|
|||||||
assert "status" in data
|
assert "status" in data
|
||||||
assert "health_score" in data
|
assert "health_score" in data
|
||||||
assert "issues" in data
|
assert "issues" in data
|
||||||
assert "权限检查响应时间过长" in data["issues"]
|
assert any("权限检查平均响应时间过长" in issue for issue in data["issues"])
|
||||||
assert "权限拒绝率过高" in data["issues"]
|
assert any("权限拒绝率偏高" in issue for issue in data["issues"])
|
||||||
assert "缓存命中率过低" in data["issues"]
|
assert any("缓存命中率偏低" in issue for issue in data["issues"])
|
||||||
|
assert data["status"] == "unhealthy"
|
||||||
|
assert data["health_score"] == 30
|
||||||
|
assert data["score_breakdown"]["performance"]["deduction"] == 25
|
||||||
|
assert data["score_breakdown"]["deny_rate"]["deduction"] == 20
|
||||||
|
assert data["score_breakdown"]["cache"]["deduction"] == 25
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -272,10 +277,33 @@ async def test_permission_system_health_includes_metrics(db_session):
|
|||||||
data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub())
|
data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub())
|
||||||
assert "last_hour" in data
|
assert "last_hour" in data
|
||||||
assert "cache_stats" in data
|
assert "cache_stats" in data
|
||||||
|
assert "score_breakdown" in data
|
||||||
|
assert "sample_sufficient" in data
|
||||||
assert "total_checks" in data["last_hour"]
|
assert "total_checks" in data["last_hour"]
|
||||||
assert "cache_metrics" in data["cache_stats"]
|
assert "cache_metrics" in data["cache_stats"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_permission_system_health_ignores_small_cache_sample(db_session):
|
||||||
|
"""缓存样本不足时不应因为低命中率扣分"""
|
||||||
|
monitor = PermissionMonitor()
|
||||||
|
set_permission_monitor(monitor)
|
||||||
|
|
||||||
|
for _ in range(3):
|
||||||
|
monitor.record_cache_miss()
|
||||||
|
|
||||||
|
data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub())
|
||||||
|
|
||||||
|
assert data["score_breakdown"]["cache"]["sample_sufficient"] is False
|
||||||
|
assert data["score_breakdown"]["cache"]["deduction"] == 0
|
||||||
|
non_cache_deductions = sum(
|
||||||
|
item["deduction"]
|
||||||
|
for name, item in data["score_breakdown"].items()
|
||||||
|
if name != "cache"
|
||||||
|
)
|
||||||
|
assert data["health_score"] == 100 - non_cache_deductions
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch):
|
async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch):
|
||||||
"""IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。"""
|
"""IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。"""
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ vi.mock("@/api/projectPermissions", () => ({
|
|||||||
cache_metrics: {
|
cache_metrics: {
|
||||||
total_accesses: 1000, cache_hits: 850, cache_misses: 150,
|
total_accesses: 1000, cache_hits: 850, cache_misses: 150,
|
||||||
cache_invalidations: 5, hit_rate: 85.0, miss_rate: 15.0,
|
cache_invalidations: 5, hit_rate: 85.0, miss_rate: 15.0,
|
||||||
|
window_seconds: 3600, scope: "sliding_window",
|
||||||
|
by_cache: {
|
||||||
|
project_permissions: {
|
||||||
|
total_accesses: 800, cache_hits: 700, cache_misses: 100,
|
||||||
|
cache_invalidations: 5, hit_rate: 87.5, miss_rate: 12.5,
|
||||||
|
},
|
||||||
|
member_roles: {
|
||||||
|
total_accesses: 200, cache_hits: 150, cache_misses: 50,
|
||||||
|
cache_invalidations: 5, hit_rate: 75, miss_rate: 25,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
uptime_seconds: 3600,
|
uptime_seconds: 3600,
|
||||||
},
|
},
|
||||||
@@ -22,7 +33,21 @@ vi.mock("@/api/projectPermissions", () => ({
|
|||||||
fetchPermissionHealth: vi.fn().mockResolvedValue({
|
fetchPermissionHealth: vi.fn().mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
status: "healthy", health_score: 95, issues: [],
|
status: "healthy", health_score: 95, issues: [],
|
||||||
metrics: {}, cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} },
|
sample_sufficient: true,
|
||||||
|
score_breakdown: {
|
||||||
|
performance: { weight: 40, deduction: 0 },
|
||||||
|
deny_rate: { weight: 20, deduction: 0 },
|
||||||
|
cache: {
|
||||||
|
weight: 25, deduction: 5, sample_sufficient: true,
|
||||||
|
scope: "sliding_window", window_seconds: 3600,
|
||||||
|
},
|
||||||
|
alerts: { weight: 15, deduction: 0 },
|
||||||
|
},
|
||||||
|
last_hour: {
|
||||||
|
total_checks: 30, denied_checks: 2, deny_rate: 6.67,
|
||||||
|
avg_elapsed_ms: 3.5, error_alerts: 0, warning_alerts: 0,
|
||||||
|
},
|
||||||
|
cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
|
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
|
||||||
@@ -94,7 +119,7 @@ describe("PermissionMonitoring.vue", () => {
|
|||||||
it("resizes the source-analysis chart when its tab becomes active", () => {
|
it("resizes the source-analysis chart when its tab becomes active", () => {
|
||||||
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
|
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
|
||||||
|
|
||||||
expect(source).toContain('import { ref, computed, h, nextTick, onMounted } from "vue"');
|
expect(source).toContain("onUnmounted");
|
||||||
expect(source).toContain("type IpLocationsExpose");
|
expect(source).toContain("type IpLocationsExpose");
|
||||||
expect(source).toContain("resize: () => void | Promise<void>");
|
expect(source).toContain("resize: () => void | Promise<void>");
|
||||||
expect(source).toContain('if (tab === "ip-locations")');
|
expect(source).toContain('if (tab === "ip-locations")');
|
||||||
|
|||||||
@@ -44,24 +44,37 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-progress>
|
</el-progress>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="score-breakdown">
|
||||||
|
<span v-for="item in healthScoreItems" :key="item.label">
|
||||||
|
{{ item.label }} {{ item.deduction ? `-${item.deduction}` : "正常" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!health.sample_sufficient" class="sample-warning">
|
||||||
|
近 1 小时仅 {{ health.last_hour.total_checks }} 次检查,样本不足,当前评分仅供参考
|
||||||
|
</div>
|
||||||
<div v-if="health.issues.length > 0" class="health-issues">
|
<div v-if="health.issues.length > 0" class="health-issues">
|
||||||
<div v-for="(issue, index) in health.issues" :key="index" class="issue-item">
|
<div v-for="(issue, index) in health.issues" :key="index" class="issue-item">
|
||||||
<span class="issue-dot" />
|
<span class="issue-dot" />
|
||||||
<span>{{ issue }}</span>
|
<span>{{ issue }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="health-ok">
|
<div v-else-if="health.sample_sufficient" class="health-ok">
|
||||||
<span class="ok-icon">✓</span>
|
<span class="ok-icon">✓</span>
|
||||||
<span>系统运行正常,未发现问题</span>
|
<span>系统运行正常,未发现问题</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="metrics" class="cache-card">
|
<div v-if="metrics" class="cache-card">
|
||||||
<h3>缓存效率</h3>
|
<div class="cache-title">
|
||||||
|
<h3>缓存效率</h3>
|
||||||
|
<span>近 1 小时</span>
|
||||||
|
</div>
|
||||||
<div class="cache-stats">
|
<div class="cache-stats">
|
||||||
<div class="cache-stat primary">
|
<div class="cache-stat primary">
|
||||||
<div class="cache-stat-header">
|
<div class="cache-stat-header">
|
||||||
<span class="stat-label">命中率</span>
|
<span class="stat-label">
|
||||||
|
综合命中率({{ metrics.cache_metrics.cache_hits }}/{{ metrics.cache_metrics.total_accesses }})
|
||||||
|
</span>
|
||||||
<strong class="stat-value" :style="{ color: getProgressColor(metrics.cache_metrics.hit_rate) }">
|
<strong class="stat-value" :style="{ color: getProgressColor(metrics.cache_metrics.hit_rate) }">
|
||||||
{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%
|
{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%
|
||||||
</strong>
|
</strong>
|
||||||
@@ -73,6 +86,22 @@
|
|||||||
:show-text="false"
|
:show-text="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cache-scope-details">
|
||||||
|
<span>
|
||||||
|
项目权限
|
||||||
|
{{ metrics.cache_metrics.by_cache.project_permissions.hit_rate.toFixed(1) }}%
|
||||||
|
({{ metrics.cache_metrics.by_cache.project_permissions.cache_hits }}/{{
|
||||||
|
metrics.cache_metrics.by_cache.project_permissions.total_accesses
|
||||||
|
}})
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
成员角色
|
||||||
|
{{ metrics.cache_metrics.by_cache.member_roles.hit_rate.toFixed(1) }}%
|
||||||
|
({{ metrics.cache_metrics.by_cache.member_roles.cache_hits }}/{{
|
||||||
|
metrics.cache_metrics.by_cache.member_roles.total_accesses
|
||||||
|
}})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="cache-stat-row">
|
<div class="cache-stat-row">
|
||||||
<div class="cache-stat-mini">
|
<div class="cache-stat-mini">
|
||||||
<span class="stat-label">缓存项目</span>
|
<span class="stat-label">缓存项目</span>
|
||||||
@@ -173,7 +202,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, h, nextTick, onMounted } from "vue";
|
import { ref, computed, h, nextTick, onMounted, onUnmounted } from "vue";
|
||||||
import type {
|
import type {
|
||||||
PermissionMetricsResponse,
|
PermissionMetricsResponse,
|
||||||
AlertsResponse,
|
AlertsResponse,
|
||||||
@@ -304,6 +333,16 @@ const summaryCards = computed(() => {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const healthScoreItems = computed(() => {
|
||||||
|
if (!health.value) return [];
|
||||||
|
return [
|
||||||
|
{ label: "性能", deduction: health.value.score_breakdown.performance.deduction },
|
||||||
|
{ label: "拒绝率", deduction: health.value.score_breakdown.deny_rate.deduction },
|
||||||
|
{ label: "缓存", deduction: health.value.score_breakdown.cache.deduction },
|
||||||
|
{ label: "告警", deduction: health.value.score_breakdown.alerts.deduction },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const loadOverviewData = async () => {
|
const loadOverviewData = async () => {
|
||||||
const [metricsRes, healthRes, alertsRes, deniedRes, summaryRes] = await Promise.allSettled([
|
const [metricsRes, healthRes, alertsRes, deniedRes, summaryRes] = await Promise.allSettled([
|
||||||
fetchPermissionMetrics(),
|
fetchPermissionMetrics(),
|
||||||
@@ -335,7 +374,18 @@ const refresh = () => {
|
|||||||
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
|
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(loadOverviewData);
|
let overviewRefreshTimer: number | undefined;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadOverviewData();
|
||||||
|
overviewRefreshTimer = window.setInterval(() => {
|
||||||
|
if (activeTab.value === "overview") loadOverviewData();
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (overviewRefreshTimer !== undefined) window.clearInterval(overviewRefreshTimer);
|
||||||
|
});
|
||||||
|
|
||||||
defineExpose({ refresh });
|
defineExpose({ refresh });
|
||||||
</script>
|
</script>
|
||||||
@@ -632,6 +682,32 @@ defineExpose({ refresh });
|
|||||||
padding: 8px 0 16px;
|
padding: 8px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.score-breakdown {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: -6px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown span {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: var(--mon-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-warning {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #b45309;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.score-inner {
|
.score-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -699,6 +775,36 @@ defineExpose({ refresh });
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cache-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-title h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-title span {
|
||||||
|
color: var(--mon-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-scope-details {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-scope-details span {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: var(--mon-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.cache-stat.primary {
|
.cache-stat.primary {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export interface PermissionCheckMetrics {
|
|||||||
errors: number;
|
errors: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacheMetrics {
|
export interface CacheMetricCounts {
|
||||||
total_accesses: number;
|
total_accesses: number;
|
||||||
cache_hits: number;
|
cache_hits: number;
|
||||||
cache_misses: number;
|
cache_misses: number;
|
||||||
@@ -247,6 +247,15 @@ export interface CacheMetrics {
|
|||||||
miss_rate: number;
|
miss_rate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CacheMetrics extends CacheMetricCounts {
|
||||||
|
window_seconds: number;
|
||||||
|
scope: "sliding_window";
|
||||||
|
by_cache: {
|
||||||
|
project_permissions: CacheMetricCounts;
|
||||||
|
member_roles: CacheMetricCounts;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface PermissionMetricsResponse {
|
export interface PermissionMetricsResponse {
|
||||||
check_metrics: PermissionCheckMetrics;
|
check_metrics: PermissionCheckMetrics;
|
||||||
cache_metrics: CacheMetrics;
|
cache_metrics: CacheMetrics;
|
||||||
@@ -279,10 +288,33 @@ export interface HealthResponse {
|
|||||||
status: "healthy" | "degraded" | "unhealthy";
|
status: "healthy" | "degraded" | "unhealthy";
|
||||||
health_score: number;
|
health_score: number;
|
||||||
issues: string[];
|
issues: string[];
|
||||||
metrics: PermissionMetricsResponse;
|
sample_sufficient: boolean;
|
||||||
|
score_breakdown: {
|
||||||
|
performance: HealthScoreItem;
|
||||||
|
deny_rate: HealthScoreItem;
|
||||||
|
cache: HealthScoreItem & {
|
||||||
|
sample_sufficient: boolean;
|
||||||
|
scope: "sliding_window";
|
||||||
|
window_seconds: number;
|
||||||
|
};
|
||||||
|
alerts: HealthScoreItem;
|
||||||
|
};
|
||||||
|
last_hour: {
|
||||||
|
total_checks: number;
|
||||||
|
denied_checks: number;
|
||||||
|
deny_rate: number;
|
||||||
|
avg_elapsed_ms: number;
|
||||||
|
error_alerts: number;
|
||||||
|
warning_alerts: number;
|
||||||
|
};
|
||||||
cache_stats: CacheStatsResponse;
|
cache_stats: CacheStatsResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HealthScoreItem {
|
||||||
|
weight: number;
|
||||||
|
deduction: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 系统监测 - 访问日志
|
// 系统监测 - 访问日志
|
||||||
export interface AccessLogItem {
|
export interface AccessLogItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1184,6 +1184,40 @@ const onSubmit = async () => {
|
|||||||
响应式断点适配
|
响应式断点适配
|
||||||
═══════════════════════ */
|
═══════════════════════ */
|
||||||
|
|
||||||
|
/* 大屏保持 1:1 分栏,通过扩大有效内容区改善内容密度 */
|
||||||
|
@media (min-width: 1440px) {
|
||||||
|
.login-left-brand {
|
||||||
|
flex: 1;
|
||||||
|
padding: clamp(64px, 5vw, 96px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-right-form {
|
||||||
|
flex: 1;
|
||||||
|
padding: clamp(64px, 6vw, 112px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-slogan-block,
|
||||||
|
.brand-features-grid {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-main-title {
|
||||||
|
font-size: clamp(38px, 2.4vw, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub-desc {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card-container {
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.login-left-brand {
|
.login-left-brand {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user