Compare commits
4 Commits
b25055775e
...
1078b67de6
| Author | SHA1 | Date | |
|---|---|---|---|
| 1078b67de6 | |||
| 668e719b6d | |||
| f94acebd1a | |||
| b1c55abb10 |
@@ -15,7 +15,11 @@ from sqlalchemy import func, select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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_metric_snapshot import PermissionMetricSnapshot
|
||||
from app.models.security_access_log import SecurityAccessLog
|
||||
@@ -78,7 +82,7 @@ async def get_permission_metrics(
|
||||
row = result.one()
|
||||
total = row.total_checks or 0
|
||||
monitor = get_permission_monitor()
|
||||
cache_metrics = monitor.metrics.cache_metrics.to_dict()
|
||||
cache_metrics = monitor.metrics.cache_metrics.window_stats()
|
||||
return {
|
||||
"window_hours": hours,
|
||||
"check_metrics": {
|
||||
@@ -178,33 +182,87 @@ async def permission_system_health(
|
||||
deny_rate = round(row.denied / total * 100, 2) if total else 0
|
||||
|
||||
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
|
||||
issues = []
|
||||
deductions: dict[str, int] = {
|
||||
"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:
|
||||
health_score -= 5
|
||||
issues.append("权限拒绝率过高")
|
||||
if (
|
||||
cache_metrics.total_accesses >= 10
|
||||
and cache_metrics.hit_rate < 50
|
||||
):
|
||||
health_score -= 10
|
||||
issues.append("缓存命中率过低")
|
||||
deductions["deny_rate"] = 20
|
||||
elif deny_rate > 20:
|
||||
deductions["deny_rate"] = 10
|
||||
if deductions["deny_rate"]:
|
||||
issues.append(f"权限拒绝率偏高({deny_rate:.2f}%)")
|
||||
|
||||
cache_sample_sufficient = (
|
||||
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 {
|
||||
"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,
|
||||
"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": {
|
||||
"total_checks": total,
|
||||
"denied_checks": row.denied or 0,
|
||||
"deny_rate": deny_rate,
|
||||
"avg_elapsed_ms": round(avg_ms, 2),
|
||||
"error_alerts": error_alert_count,
|
||||
"warning_alerts": warning_alert_count,
|
||||
},
|
||||
"cache_stats": monitor.get_cache_stats(),
|
||||
}
|
||||
|
||||
@@ -44,8 +44,12 @@ class PermissionCache:
|
||||
if cache_key in self._member_role_cache:
|
||||
cached_role, timestamp = self._member_role_cache[cache_key]
|
||||
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
|
||||
|
||||
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)
|
||||
role = membership.role_in_study if membership and membership.is_active else None
|
||||
|
||||
@@ -102,6 +106,17 @@ class PermissionCache:
|
||||
self._member_role_cache.clear()
|
||||
|
||||
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 {
|
||||
"project_permissions_count": len(self._project_permissions_cache),
|
||||
"member_role_count": len(self._member_role_cache),
|
||||
|
||||
@@ -8,13 +8,15 @@ permission_access_logs 表中,通过 /metrics 端点实时聚合查询。
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
@@ -25,6 +27,11 @@ class CacheMetrics:
|
||||
cache_hits: int = 0
|
||||
cache_misses: 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
|
||||
def hit_rate(self) -> float:
|
||||
@@ -48,6 +55,53 @@ class CacheMetrics:
|
||||
"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
|
||||
class PermissionSystemMetrics:
|
||||
@@ -70,16 +124,14 @@ class PermissionMonitor:
|
||||
self.metrics = PermissionSystemMetrics()
|
||||
self._alerts: list[dict[str, Any]] = []
|
||||
|
||||
def record_cache_hit(self) -> None:
|
||||
self.metrics.cache_metrics.total_accesses += 1
|
||||
self.metrics.cache_metrics.cache_hits += 1
|
||||
def record_cache_hit(self, cache_name: str = "project_permissions") -> None:
|
||||
self.metrics.cache_metrics.record_access(cache_name, hit=True)
|
||||
|
||||
def record_cache_miss(self) -> None:
|
||||
self.metrics.cache_metrics.total_accesses += 1
|
||||
self.metrics.cache_metrics.cache_misses += 1
|
||||
def record_cache_miss(self, cache_name: str = "project_permissions") -> None:
|
||||
self.metrics.cache_metrics.record_access(cache_name, hit=False)
|
||||
|
||||
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:
|
||||
self._add_alert(
|
||||
@@ -119,12 +171,14 @@ class PermissionMonitor:
|
||||
cache = get_permission_cache()
|
||||
return {
|
||||
"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]:
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
|
||||
|
||||
from app.core.permission_monitor import get_permission_monitor
|
||||
monitor = get_permission_monitor()
|
||||
cache_metrics = monitor.metrics.cache_metrics
|
||||
cache_metrics = monitor.metrics.cache_metrics.window_stats(3600)
|
||||
|
||||
snapshot = PermissionMetricSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
@@ -50,8 +50,8 @@ async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
|
||||
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,
|
||||
cache_hits=cache_metrics["cache_hits"],
|
||||
cache_misses=cache_metrics["cache_misses"],
|
||||
error_count=0,
|
||||
)
|
||||
session.add(snapshot)
|
||||
|
||||
@@ -30,6 +30,24 @@ async def test_cache_metrics():
|
||||
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
|
||||
async def test_cache_invalidation_tracking():
|
||||
"""测试缓存失效追踪"""
|
||||
|
||||
@@ -258,9 +258,14 @@ async def test_permission_system_health_degraded(db_session):
|
||||
assert "status" in data
|
||||
assert "health_score" in data
|
||||
assert "issues" in data
|
||||
assert "权限检查响应时间过长" in data["issues"]
|
||||
assert "权限拒绝率过高" in data["issues"]
|
||||
assert "缓存命中率过低" in data["issues"]
|
||||
assert any("权限检查平均响应时间过长" in issue for issue in data["issues"])
|
||||
assert any("权限拒绝率偏高" in issue for issue 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
|
||||
@@ -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())
|
||||
assert "last_hour" 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 "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
|
||||
async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch):
|
||||
"""IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。"""
|
||||
|
||||
@@ -15,6 +15,17 @@ vi.mock("@/api/projectPermissions", () => ({
|
||||
cache_metrics: {
|
||||
total_accesses: 1000, cache_hits: 850, cache_misses: 150,
|
||||
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,
|
||||
},
|
||||
@@ -22,7 +33,21 @@ vi.mock("@/api/projectPermissions", () => ({
|
||||
fetchPermissionHealth: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
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: [] } }),
|
||||
@@ -94,7 +119,7 @@ describe("PermissionMonitoring.vue", () => {
|
||||
it("resizes the source-analysis chart when its tab becomes active", () => {
|
||||
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("resize: () => void | Promise<void>");
|
||||
expect(source).toContain('if (tab === "ip-locations")');
|
||||
|
||||
@@ -44,24 +44,37 @@
|
||||
</template>
|
||||
</el-progress>
|
||||
</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-for="(issue, index) in health.issues" :key="index" class="issue-item">
|
||||
<span class="issue-dot" />
|
||||
<span>{{ issue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="health-ok">
|
||||
<div v-else-if="health.sample_sufficient" class="health-ok">
|
||||
<span class="ok-icon">✓</span>
|
||||
<span>系统运行正常,未发现问题</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="metrics" class="cache-card">
|
||||
<div class="cache-title">
|
||||
<h3>缓存效率</h3>
|
||||
<span>近 1 小时</span>
|
||||
</div>
|
||||
<div class="cache-stats">
|
||||
<div class="cache-stat primary">
|
||||
<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) }">
|
||||
{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%
|
||||
</strong>
|
||||
@@ -73,6 +86,22 @@
|
||||
:show-text="false"
|
||||
/>
|
||||
</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-mini">
|
||||
<span class="stat-label">缓存项目</span>
|
||||
@@ -173,7 +202,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, nextTick, onMounted } from "vue";
|
||||
import { ref, computed, h, nextTick, onMounted, onUnmounted } from "vue";
|
||||
import type {
|
||||
PermissionMetricsResponse,
|
||||
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 [metricsRes, healthRes, alertsRes, deniedRes, summaryRes] = await Promise.allSettled([
|
||||
fetchPermissionMetrics(),
|
||||
@@ -335,7 +374,18 @@ const 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 });
|
||||
</script>
|
||||
@@ -632,6 +682,32 @@ defineExpose({ refresh });
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -699,6 +775,36 @@ defineExpose({ refresh });
|
||||
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 {
|
||||
padding: 14px;
|
||||
background: #f8fafc;
|
||||
|
||||
@@ -238,7 +238,7 @@ export interface PermissionCheckMetrics {
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface CacheMetrics {
|
||||
export interface CacheMetricCounts {
|
||||
total_accesses: number;
|
||||
cache_hits: number;
|
||||
cache_misses: number;
|
||||
@@ -247,6 +247,15 @@ export interface CacheMetrics {
|
||||
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 {
|
||||
check_metrics: PermissionCheckMetrics;
|
||||
cache_metrics: CacheMetrics;
|
||||
@@ -279,10 +288,33 @@ export interface HealthResponse {
|
||||
status: "healthy" | "degraded" | "unhealthy";
|
||||
health_score: number;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface HealthScoreItem {
|
||||
weight: number;
|
||||
deduction: number;
|
||||
}
|
||||
|
||||
// 系统监测 - 访问日志
|
||||
export interface AccessLogItem {
|
||||
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) {
|
||||
.login-left-brand {
|
||||
display: none;
|
||||
|
||||
Reference in New Issue
Block a user