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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-20 14:36:35 +08:00
parent e95eeed90e
commit 6cefa620e4
68 changed files with 6927 additions and 984 deletions
+617 -259
View File
@@ -1,346 +1,704 @@
<template>
<div class="permission-monitoring">
<!-- 系统健康评分 -->
<div v-if="health" class="health-card">
<div class="health-header">
<h3>系统健康状态</h3>
<el-tag :type="getHealthType(health.status)">
{{ getHealthLabel(health.status) }}
</el-tag>
</div>
<div class="health-score">
<div class="score-value">{{ health.health_score }}</div>
<div class="score-label">健康分数</div>
</div>
<div v-if="health.issues.length > 0" class="health-issues">
<div class="issues-title">发现的问题</div>
<ul>
<li v-for="(issue, index) in health.issues" :key="index">{{ issue }}</li>
</ul>
</div>
</div>
<!-- 性能指标卡片 -->
<div v-if="metrics" class="metrics-cards">
<div class="metric-card">
<div class="metric-label">总检查次数</div>
<div class="metric-value">{{ metrics.check_metrics.total_checks }}</div>
</div>
<div class="metric-card">
<div class="metric-label">平均响应时间</div>
<div class="metric-value">{{ (metrics.check_metrics.avg_time * 1000).toFixed(2) }}ms</div>
</div>
<div class="metric-card">
<div class="metric-label">允许率</div>
<div class="metric-value">{{ metrics.check_metrics.allow_rate.toFixed(1) }}%</div>
</div>
<div class="metric-card">
<div class="metric-label">错误率</div>
<div class="metric-value" :style="{ color: metrics.check_metrics.error_rate > 1 ? '#f56c6c' : '#67c23a' }">
{{ metrics.check_metrics.error_rate.toFixed(2) }}%
</div>
</div>
</div>
<!-- 缓存效率 -->
<div v-if="metrics" class="cache-card">
<div class="cache-header">
<h3>缓存效率</h3>
</div>
<div class="cache-stats">
<div class="cache-stat">
<div class="stat-label">缓存命中率</div>
<el-progress
:percentage="metrics.cache_metrics.hit_rate"
:color="getProgressColor(metrics.cache_metrics.hit_rate)"
/>
<div class="stat-value">{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%</div>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="实时概览" name="overview">
<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="cache-stat">
<div class="stat-label">缓存项目数</div>
<div class="stat-value">{{ health?.cache_stats?.cache_items.total_count || 0 }}</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 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">
<span class="ok-icon">&#10003;</span>
<span>系统运行正常未发现问题</span>
</div>
</div>
<div v-if="metrics" class="cache-card">
<h3>缓存效率</h3>
<div class="cache-stats">
<div class="cache-stat primary">
<div class="cache-stat-header">
<span class="stat-label">命中率</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-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 class="cache-stat">
<div class="stat-label">缓存失效次数</div>
<div class="stat-value">{{ metrics.cache_metrics.cache_invalidations }}</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>
</div>
<!-- 告警列表 -->
<div class="alerts-card">
<div class="alerts-header">
<h3>最近告警</h3>
<el-button type="primary" size="small" @click="$emit('refresh')">
<el-icon><RefreshRight /></el-icon>
刷新
</el-button>
</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>
<div v-if="alerts && alerts.alerts.length > 0" class="alerts-table-wrapper">
<el-table :data="alerts.alerts" stripe border>
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="{ row }">
{{ formatTime(row.timestamp) }}
</template>
</el-table-column>
<el-tab-pane label="趋势分析" name="trends">
<PermissionTrendCharts ref="trendChartsRef" />
</el-tab-pane>
<el-table-column prop="level" label="级别" width="80">
<template #default="{ row }">
<el-tag :type="getAlertType(row.level)">{{ row.level }}</el-tag>
</template>
</el-table-column>
<el-tab-pane label="访问日志" name="logs">
<PermissionAccessLogs ref="accessLogsRef" />
</el-tab-pane>
<el-table-column prop="type" label="类型" width="150" />
<el-table-column prop="message" label="消息" show-overflow-tooltip />
</el-table>
</div>
<div v-else class="empty-state">
<el-empty description="暂无告警" />
</div>
</div>
<el-tab-pane label="IP属地" name="ip-locations">
<PermissionIpLocations ref="ipLocationsRef" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { RefreshRight } from "@element-plus/icons-vue";
import { ref, computed, h, onMounted } from "vue";
import type {
PermissionMetricsResponse,
CacheStatsResponse,
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";
interface Props {
metrics: PermissionMetricsResponse | null;
health: HealthResponse | null;
alerts: AlertsResponse | null;
}
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" }),
]);
interface Emits {
(e: "refresh"): void;
}
const activeTab = ref("overview");
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);
defineProps<Props>();
defineEmits<Emits>();
const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>();
const accessLogsRef = ref<InstanceType<typeof PermissionAccessLogs>>();
const ipLocationsRef = ref<InstanceType<typeof PermissionIpLocations>>();
const formatTime = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleString("zh-CN");
};
const getHealthType = (status: string): string => {
const typeMap: Record<string, string> = {
healthy: "success",
degraded: "warning",
unhealthy: "danger",
};
return typeMap[status] || "info";
const getHealthType = (status: string) => {
const map: Record<string, string> = { healthy: "success", degraded: "warning", unhealthy: "danger" };
return map[status] || "info";
};
const getHealthLabel = (status: string): string => {
const labelMap: Record<string, string> = {
healthy: "健康",
degraded: "降级",
unhealthy: "不健康",
};
return labelMap[status] || status;
const getHealthLabel = (status: string) => {
const map: Record<string, string> = { healthy: "健康", degraded: "降级", unhealthy: "不健康" };
return map[status] || status;
};
const getAlertType = (level: string): string => {
const typeMap: Record<string, string> = {
info: "info",
warning: "warning",
error: "danger",
};
return typeMap[level] || "info";
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 "#67c23a";
if (percentage >= 50) return "#e6a23c";
return "#f56c6c";
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 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 = (tab: string) => {
if (tab === "overview") loadOverviewData();
};
const refresh = () => {
if (activeTab.value === "overview") loadOverviewData();
else if (activeTab.value === "trends") trendChartsRef.value?.refresh();
else if (activeTab.value === "logs") accessLogsRef.value?.refresh();
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
};
onMounted(loadOverviewData);
defineExpose({ refresh });
</script>
<style scoped lang="scss">
<style scoped>
.permission-monitoring {
padding: 20px 0;
display: flex;
flex-direction: column;
gap: 20px;
--mon-ink: #1a2332;
--mon-muted: #64748b;
--mon-border: #e2e8f0;
--mon-radius: 16px;
--mon-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
padding: 0;
}
.health-card {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
.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: 20px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
margin-bottom: 16px;
}
.health-score {
text-align: center;
margin: 20px 0;
.health-header h3 {
margin: 0;
}
.score-value {
font-size: 48px;
font-weight: bold;
color: #409eff;
.health-score-ring {
display: flex;
justify-content: center;
padding: 8px 0 16px;
}
.score-label {
font-size: 14px;
color: #909399;
margin-top: 10px;
.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 {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #dcdfe6;
padding-top: 12px;
border-top: 1px solid var(--mon-border);
display: flex;
flex-direction: column;
gap: 8px;
}
.issues-title {
font-size: 14px;
font-weight: 600;
color: #606266;
margin-bottom: 10px;
}
.health-issues ul {
margin: 0;
padding-left: 20px;
li {
color: #f56c6c;
font-size: 13px;
margin: 5px 0;
}
}
.metrics-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.metric-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
text-align: center;
}
.metric-label {
.issue-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #909399;
margin-bottom: 10px;
color: #dc2626;
}
.metric-value {
font-size: 24px;
font-weight: bold;
color: #303133;
.issue-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ef4444;
flex-shrink: 0;
}
.cache-card {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
.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;
}
.cache-header {
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.ok-icon {
font-size: 16px;
font-weight: 700;
}
.cache-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.cache-stat {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 15px;
.cache-stat.primary {
padding: 14px;
background: #f8fafc;
border-radius: 12px;
}
.stat-label {
font-size: 13px;
color: #909399;
.cache-stat-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.stat-value {
font-size: 20px;
font-weight: bold;
color: #303133;
margin-top: 10px;
.cache-stat-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
:deep(.el-progress) {
margin: 10px 0;
}
.alerts-card {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
}
.alerts-header {
.cache-stat-mini {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-direction: column;
gap: 6px;
padding: 12px;
background: #f8fafc;
border-radius: 10px;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
.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));
}
}
.alerts-table-wrapper {
overflow-x: auto;
@media (max-width: 900px) {
.stats-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-grid {
grid-template-columns: 1fr;
}
}
.empty-state {
padding: 40px 20px;
text-align: center;
@media (max-width: 600px) {
.stats-summary {
grid-template-columns: 1fr;
}
}
</style>