Files
ctms/frontend/src/components/PermissionMonitoring.vue
T
2026-06-30 10:43:24 +08:00

998 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="permission-monitoring" :class="{ 'is-source-tab': activeTab === 'ip-locations' }">
<el-tabs v-model="activeTab" class="soybean-monitoring-tabs" @tab-change="onTabChange">
<el-tab-pane label="运行概览" name="overview">
<template #label>
<span class="soybean-tab-label">
<component :is="IconCheck" />
<span>运行概览</span>
</span>
</template>
<div v-if="statsSummary" class="stats-summary">
<div v-for="card in summaryCards" :key="card.label" class="summary-card" :class="card.tone">
<div class="summary-icon">
<component :is="card.icon" />
</div>
<div class="summary-body">
<span class="summary-label">{{ card.label }}</span>
<strong class="summary-value">{{ card.value }}</strong>
</div>
</div>
</div>
<div class="overview-grid">
<div v-if="health" class="health-card">
<div class="health-header">
<h3>系统健康状态</h3>
<el-tag :type="getHealthType(health.status)" effect="dark" round>
{{ getHealthLabel(health.status) }}
</el-tag>
</div>
<div class="health-score-ring">
<el-progress
type="dashboard"
:percentage="health.health_score"
:color="getScoreColor(health.health_score)"
:width="140"
:stroke-width="10"
>
<template #default>
<div class="score-inner">
<strong>{{ health.health_score }}</strong>
<span>健康分</span>
</div>
</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-if="health.sample_sufficient" class="health-ok">
<span class="ok-icon">&#10003;</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">
综合命中率{{ 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>
</div>
<el-progress
:percentage="metrics.cache_metrics.hit_rate"
:color="getProgressColor(metrics.cache_metrics.hit_rate)"
:stroke-width="8"
: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>
<strong>{{ health?.cache_stats?.cache_items.total_count || 0 }}</strong>
</div>
<div class="cache-stat-mini">
<span class="stat-label">失效次数</span>
<strong>{{ metrics.cache_metrics.cache_invalidations }}</strong>
</div>
</div>
</div>
</div>
</div>
<div v-if="topDenied && topDenied.length > 0" class="denied-card">
<div class="card-header">
<h3>异常拒绝排行</h3>
<el-tag effect="plain" size="small" type="info"> 7 </el-tag>
</div>
<div class="denied-list">
<div v-for="(item, index) in topDenied" :key="index" class="denied-row">
<span class="denied-rank" :class="{ top: index < 3 }">{{ index + 1 }}</span>
<div class="denied-info">
<strong>{{ item.endpoint_key }}</strong>
<small>{{ item.role }}</small>
</div>
<span class="denied-count">{{ item.denied_count }}</span>
</div>
</div>
</div>
<div class="alerts-card">
<div class="card-header">
<h3>最近告警</h3>
<el-tag v-if="alerts && alerts.alerts.length > 0" effect="dark" size="small" type="warning" round>
{{ alerts.alerts.length }}
</el-tag>
</div>
<div v-if="alerts && alerts.alerts.length > 0" class="alerts-list">
<div v-for="(alert, index) in alerts.alerts" :key="index" class="alert-row" :class="alert.level">
<div class="alert-level">
<el-tag :type="getAlertType(alert.level)" size="small" effect="dark" round>{{ alert.level }}</el-tag>
</div>
<div class="alert-body">
<strong>{{ alert.type }}</strong>
<span>{{ alert.message }}</span>
</div>
<span class="alert-time">{{ formatTime(alert.timestamp) }}</span>
</div>
</div>
<el-empty v-else description="暂无告警,系统运行正常" :image-size="60" />
</div>
</el-tab-pane>
<el-tab-pane label="性能趋势" name="trends">
<template #label>
<span class="soybean-tab-label">
<component :is="IconActivity" />
<span>性能趋势</span>
</span>
</template>
<PermissionTrendCharts ref="trendChartsRef" />
</el-tab-pane>
<el-tab-pane v-if="props.isAdmin" label="安全中心" name="security">
<template #label>
<span class="soybean-tab-label">
<component :is="IconShield" />
<span>安全中心</span>
</span>
</template>
<SecurityCenter v-if="props.isAdmin" ref="securityCenterRef" />
</el-tab-pane>
<el-tab-pane label="访问审计" name="logs">
<template #label>
<span class="soybean-tab-label">
<component :is="IconDatabase" />
<span>访问审计</span>
</span>
</template>
<PermissionAccessLogs ref="accessLogsRef" :show-security-log="props.isAdmin" />
</el-tab-pane>
<el-tab-pane label="来源分析" name="ip-locations">
<template #label>
<span class="soybean-tab-label">
<component :is="IconPercent" />
<span>来源分析</span>
</span>
</template>
<div class="ip-locations-pane">
<PermissionIpLocations ref="ipLocationsRef" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h, nextTick, onMounted, onUnmounted } from "vue";
import type {
PermissionMetricsResponse,
AlertsResponse,
HealthResponse,
TopDeniedItem,
StatsSummaryResponse,
} from "@/types/api";
import {
fetchPermissionMetrics,
fetchPermissionAlerts,
fetchPermissionHealth,
fetchTopDenied,
fetchStatsSummary,
} from "@/api/projectPermissions";
import PermissionTrendCharts from "./PermissionTrendCharts.vue";
import PermissionAccessLogs from "./PermissionAccessLogs.vue";
import PermissionIpLocations from "./PermissionIpLocations.vue";
import SecurityCenter from "./SecurityCenter.vue";
const IconCheck = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("polyline", { points: "20 6 9 17 4 12" }),
]);
const IconActivity = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("polyline", { points: "22 12 18 12 15 21 9 3 6 12 2 12" }),
]);
const IconPercent = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("line", { x1: "19", y1: "5", x2: "5", y2: "19" }),
h("circle", { cx: "6.5", cy: "6.5", r: "2.5" }),
h("circle", { cx: "17.5", cy: "17.5", r: "2.5" }),
]);
const IconClock = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("polyline", { points: "12 6 12 12 16 14" }),
]);
const IconDatabase = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("ellipse", { cx: "12", cy: "5", rx: "9", ry: "3" }),
h("path", { d: "M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" }),
h("path", { d: "M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" }),
]);
const IconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
]);
const activeTab = ref("overview");
const props = withDefaults(defineProps<{ isAdmin?: boolean }>(), {
isAdmin: false,
});
const metrics = ref<PermissionMetricsResponse | null>(null);
const health = ref<HealthResponse | null>(null);
const alerts = ref<AlertsResponse | null>(null);
const topDenied = ref<TopDeniedItem[]>([]);
const statsSummary = ref<StatsSummaryResponse | null>(null);
const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>();
const accessLogsRef = ref<InstanceType<typeof PermissionAccessLogs>>();
type IpLocationsExpose = {
refresh: () => void | Promise<void>;
resize: () => void | Promise<void>;
};
const ipLocationsRef = ref<IpLocationsExpose>();
const securityCenterRef = ref<InstanceType<typeof SecurityCenter>>();
const formatTime = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleString("zh-CN");
};
const getHealthType = (status: string) => {
const map: Record<string, string> = { healthy: "success", degraded: "warning", unhealthy: "danger" };
return map[status] || "info";
};
const getHealthLabel = (status: string) => {
const map: Record<string, string> = { healthy: "健康", degraded: "降级", unhealthy: "不健康" };
return map[status] || status;
};
const getAlertType = (level: string) => {
const map: Record<string, string> = { info: "info", warning: "warning", error: "danger" };
return map[level] || "info";
};
const getProgressColor = (percentage: number): string => {
if (percentage >= 80) return "#10b981";
if (percentage >= 50) return "#f59e0b";
return "#ef4444";
};
const getScoreColor = (score: number): string => {
if (score >= 80) return "#10b981";
if (score >= 60) return "#f59e0b";
return "#ef4444";
};
const summaryCards = computed(() => {
if (!statsSummary.value) return [];
return [
{
label: "今日监测事件",
value: statsSummary.value.today.total_checks.toLocaleString(),
tone: "blue",
icon: IconCheck,
},
{
label: "每分钟事件",
value: statsSummary.value.last_hour.requests_per_minute,
tone: "cyan",
icon: IconActivity,
},
{
label: "今日通过率",
value: `${statsSummary.value.today.allow_rate}%`,
tone: statsSummary.value.today.allow_rate >= 80 ? "green" : "warning",
icon: IconPercent,
},
{
label: "平均响应时间",
value: `${statsSummary.value.today.avg_elapsed_ms}ms`,
tone: statsSummary.value.today.avg_elapsed_ms > 10 ? "danger" : "indigo",
icon: IconClock,
},
{
label: "历史事件总数",
value: statsSummary.value.total_logs.toLocaleString(),
tone: "violet",
icon: IconDatabase,
},
];
});
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(),
fetchPermissionHealth(),
fetchPermissionAlerts(undefined, 20),
fetchTopDenied({ days: 7, limit: 10 }),
fetchStatsSummary(),
]);
if (metricsRes.status === "fulfilled") metrics.value = metricsRes.value.data;
if (healthRes.status === "fulfilled") health.value = healthRes.value.data;
if (alertsRes.status === "fulfilled") alerts.value = alertsRes.value.data;
if (deniedRes.status === "fulfilled") topDenied.value = deniedRes.value.data.items;
if (summaryRes.status === "fulfilled") statsSummary.value = summaryRes.value.data;
};
const onTabChange = async (tab: string) => {
if (tab === "overview") loadOverviewData();
if (tab === "ip-locations") {
await nextTick();
await ipLocationsRef.value?.resize();
}
};
const refresh = () => {
if (activeTab.value === "overview") loadOverviewData();
else if (activeTab.value === "trends") trendChartsRef.value?.refresh();
else if (activeTab.value === "security") securityCenterRef.value?.refresh();
else if (activeTab.value === "logs") accessLogsRef.value?.refresh();
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
};
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>
<style scoped>
.permission-monitoring {
--mon-ink: #1a2332;
--mon-muted: #64748b;
--mon-border: #e2e8f0;
--mon-tab-active: #6c63ff;
--mon-tab-active-bg: #f0edff;
--mon-radius: 16px;
--mon-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 0;
}
.soybean-monitoring-tabs {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.soybean-monitoring-tabs :deep(.el-tabs__header) {
height: 56px;
margin: 0 0 16px;
padding: 0 24px;
background: #ffffff;
border-top: 1px solid var(--mon-border);
border-bottom: 1px solid var(--mon-border);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.soybean-monitoring-tabs :deep(.el-tabs__nav-wrap) {
height: 56px;
}
.soybean-monitoring-tabs :deep(.el-tabs__nav-wrap::after),
.soybean-monitoring-tabs :deep(.el-tabs__active-bar) {
display: none;
}
.soybean-monitoring-tabs :deep(.el-tabs__nav-scroll),
.soybean-monitoring-tabs :deep(.el-tabs__nav) {
height: 100%;
}
.soybean-monitoring-tabs :deep(.el-tabs__nav) {
display: flex;
align-items: center;
}
.soybean-monitoring-tabs :deep(.el-tabs__item) {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 36px;
margin: 0 4px;
padding: 0 18px;
border-radius: 10px;
color: #303133;
font-size: 15px;
font-weight: 600;
line-height: 1;
transition: color 0.2s ease, background 0.2s ease;
}
:deep(.soybean-monitoring-tabs.el-tabs--top > .el-tabs__header .el-tabs__item:nth-child(2)) {
padding-left: 18px;
}
:deep(.soybean-monitoring-tabs.el-tabs--top > .el-tabs__header .el-tabs__item:last-child) {
padding-right: 18px;
}
.soybean-monitoring-tabs :deep(.el-tabs__item:not(.is-active)::after) {
content: "";
position: absolute;
top: 10px;
right: -5px;
width: 1px;
height: 16px;
background: #dcdfe6;
}
.soybean-monitoring-tabs :deep(.el-tabs__item:last-child::after),
.soybean-monitoring-tabs :deep(.el-tabs__item.is-active + .el-tabs__item::after) {
display: none;
}
.soybean-monitoring-tabs :deep(.el-tabs__item:hover) {
color: var(--mon-tab-active);
}
.soybean-monitoring-tabs :deep(.el-tabs__item.is-active) {
background: var(--mon-tab-active-bg);
color: var(--mon-tab-active);
}
.soybean-monitoring-tabs :deep(.el-tabs__content) {
flex: 1 1 auto;
min-height: 0;
padding: 0;
overflow: auto;
}
.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content) {
overflow: hidden;
}
.soybean-monitoring-tabs :deep(.el-tab-pane) {
min-height: 100%;
}
.soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"]) {
height: 100%;
min-height: 0;
}
.ip-locations-pane {
height: 100%;
min-height: 0;
overflow: hidden;
}
.soybean-tab-label {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-width: 0;
line-height: 1;
}
.soybean-tab-label svg {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.stats-summary {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.summary-card {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 18px;
border-radius: var(--mon-radius);
background: #fff;
border: 1px solid var(--mon-border);
box-shadow: var(--mon-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.summary-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.summary-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: var(--mon-radius) var(--mon-radius) 0 0;
}
.summary-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
.summary-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
.summary-card.green::before { background: linear-gradient(90deg, #10b981, #34d399); }
.summary-card.warning::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.summary-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
.summary-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
.summary-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
.summary-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
flex-shrink: 0;
}
.summary-icon svg {
width: 20px;
height: 20px;
}
.summary-card.blue .summary-icon { background: #eff6ff; color: #3b82f6; }
.summary-card.cyan .summary-icon { background: #ecfeff; color: #06b6d4; }
.summary-card.green .summary-icon { background: #ecfdf5; color: #10b981; }
.summary-card.warning .summary-icon { background: #fffbeb; color: #f59e0b; }
.summary-card.indigo .summary-icon { background: #eef2ff; color: #6366f1; }
.summary-card.danger .summary-icon { background: #fef2f2; color: #ef4444; }
.summary-card.violet .summary-icon { background: #f5f3ff; color: #8b5cf6; }
.summary-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.summary-label {
color: var(--mon-muted);
font-size: 12px;
font-weight: 500;
}
.summary-value {
color: var(--mon-ink);
font-size: 22px;
font-weight: 700;
line-height: 1.1;
}
.overview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 16px;
}
.health-card,
.cache-card,
.denied-card,
.alerts-card {
background: #fff;
border: 1px solid var(--mon-border);
border-radius: var(--mon-radius);
padding: 20px;
box-shadow: var(--mon-shadow);
}
.denied-card,
.alerts-card {
margin-bottom: 16px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--mon-ink);
}
.health-card h3,
.cache-card h3 {
margin: 0 0 16px;
font-size: 15px;
font-weight: 600;
color: var(--mon-ink);
}
.health-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.health-header h3 {
margin: 0;
}
.health-score-ring {
display: flex;
justify-content: center;
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;
align-items: center;
}
.score-inner strong {
font-size: 32px;
font-weight: 700;
color: var(--mon-ink);
line-height: 1;
}
.score-inner span {
font-size: 12px;
color: var(--mon-muted);
margin-top: 4px;
}
.health-issues {
padding-top: 12px;
border-top: 1px solid var(--mon-border);
display: flex;
flex-direction: column;
gap: 8px;
}
.issue-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #dc2626;
}
.issue-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ef4444;
flex-shrink: 0;
}
.health-ok {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-radius: 10px;
background: #ecfdf5;
color: #059669;
font-size: 13px;
font-weight: 500;
}
.ok-icon {
font-size: 16px;
font-weight: 700;
}
.cache-stats {
display: flex;
flex-direction: column;
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;
border-radius: 12px;
}
.cache-stat-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.cache-stat-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.cache-stat-mini {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
background: #f8fafc;
border-radius: 10px;
}
.cache-stat-mini strong {
font-size: 20px;
font-weight: 700;
color: var(--mon-ink);
}
.stat-label {
font-size: 12px;
color: var(--mon-muted);
font-weight: 500;
}
.stat-value {
font-size: 18px;
font-weight: 700;
}
.denied-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.denied-row {
display: grid;
grid-template-columns: 32px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
border-radius: 10px;
transition: background 0.15s ease;
}
.denied-row:hover {
background: #f8fafc;
}
.denied-rank {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
background: #f1f5f9;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.denied-rank.top {
background: linear-gradient(135deg, #ef4444, #f87171);
color: #fff;
}
.denied-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.denied-info strong {
font-size: 13px;
font-weight: 600;
color: var(--mon-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.denied-info small {
font-size: 11px;
color: var(--mon-muted);
}
.denied-count {
font-size: 16px;
font-weight: 700;
color: #ef4444;
}
.alerts-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.alert-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--mon-border);
transition: background 0.15s ease;
}
.alert-row:hover {
background: #f8fafc;
}
.alert-row.error {
border-color: #fecaca;
background: #fef2f2;
}
.alert-row.warning {
border-color: #fed7aa;
background: #fffbeb;
}
.alert-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.alert-body strong {
font-size: 13px;
font-weight: 600;
color: var(--mon-ink);
}
.alert-body span {
font-size: 12px;
color: var(--mon-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alert-time {
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
}
@media (max-width: 1200px) {
.stats-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.stats-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.stats-summary {
grid-template-columns: 1fr;
}
}
</style>