优化系统健康评分与缓存监测

This commit is contained in:
Cheng Zhou
2026-06-30 10:43:24 +08:00
parent b25055775e
commit 668e719b6d
10 changed files with 412 additions and 42 deletions
+75 -17
View File
@@ -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(),
} }
+15
View File
@@ -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),
+64 -10
View File
@@ -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">&#10003;</span> <span class="ok-icon">&#10003;</span>
<span>系统运行正常未发现问题</span> <span>系统运行正常未发现问题</span>
</div> </div>
</div> </div>
<div v-if="metrics" class="cache-card"> <div v-if="metrics" class="cache-card">
<div class="cache-title">
<h3>缓存效率</h3> <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;
+34 -2
View File
@@ -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;
+34
View File
@@ -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;