完善权限监控与安全中心
This commit is contained in:
@@ -44,21 +44,23 @@
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>IP访问排行</h4>
|
||||
<p>按来源 IP 排序,账号作为辅助定位</p>
|
||||
</div>
|
||||
<el-tag effect="plain" size="small" type="info">TOP {{ userStats.length }}</el-tag>
|
||||
<el-tag effect="plain" size="small" type="info">TOP {{ ipRankingItems.length }}</el-tag>
|
||||
</div>
|
||||
<div v-if="userStats.length" class="user-rank-list">
|
||||
<div v-for="(user, index) in userStats" :key="`${user.sample_ip_address}-${user.user_id}-${user.role}`" class="user-rank-row">
|
||||
<div v-if="ipRankingItems.length" class="user-rank-list">
|
||||
<div v-for="(item, index) in ipRankingItems" :key="item.ip" class="user-rank-row" :class="{ 'abnormal-rank-row': item.isAbnormal }">
|
||||
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
|
||||
<div class="rank-user">
|
||||
<strong>{{ user.sample_ip_address || "未知 IP" }}</strong>
|
||||
<small>{{ user.user_name }} / {{ roleLabel(user.role) }}</small>
|
||||
<span class="rank-location">{{ user.primary_location || "未知属地" }}</span>
|
||||
<strong>
|
||||
{{ item.ip }}
|
||||
<el-tag v-if="item.isAbnormal" class="rank-risk-tag" effect="plain" size="small" type="danger">异常IP</el-tag>
|
||||
</strong>
|
||||
<small>{{ item.label }}</small>
|
||||
<span class="rank-location">{{ item.location }}</span>
|
||||
</div>
|
||||
<div class="rank-count">
|
||||
<strong>{{ user.total_count }}</strong>
|
||||
<small :class="{ 'denied-highlight': user.denied_count > 0 }">{{ user.denied_count }} 拒绝</small>
|
||||
<strong>{{ item.total }}</strong>
|
||||
<small :class="{ 'denied-highlight': item.riskCount > 0 }">{{ item.riskCount }} 异常/拒绝</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,7 +71,6 @@
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>日志审计</h4>
|
||||
<p>查看详细的接口访问与安全事件记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-actions">
|
||||
@@ -81,7 +82,7 @@
|
||||
<strong>接口访问审计日志</strong>
|
||||
<span>记录所有 API 接口的访问行为</span>
|
||||
</div>
|
||||
<el-tag effect="dark" size="small" round>{{ terminalLines.length }} 条</el-tag>
|
||||
<el-tag effect="dark" size="small" round>{{ formatNumber(total) }} 条</el-tag>
|
||||
</div>
|
||||
<div v-if="showSecurityLog" class="log-action-item log-action-security" @click="openSecurityLogDialog">
|
||||
<div class="log-action-icon security">
|
||||
@@ -103,7 +104,7 @@
|
||||
<div class="dialog-head">
|
||||
<strong>接口访问审计日志</strong>
|
||||
<div class="dialog-actions">
|
||||
<el-tag effect="plain" type="info">最近 {{ terminalLines.length }} 条</el-tag>
|
||||
<el-tag effect="plain" type="info">共 {{ formatNumber(total) }} 条</el-tag>
|
||||
<el-button size="small" type="primary" @click="downloadInterfaceLog">下载日志</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,6 +181,8 @@ const MetricIconSpeed = () => h("svg", { viewBox: "0 0 24 24", fill: "none", str
|
||||
]);
|
||||
|
||||
const REALTIME_POLL_INTERVAL_MS = 3000;
|
||||
const IP_RANKING_LIMIT = 10;
|
||||
const SECURITY_RANKING_PAGE_SIZE = 200;
|
||||
|
||||
const emptySummary: AccessLogsSummary = {
|
||||
total_count: 0,
|
||||
@@ -228,6 +231,26 @@ const SECURITY_AUTH_LABELS: Record<string, string> = {
|
||||
AUTHENTICATED: "已认证",
|
||||
};
|
||||
|
||||
const SECURITY_CATEGORY_LABELS: Record<string, string> = {
|
||||
ABNORMAL_IP: "异常IP",
|
||||
PROBE: "敏感路径探测",
|
||||
INVALID_TOKEN: "无效令牌",
|
||||
SERVER_ERROR: "服务异常",
|
||||
ANONYMOUS_API: "匿名 API",
|
||||
NOT_FOUND_NOISE: "普通 404",
|
||||
OTHER: "其他",
|
||||
};
|
||||
|
||||
interface IpRankingItem {
|
||||
ip: string;
|
||||
location: string;
|
||||
label: string;
|
||||
total: number;
|
||||
riskCount: number;
|
||||
isAbnormal: boolean;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
const formatTerminalTime = (iso: string) => {
|
||||
const date = new Date(iso);
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
@@ -239,6 +262,13 @@ const formatIpLocation = (row: AccessLogItem) => {
|
||||
return parts.length ? parts.join(" / ") : row.ip_location;
|
||||
};
|
||||
|
||||
const formatSecurityIpLocation = (row: SecurityAccessLogItem) => {
|
||||
const parts = [row.ip_country && row.ip_country !== "中国" ? row.ip_country : "", row.ip_province, row.ip_city, row.ip_isp].filter(Boolean);
|
||||
return parts.length ? parts.join(" / ") : row.ip_location;
|
||||
};
|
||||
|
||||
const categoryLabel = (category: string) => SECURITY_CATEGORY_LABELS[category] || category || "其他";
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||
|
||||
const metricCards = computed(() => [
|
||||
@@ -304,6 +334,77 @@ const securityTerminalLines = computed(() => {
|
||||
return lines.filter((line) => line.toLowerCase().includes(token));
|
||||
});
|
||||
|
||||
const ipRankingItems = computed<IpRankingItem[]>(() => {
|
||||
const rankingByIp = new Map<string, IpRankingItem>();
|
||||
|
||||
userStats.value.forEach((user) => {
|
||||
const ip = user.sample_ip_address || "未知 IP";
|
||||
const existing = rankingByIp.get(ip);
|
||||
const lastSeenAt =
|
||||
existing?.lastSeenAt && new Date(existing.lastSeenAt).getTime() > new Date(user.last_seen_at || 0).getTime()
|
||||
? existing.lastSeenAt
|
||||
: user.last_seen_at;
|
||||
|
||||
if (!existing) {
|
||||
rankingByIp.set(ip, {
|
||||
ip,
|
||||
location: user.primary_location || "未知属地",
|
||||
label: `${user.user_name || "未知用户"} / ${roleLabel(user.role)}`,
|
||||
total: user.total_count,
|
||||
riskCount: user.denied_count,
|
||||
isAbnormal: false,
|
||||
lastSeenAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.total += user.total_count;
|
||||
existing.riskCount += user.denied_count;
|
||||
existing.lastSeenAt = lastSeenAt;
|
||||
if (existing.location === "未知属地" && user.primary_location) existing.location = user.primary_location;
|
||||
});
|
||||
|
||||
securityLogs.value.forEach((event) => {
|
||||
const ip = event.client_ip || "未知 IP";
|
||||
const existing = rankingByIp.get(ip);
|
||||
const isAbnormal = event.category === "ABNORMAL_IP";
|
||||
const location = formatSecurityIpLocation(event) || existing?.location || "未知属地";
|
||||
const lastSeenAt =
|
||||
existing?.lastSeenAt && new Date(existing.lastSeenAt).getTime() > new Date(event.created_at).getTime()
|
||||
? existing.lastSeenAt
|
||||
: event.created_at;
|
||||
|
||||
if (!existing) {
|
||||
rankingByIp.set(ip, {
|
||||
ip,
|
||||
location,
|
||||
label: isAbnormal ? categoryLabel(event.category) : `${categoryLabel(event.category)} / ${event.account_label || "未知账号"}`,
|
||||
total: 1,
|
||||
riskCount: isAbnormal || event.severity === "HIGH" || event.severity === "CRITICAL" ? 1 : 0,
|
||||
isAbnormal,
|
||||
lastSeenAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.location = location;
|
||||
existing.total += 1;
|
||||
existing.riskCount += isAbnormal || event.severity === "HIGH" || event.severity === "CRITICAL" ? 1 : 0;
|
||||
existing.isAbnormal = existing.isAbnormal || isAbnormal;
|
||||
existing.lastSeenAt = lastSeenAt;
|
||||
if (isAbnormal) existing.label = categoryLabel(event.category);
|
||||
});
|
||||
|
||||
return [...rankingByIp.values()]
|
||||
.sort((a, b) => {
|
||||
if (b.total !== a.total) return b.total - a.total;
|
||||
if (b.riskCount !== a.riskCount) return b.riskCount - a.riskCount;
|
||||
if (b.isAbnormal !== a.isAbnormal) return Number(b.isAbnormal) - Number(a.isAbnormal);
|
||||
return new Date(b.lastSeenAt || 0).getTime() - new Date(a.lastSeenAt || 0).getTime();
|
||||
})
|
||||
.slice(0, IP_RANKING_LIMIT);
|
||||
});
|
||||
|
||||
const onFilterChange = () => {
|
||||
page.value = 1;
|
||||
loadData();
|
||||
@@ -363,9 +464,15 @@ const loadSecurityData = async (options: { silent?: boolean } = {}) => {
|
||||
if (!showSecurityLog.value) return;
|
||||
if (!options.silent) securityLoading.value = true;
|
||||
try {
|
||||
const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 });
|
||||
securityLogs.value = res.data.items;
|
||||
securitySummary.value = res.data.summary || emptySecuritySummary;
|
||||
const first = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: SECURITY_RANKING_PAGE_SIZE });
|
||||
const allItems = [...first.data.items];
|
||||
const totalPages = Math.ceil(first.data.total / SECURITY_RANKING_PAGE_SIZE);
|
||||
for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {
|
||||
const res = await fetchSecurityAccessLogs({ status_min: 400, page: nextPage, page_size: SECURITY_RANKING_PAGE_SIZE });
|
||||
allItems.push(...res.data.items);
|
||||
}
|
||||
securityLogs.value = allItems;
|
||||
securitySummary.value = first.data.summary || emptySecuritySummary;
|
||||
if (securityLogDialogVisible.value) scrollSecurityTerminalToBottom();
|
||||
} catch {
|
||||
if (options.silent) return;
|
||||
@@ -551,7 +658,7 @@ defineExpose({ refresh });
|
||||
|
||||
.audit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 380px minmax(0, 1fr);
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -632,6 +739,10 @@ defineExpose({ refresh });
|
||||
}
|
||||
|
||||
.rank-user strong {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -667,6 +778,14 @@ defineExpose({ refresh });
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.abnormal-rank-row {
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.rank-risk-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -798,17 +917,15 @@ defineExpose({ refresh });
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.audit-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.audit-metrics,
|
||||
.audit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.audit-metrics {
|
||||
.audit-metrics,
|
||||
.audit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user