完善权限监控与安全中心

This commit is contained in:
Cheng Zhou
2026-06-17 17:21:48 +08:00
parent 1886765db8
commit 061792c73f
15 changed files with 2837 additions and 160 deletions
+971
View File
@@ -0,0 +1,971 @@
<template>
<div class="security-center">
<section class="audit-metrics">
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
<div class="metric-icon">
<component :is="card.icon" />
</div>
<div class="metric-body">
<span class="metric-label">{{ card.label }}</span>
<strong class="metric-value">{{ card.value }}</strong>
</div>
</article>
</section>
<section class="audit-grid">
<article class="ranking-card">
<div class="section-head">
<div class="section-title-group">
<h4>异常排行</h4>
</div>
<el-tag effect="plain" size="small" type="info">TOP {{ ipRiskStats.length }}</el-tag>
</div>
<div v-if="ipRiskStats.length" class="risk-rank-list">
<div v-for="(item, index) in ipRiskStats" :key="item.ip" class="risk-rank-row">
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
<div class="rank-user">
<div class="rank-main-line">
<strong>{{ item.ip }}</strong>
<small>{{ item.location }}</small>
</div>
<div class="rank-meta-line">
<span>{{ item.categoryText }}</span>
<span>最后访问 {{ formatTime(item.lastSeenAt) }}</span>
</div>
</div>
<div class="rank-count">
<strong>{{ item.total }}</strong>
<small :class="{ 'denied-highlight': item.highRiskCount > 0 }">{{ item.highRiskCount }} 高危</small>
</div>
</div>
</div>
<el-empty v-else description="暂无异常数据" :image-size="72" />
</article>
<article class="ranking-card event-distribution-card">
<div class="section-head">
<div class="section-title-group">
<h4>事件分布</h4>
</div>
</div>
<div class="distribution-section">
<div class="distribution-section-head">
<strong>访问的接口类型</strong>
</div>
<div v-if="endpointTypeStats.length" class="distribution-list">
<div v-for="item in endpointTypeStats" :key="item.type" class="distribution-row">
<div class="distribution-info">
<strong>{{ item.type }}</strong>
<small>{{ item.samplePath }}</small>
</div>
<div class="distribution-meter">
<span :style="{ width: `${item.percent}%` }" />
</div>
<strong class="distribution-count">{{ item.total }}</strong>
</div>
</div>
<el-empty v-else description="暂无接口类型数据" :image-size="72" />
</div>
<div class="distribution-section compact-risk-section">
<div class="distribution-section-head">
<strong>风险等级</strong>
</div>
<div class="risk-level-list">
<div v-for="item in riskLevelStats" :key="item.value" class="risk-level-row">
<span class="risk-dot" :class="item.tone" />
<span>{{ item.label }}</span>
<div class="risk-level-meter">
<span :class="item.tone" :style="{ width: `${item.percent}%` }" />
</div>
<strong>{{ item.total }}</strong>
</div>
</div>
</div>
</article>
</section>
<section class="security-table-wrap">
<div class="section-head table-head">
<div class="section-title-group">
<h4>安全事件明细</h4>
</div>
<section class="audit-filters detail-filters">
<el-select v-model="severityFilter" placeholder="风险等级" clearable style="width: 130px" @change="onFilterChange">
<el-option v-for="item in severityOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="categoryFilter" placeholder="事件分类" clearable style="width: 150px" @change="onFilterChange">
<el-option v-for="item in categoryOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="authFilter" placeholder="认证状态" clearable style="width: 140px" @change="onFilterChange">
<el-option label="匿名" value="ANONYMOUS" />
<el-option label="无效令牌" value="INVALID_TOKEN" />
<el-option label="已认证" value="AUTHENTICATED" />
</el-select>
<el-input
v-model="keyword"
placeholder="搜索 IP、位置、账号、路径或 UA"
clearable
class="security-search"
:prefix-icon="SearchIcon"
/>
<el-button type="primary" :loading="loading" @click="loadSecurityEvents">刷新</el-button>
</section>
<el-tag effect="plain" size="small" type="info">{{ securityTotal }} </el-tag>
</div>
<el-table :data="paginatedEvents" v-loading="loading" class="security-table" table-layout="fixed">
<el-table-column label="级别" width="96">
<template #default="{ row }">
<el-tag :type="severityTagType(row.severity)" effect="dark" size="small">{{ severityLabel(row.severity) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="分类" width="130">
<template #default="{ row }">
<span class="category-pill">{{ categoryLabel(row.category) }}</span>
</template>
</el-table-column>
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="IP" width="132">
<template #default="{ row }">{{ row.client_ip || "未知 IP" }}</template>
</el-table-column>
<el-table-column label="位置" width="180">
<template #default="{ row }">{{ formatIpLocation(row) }}</template>
</el-table-column>
<el-table-column label="账号" width="140" prop="account_label" />
<el-table-column label="请求" min-width="320">
<template #default="{ row }">
<div class="request-cell">
<strong>{{ row.method }} {{ row.status_code }} / {{ resolveEndpointType(row.path) }}</strong>
<span>{{ row.path }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="耗时" width="86">
<template #default="{ row }">{{ row.elapsed_ms.toFixed(1) }}ms</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="securityTotal > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="securityTotal"
layout="prev, pager, next, sizes, total"
small
@size-change="onPageSizeChange"
/>
</div>
</section>
<el-drawer v-model="detailVisible" size="520px" direction="rtl" :show-close="false">
<template #header>
<div class="security-detail-head">
<strong>安全事件详情</strong>
<el-button size="small" @click="detailVisible = false">关闭</el-button>
</div>
</template>
<div v-if="selectedEvent" class="security-detail">
<div class="detail-grid">
<div><span>级别</span><strong>{{ severityLabel(selectedEvent.severity) }}</strong></div>
<div><span>分类</span><strong>{{ categoryLabel(selectedEvent.category) }}</strong></div>
<div><span>状态码</span><strong>{{ selectedEvent.status_code }}</strong></div>
<div><span>认证状态</span><strong>{{ authLabel(selectedEvent.auth_status) }}</strong></div>
<div><span>IP</span><strong>{{ selectedEvent.client_ip || "未知" }}</strong></div>
<div><span>位置</span><strong>{{ formatIpLocation(selectedEvent) }}</strong></div>
<div><span>接口类型</span><strong>{{ resolveEndpointType(selectedEvent.path) }}</strong></div>
<div><span>账号</span><strong>{{ selectedEvent.account_label }}</strong></div>
</div>
<div class="detail-section">
<span>请求路径</span>
<code>{{ selectedEvent.method }} {{ selectedEvent.path }}</code>
</div>
<div class="detail-section">
<span>User Agent</span>
<code>{{ selectedEvent.user_agent || "-" }}</code>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref, watch } from "vue";
import { Search as SearchIcon } from "@element-plus/icons-vue";
import { fetchSecurityAccessLogs } from "@/api/projectPermissions";
import type { SecurityAccessLogItem } from "@/types/api";
type MetricTone = "blue" | "cyan" | "danger" | "indigo";
const MetricIconShield = () => 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 MetricIconIp = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "10", r: "3" }),
h("path", { d: "M12 21s7-5.2 7-11a7 7 0 1 0-14 0c0 5.8 7 11 7 11z" }),
]);
const MetricIconAlert = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" }),
h("line", { x1: "12", y1: "9", x2: "12", y2: "13" }),
h("line", { x1: "12", y1: "17", x2: "12.01", y2: "17" }),
]);
const MetricIconServer = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("rect", { x: "3", y: "4", width: "18", height: "8", rx: "2" }),
h("rect", { x: "3", y: "12", width: "18", height: "8", rx: "2" }),
h("line", { x1: "7", y1: "8", x2: "7.01", y2: "8" }),
h("line", { x1: "7", y1: "16", x2: "7.01", y2: "16" }),
]);
const loading = ref(false);
const statsEvents = ref<SecurityAccessLogItem[]>([]);
const page = ref(1);
const pageSize = ref(10);
const keyword = ref("");
const severityFilter = ref("");
const categoryFilter = ref("");
const authFilter = ref("");
const detailVisible = ref(false);
const selectedEvent = ref<SecurityAccessLogItem | null>(null);
const severityOptions = [
{ label: "严重", value: "CRITICAL", tone: "critical" },
{ label: "高", value: "HIGH", tone: "high" },
{ label: "中", value: "MEDIUM", tone: "medium" },
{ label: "低", value: "LOW", tone: "low" },
];
const categoryOptions = [
{ label: "敏感路径探测", value: "PROBE" },
{ label: "异常IP", value: "ABNORMAL_IP" },
{ label: "无效令牌", value: "INVALID_TOKEN" },
{ label: "服务异常", value: "SERVER_ERROR" },
{ label: "匿名 API", value: "ANONYMOUS_API" },
{ label: "普通 404", value: "NOT_FOUND_NOISE" },
{ label: "其他", value: "OTHER" },
];
const severityLabel = (value: string) => severityOptions.find((item) => item.value === value)?.label || value;
const categoryLabel = (value: string) => categoryOptions.find((item) => item.value === value)?.label || value;
const authLabel = (value: string) => {
const labels: Record<string, string> = {
ANONYMOUS: "匿名",
INVALID_TOKEN: "无效令牌",
AUTHENTICATED: "已认证",
};
return labels[value] || value;
};
const severityTagType = (value: string) => {
if (value === "CRITICAL" || value === "HIGH") return "danger";
if (value === "MEDIUM") return "warning";
return "info";
};
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
const formatTime = (value: string) => new Date(value).toLocaleString("zh-CN", { hour12: false });
const isHighRisk = (event: SecurityAccessLogItem) => event.severity === "CRITICAL" || event.severity === "HIGH";
const fallbackIpLocation = (ip: string | null) => {
if (!ip) return "未知位置";
if (ip === "127.0.0.1" || ip === "::1") return "本机访问";
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip)) return "内网地址";
return "未知位置";
};
const formatIpLocation = (row: SecurityAccessLogItem) => {
const parts = [
row.ip_country && row.ip_country !== "中国" ? row.ip_country : "",
row.ip_province,
row.ip_city,
row.ip_isp,
].filter(Boolean);
if (parts.length) return parts.join(" / ");
return row.ip_location || fallbackIpLocation(row.client_ip);
};
const resolveEndpointType = (path: string) => {
const normalizedPath = path.toLowerCase();
if (normalizedPath.includes("/auth") || normalizedPath.includes("/login")) return "认证接口";
if (normalizedPath.startsWith("/api/")) return "业务 API";
if (normalizedPath.match(/\.(js|css|png|jpg|jpeg|svg|ico|map)$/)) return "静态资源";
if ([".env", "wp-", "php", "admin", "shell", "config"].some((marker) => normalizedPath.includes(marker))) return "探测路径";
return "其他请求";
};
const detailFilteredEvents = computed(() => {
const token = keyword.value.trim().toLowerCase();
return statsEvents.value.filter((event) => {
if (severityFilter.value && event.severity !== severityFilter.value) return false;
if (categoryFilter.value && event.category !== categoryFilter.value) return false;
if (authFilter.value && event.auth_status !== authFilter.value) return false;
if (!token) return true;
return [
event.client_ip,
formatIpLocation(event),
event.account_label,
event.path,
resolveEndpointType(event.path),
event.user_agent,
event.auth_status,
event.status_code,
].some((value) => String(value || "").toLowerCase().includes(token));
});
});
const securityTotal = computed(() => detailFilteredEvents.value.length);
const paginatedEvents = computed(() => {
const start = (page.value - 1) * pageSize.value;
return detailFilteredEvents.value.slice(start, start + pageSize.value);
});
const countByCategory = (category: string) => statsEvents.value.filter((event) => event.category === category).length;
const metricCards = computed<Array<{ label: string; value: string; tone: MetricTone; icon: any }>>(() => {
const uniqueIpCount = new Set(statsEvents.value.map((event) => event.client_ip || "未知 IP")).size;
const highRiskCount = statsEvents.value.filter(isHighRisk).length;
const serverErrorCount = countByCategory("SERVER_ERROR");
return [
{ label: "安全事件", value: formatNumber(statsEvents.value.length), tone: "blue", icon: MetricIconShield },
{ label: "异常 IP", value: formatNumber(uniqueIpCount), tone: "cyan", icon: MetricIconIp },
{ label: "高危风险", value: formatNumber(highRiskCount), tone: highRiskCount > 0 ? "danger" : "indigo", icon: MetricIconAlert },
{ label: "服务异常", value: formatNumber(serverErrorCount), tone: serverErrorCount > 0 ? "danger" : "indigo", icon: MetricIconServer },
];
});
const ipRiskStats = computed(() => {
const stats = new Map<string, { ip: string; location: string; categoryText: string; total: number; highRiskCount: number; lastSeenAt: string }>();
statsEvents.value.forEach((event) => {
const ip = event.client_ip || "未知 IP";
const current = stats.get(ip) || {
ip,
location: formatIpLocation(event),
categoryText: categoryLabel(event.category),
total: 0,
highRiskCount: 0,
lastSeenAt: event.created_at,
};
current.total += 1;
if (isHighRisk(event)) current.highRiskCount += 1;
if (new Date(event.created_at).getTime() > new Date(current.lastSeenAt).getTime()) {
current.lastSeenAt = event.created_at;
current.categoryText = categoryLabel(event.category);
}
stats.set(ip, current);
});
return [...stats.values()].sort((a, b) => b.highRiskCount - a.highRiskCount || b.total - a.total).slice(0, 10);
});
const endpointTypeStats = computed(() => {
const stats = new Map<string, { type: string; total: number; samplePath: string }>();
statsEvents.value.forEach((event) => {
const type = resolveEndpointType(event.path);
const current = stats.get(type) || { type, total: 0, samplePath: event.path };
current.total += 1;
stats.set(type, current);
});
const maxTotal = Math.max(1, ...[...stats.values()].map((item) => item.total));
return [...stats.values()]
.sort((a, b) => b.total - a.total)
.map((item) => ({ ...item, percent: Math.max(8, Math.round((item.total / maxTotal) * 100)) }));
});
const riskLevelStats = computed(() => {
const filteredCounts = severityOptions.map((item) => ({
...item,
total: statsEvents.value.filter((event) => event.severity === item.value).length,
}));
const maxTotal = Math.max(1, ...filteredCounts.map((item) => item.total));
return filteredCounts.map((item) => {
return {
...item,
percent: item.total > 0 ? Math.max(8, Math.round((item.total / maxTotal) * 100)) : 0,
};
});
});
const SECURITY_STATS_PAGE_SIZE = 200;
const loadSecurityEvents = async () => {
loading.value = true;
try {
await loadSecurityStats();
} finally {
loading.value = false;
}
};
const loadSecurityStats = async () => {
const first = await fetchSecurityAccessLogs({
status_min: 400,
auth_status: authFilter.value || undefined,
page: 1,
page_size: SECURITY_STATS_PAGE_SIZE,
});
const allItems = [...first.data.items];
const total = first.data.total;
const totalPages = Math.ceil(total / SECURITY_STATS_PAGE_SIZE);
for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {
const res = await fetchSecurityAccessLogs({
status_min: 400,
auth_status: authFilter.value || undefined,
page: nextPage,
page_size: SECURITY_STATS_PAGE_SIZE,
});
allItems.push(...res.data.items);
}
statsEvents.value = allItems;
};
const onFilterChange = () => {
page.value = 1;
loadSecurityEvents();
};
const onPageSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
};
watch(keyword, () => {
page.value = 1;
});
const openDetail = (event: SecurityAccessLogItem) => {
selectedEvent.value = event;
detailVisible.value = true;
};
onMounted(loadSecurityEvents);
defineExpose({ refresh: loadSecurityEvents });
</script>
<style scoped>
.security-center {
--audit-ink: #1a2332;
--audit-muted: #64748b;
--audit-border: #e2e8f0;
--audit-blue: #3b82f6;
--audit-cyan: #06b6d4;
--audit-danger: #ef4444;
--audit-indigo: #6366f1;
--card-radius: 16px;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
gap: 16px;
}
.audit-filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
padding: 14px 18px;
background: #fff;
border-radius: var(--card-radius);
border: 1px solid var(--audit-border);
box-shadow: var(--card-shadow);
}
.security-search {
width: 280px;
}
.audit-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.metric-card {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 14px;
min-height: 64px;
padding: 10px 16px;
border-radius: var(--card-radius);
background: #fff;
border: 1px solid var(--audit-border);
box-shadow: var(--card-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.metric-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
.metric-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
flex-shrink: 0;
}
.metric-icon svg {
width: 22px;
height: 22px;
}
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
.metric-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.metric-label {
color: var(--audit-muted);
font-size: 13px;
font-weight: 500;
}
.metric-value {
color: var(--audit-ink);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.audit-grid {
display: grid;
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);
gap: 14px;
}
.ranking-card,
.security-table-wrap {
min-width: 0;
padding: 20px;
border: 1px solid var(--audit-border);
border-radius: var(--card-radius);
background: #fff;
box-shadow: var(--card-shadow);
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.table-head {
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
}
.table-head .section-title-group {
flex-shrink: 0;
}
.table-head .el-tag {
margin-left: auto;
flex-shrink: 0;
}
.detail-filters {
flex: 1;
min-width: 0;
padding: 0;
border: 0;
box-shadow: none;
}
.section-title-group h4 {
margin: 0;
color: var(--audit-ink);
font-size: 16px;
font-weight: 600;
}
.section-title-group p {
margin: 4px 0 0;
color: var(--audit-muted);
font-size: 12px;
}
.risk-rank-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.risk-rank-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 12px 10px;
border-radius: 10px;
transition: background 0.15s ease;
}
.risk-rank-row:hover {
background: #f8fafc;
}
.rank-no {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #f1f5f9;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.rank-no.rank-top {
background: linear-gradient(135deg, #ef4444, #6366f1);
color: #fff;
}
.rank-user,
.rank-count {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.rank-main-line,
.rank-meta-line {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.rank-main-line strong {
flex-shrink: 0;
overflow: hidden;
color: var(--audit-ink);
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-main-line small {
overflow: hidden;
color: var(--audit-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-meta-line span {
color: var(--audit-muted);
font-size: 12px;
}
.rank-count {
align-items: flex-end;
}
.rank-count strong {
color: var(--audit-ink);
font-size: 18px;
font-weight: 700;
}
.rank-count small {
color: var(--audit-muted);
font-size: 11px;
}
.rank-count .denied-highlight {
color: var(--audit-danger);
font-weight: 600;
}
.distribution-list,
.risk-level-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.event-distribution-card {
display: flex;
flex-direction: column;
gap: 22px;
}
.distribution-section {
display: flex;
flex-direction: column;
gap: 14px;
}
.distribution-section + .distribution-section {
padding-top: 24px;
border-top: 1px solid var(--audit-border);
}
.event-distribution-card > .section-head {
margin-bottom: 0;
}
.event-distribution-card .section-title-group h4 {
font-size: 16px;
}
.distribution-section-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.distribution-section-head strong {
color: var(--audit-ink);
font-size: 15px;
font-weight: 700;
}
.distribution-row {
display: grid;
grid-template-columns: minmax(160px, 0.9fr) minmax(120px, 1fr) 44px;
gap: 12px;
align-items: center;
}
.distribution-info {
min-width: 0;
}
.distribution-info strong {
display: block;
color: var(--audit-ink);
font-size: 14px;
font-weight: 600;
}
.distribution-info small {
display: block;
overflow: hidden;
margin-top: 3px;
color: var(--audit-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.distribution-meter,
.risk-level-meter {
overflow: hidden;
height: 8px;
border-radius: 999px;
background: #f1f5f9;
}
.distribution-meter span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #06b6d4, #3b82f6);
}
.distribution-count {
color: var(--audit-ink);
font-size: 16px;
text-align: right;
}
.risk-level-row {
display: grid;
grid-template-columns: 10px 52px minmax(0, 1fr) 44px;
gap: 10px;
align-items: center;
}
.risk-dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.risk-dot.critical,
.risk-level-meter span.critical { background: #991b1b; }
.risk-dot.high,
.risk-level-meter span.high { background: #ef4444; }
.risk-dot.medium,
.risk-level-meter span.medium { background: #f59e0b; }
.risk-dot.low,
.risk-level-meter span.low { background: #64748b; }
.risk-level-row > span:nth-child(2) {
color: var(--audit-ink);
font-size: 13px;
font-weight: 600;
}
.risk-level-meter span {
display: block;
height: 100%;
border-radius: inherit;
}
.risk-level-row strong {
color: var(--audit-ink);
font-size: 14px;
text-align: right;
}
.security-table-wrap {
overflow: hidden;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 10px;
padding: 8px 16px 0;
}
.category-pill {
display: inline-flex;
align-items: center;
max-width: 112px;
padding: 3px 8px;
border-radius: 999px;
background: #eef2f7;
color: #334155;
font-size: 12px;
font-weight: 600;
}
.request-cell {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.request-cell strong {
font-size: 12px;
color: #475569;
}
.request-cell span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--audit-ink);
}
.security-detail-head {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
}
.security-detail {
display: flex;
flex-direction: column;
gap: 14px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.detail-grid div,
.detail-section {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border: 1px solid var(--audit-border);
border-radius: 8px;
background: #f8fafc;
}
.detail-grid span,
.detail-section span {
color: var(--audit-muted);
font-size: 12px;
}
.detail-grid strong {
color: var(--audit-ink);
font-size: 14px;
}
.detail-section code {
white-space: pre-wrap;
word-break: break-all;
color: var(--audit-ink);
}
@media (max-width: 1100px) {
.audit-metrics,
.audit-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.audit-metrics,
.audit-grid {
grid-template-columns: 1fr;
}
.security-search {
width: 100%;
}
.distribution-row {
grid-template-columns: 1fr 44px;
}
.distribution-meter {
grid-column: 1 / -1;
grid-row: 2;
}
}
</style>