6b55c18610
- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。 - 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。 - 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。 - 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。 - 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。 - 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。 - 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。 - 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。 - 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。 - 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。 - 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。
1881 lines
52 KiB
Vue
1881 lines
52 KiB
Vue
<template>
|
|
<div class="security-center">
|
|
<el-alert v-if="loadError" :title="loadError" type="warning" show-icon :closable="false" />
|
|
|
|
<section class="security-overview">
|
|
<section class="security-toolbar">
|
|
<div class="security-toolbar-title">
|
|
<h3>安全中心</h3>
|
|
</div>
|
|
<div class="security-toolbar-actions">
|
|
<TimeRangeSelector v-model="timeRangePreset" @change="onTimeRangeChange" />
|
|
<span v-if="lastUpdatedAt" class="ctms-updated-at">更新于 {{ formatLastUpdated(lastUpdatedAt) }}</span>
|
|
<el-button size="small" type="primary" :loading="loading" @click="loadSecurityEvents">刷新</el-button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="audit-metrics" aria-label="安全指标">
|
|
<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>
|
|
|
|
<section class="security-analysis-card">
|
|
<div class="section-head analysis-summary-head">
|
|
<div class="section-title-group">
|
|
<h4>异常分析</h4>
|
|
<p>高风险来源与风险等级摘要</p>
|
|
</div>
|
|
<div class="analysis-summary-actions">
|
|
<el-tag effect="plain" size="small" type="danger">高危 {{ formatNumber(securitySummary.high_risk_count) }}</el-tag>
|
|
<el-button class="ctms-btn-dialog ctms-btn-capsule-danger" @click="openAnalysisDialog">
|
|
查看异常分析
|
|
<el-icon class="el-icon--right"><HistogramIcon /></el-icon>
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
<div class="analysis-summary-body">
|
|
<div class="risk-preview-list">
|
|
<div v-for="(item, index) in topRiskPreview" :key="item.ip" class="risk-preview-row">
|
|
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
|
|
<div class="risk-preview-main">
|
|
<strong>{{ item.ip }}</strong>
|
|
<span>{{ item.location }} · {{ item.categoryText }}</span>
|
|
</div>
|
|
<div class="risk-preview-count">
|
|
<strong>{{ item.total }}</strong>
|
|
<small :class="{ 'denied-highlight': item.highRiskCount > 0 }">{{ item.highRiskCount }} 高危</small>
|
|
</div>
|
|
</div>
|
|
<el-empty v-if="!topRiskPreview.length" description="暂无异常数据" :image-size="48" />
|
|
</div>
|
|
<div class="risk-summary-panel">
|
|
<strong class="risk-summary-title">风险等级</strong>
|
|
<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>
|
|
</div>
|
|
</section>
|
|
|
|
<section ref="securityEventsSectionRef" class="security-table-wrap">
|
|
<div class="section-head table-head">
|
|
<section class="audit-filters detail-filters">
|
|
<el-select v-model="severityFilter" placeholder="风险等级" clearable size="small" style="width: 112px" @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 size="small" style="width: 132px" @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 size="small" style="width: 122px" @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
|
|
size="small"
|
|
class="security-search"
|
|
:prefix-icon="SearchIcon"
|
|
/>
|
|
<el-button size="small" class="ctms-btn-dialog ctms-btn-gradient-indigo" :loading="securityLogDialogLoading" @click="openSecurityLogDialog">
|
|
安全日志
|
|
<el-icon class="el-icon--right"><DocumentIcon /></el-icon>
|
|
</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" size="small" table-layout="fixed">
|
|
<el-table-column label="级别" width="80">
|
|
<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="110">
|
|
<template #default="{ row }">
|
|
<span class="category-pill">{{ categoryLabel(row.category) }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="时间" width="160">
|
|
<template #default="{ row }">
|
|
<span class="time-cell">{{ formatTime(row.created_at) }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="IP" width="140">
|
|
<template #default="{ row }">
|
|
<span class="ip-cell">{{ row.client_ip || "未知 IP" }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="位置" width="160">
|
|
<template #default="{ row }">
|
|
<span class="location-cell">{{ formatIpLocation(row) }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="账号" width="130" prop="account_label" />
|
|
<el-table-column label="请求" min-width="260">
|
|
<template #default="{ row }">
|
|
<div class="request-cell">
|
|
<strong>{{ row.method }} {{ row.status_code }} / {{ resolveEndpointType(row.path) }}</strong>
|
|
<span>{{ securityRequestTarget(row) }}</span>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="耗时" width="90">
|
|
<template #default="{ row }">
|
|
<span class="elapsed-cell-strong">{{ row.elapsed_ms.toFixed(1) }}ms</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="72" align="center">
|
|
<template #default="{ row }">
|
|
<el-button link type="primary" size="small" @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"
|
|
@current-change="loadSecurityEvents"
|
|
/>
|
|
</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 detail-stack">
|
|
<div class="detail-grid">
|
|
<div class="detail-card">
|
|
<span>账号</span>
|
|
<strong>{{ selectedEvent.account_label || "未知账号" }}</strong>
|
|
<small>{{ selectedEvent.user_identifier || authLabel(selectedEvent.auth_status) }}</small>
|
|
</div>
|
|
<div class="detail-card">
|
|
<span>来源</span>
|
|
<strong>{{ securityClientSourceLabel(selectedEvent) }}</strong>
|
|
<small>{{ securityClientMeta(selectedEvent) }}</small>
|
|
</div>
|
|
<div class="detail-card">
|
|
<span>IP / 属地</span>
|
|
<strong>{{ selectedEvent.client_ip || "未知 IP" }}</strong>
|
|
<small>{{ formatIpLocation(selectedEvent) }}</small>
|
|
</div>
|
|
<div class="detail-card">
|
|
<span>结果 / 耗时</span>
|
|
<strong>HTTP {{ selectedEvent.status_code }}</strong>
|
|
<small>{{ selectedEvent.elapsed_ms.toFixed(1) }}ms</small>
|
|
</div>
|
|
</div>
|
|
<div class="detail-section detail-section--request">
|
|
<h4>请求概览</h4>
|
|
<p class="detail-request-line">{{ securityRequestMethod(selectedEvent) }} {{ securityRequestTarget(selectedEvent) }}</p>
|
|
<div class="detail-field-grid">
|
|
<div v-for="field in securityRequestOverviewFields(selectedEvent)" :key="field.label" class="detail-field-item">
|
|
<span>{{ field.label }}</span>
|
|
<strong>{{ field.value }}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-section detail-section--raw">
|
|
<h4>原始 HTTP 请求</h4>
|
|
<pre class="detail-raw-line">{{ buildSecurityRawRequestBlock(selectedEvent) }}</pre>
|
|
</div>
|
|
<el-collapse class="detail-more">
|
|
<el-collapse-item name="metadata" title="更多审计信息">
|
|
<div class="detail-subsection">
|
|
<h5>日志标识</h5>
|
|
<div class="detail-field-grid">
|
|
<div v-for="field in securityAuditMetadataFields(selectedEvent)" :key="field.label" class="detail-field-item">
|
|
<span>{{ field.label }}</span>
|
|
<strong>{{ field.value }}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-subsection">
|
|
<h5>传输信息</h5>
|
|
<div class="detail-field-grid">
|
|
<div v-for="field in securityRequestSnapshotFields(selectedEvent)" :key="field.label" class="detail-field-item">
|
|
<span>{{ field.label }}</span>
|
|
<strong>{{ field.value }}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-subsection">
|
|
<h5>客户端标识</h5>
|
|
<div class="detail-field-grid">
|
|
<div class="detail-field-item">
|
|
<span>User-Agent</span>
|
|
<strong>{{ selectedEvent.user_agent || "未记录" }}</strong>
|
|
</div>
|
|
<div class="detail-field-item">
|
|
<span>构建信息</span>
|
|
<strong>{{ securityBuildMeta(selectedEvent) }}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</el-collapse-item>
|
|
</el-collapse>
|
|
</div>
|
|
</el-drawer>
|
|
|
|
<el-dialog v-model="analysisDialogVisible" width="min(1180px, 96vw)" class="security-analysis-dialog">
|
|
<template #header>
|
|
<div class="security-log-dialog-head">
|
|
<strong>异常分析</strong>
|
|
<div class="security-log-dialog-actions">
|
|
<el-tag effect="plain" type="danger">高危 {{ formatNumber(securitySummary.high_risk_count) }}</el-tag>
|
|
<el-button size="small" type="primary" @click="jumpToSecurityEvents">查看事件明细</el-button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="analysis-ranking-dialog">
|
|
<div class="analysis-ranking-head">
|
|
<el-tag effect="plain" size="small" type="info">TOP {{ ipRiskStats.length }}</el-tag>
|
|
<div class="endpoint-summary-chips">
|
|
<span v-for="item in endpointTypeStats" :key="item.type">
|
|
{{ item.type }} <strong>{{ formatNumber(item.total) }}</strong>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div v-if="ipRiskStats.length" class="analysis-rank-grid">
|
|
<article
|
|
v-for="(item, index) in ipRiskStats"
|
|
:key="item.ip"
|
|
class="analysis-rank-card"
|
|
:class="{ 'is-high-risk': item.highRiskCount > 0 }"
|
|
>
|
|
<div class="analysis-rank-card-head">
|
|
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
|
|
<el-tag size="small" :type="item.highRiskCount > 0 ? 'danger' : 'info'" effect="plain">
|
|
{{ item.categoryText }}
|
|
</el-tag>
|
|
</div>
|
|
<div class="analysis-rank-card-ip">
|
|
<strong>{{ item.ip }}</strong>
|
|
<span>{{ item.location }}</span>
|
|
</div>
|
|
<div class="analysis-rank-card-meta">最后访问 {{ formatTime(item.lastSeenAt) }}</div>
|
|
<div class="analysis-rank-card-stats">
|
|
<div>
|
|
<span>事件</span>
|
|
<strong>{{ formatNumber(item.total) }}</strong>
|
|
</div>
|
|
<div>
|
|
<span>高危</span>
|
|
<strong :class="{ 'denied-highlight': item.highRiskCount > 0 }">{{ formatNumber(item.highRiskCount) }}</strong>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
<el-empty v-else description="暂无异常数据" :image-size="72" />
|
|
</div>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="securityLogDialogVisible" title="安全日志" width="82%" @opened="scrollSecurityTerminalToBottom">
|
|
<template #header>
|
|
<div class="security-log-dialog-head">
|
|
<strong>安全日志</strong>
|
|
<div class="security-log-dialog-actions">
|
|
<el-tag effect="plain" type="danger">共 {{ formatNumber(securityTotal) }} 条</el-tag>
|
|
<el-button size="small" type="primary" @click="downloadSecurityLog">下载日志</el-button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div ref="securityTerminalWindowRef" v-loading="securityLogDialogLoading || loading" class="terminal-window dialog-terminal-window">
|
|
<el-alert v-if="securityLogDialogError" :title="securityLogDialogError" type="warning" show-icon :closable="false" />
|
|
<pre v-if="securityTerminalLines.length" class="terminal-lines"><code>{{ securityTerminalLines.join("\n") }}</code></pre>
|
|
<el-empty v-else description="暂无安全日志" :image-size="72" />
|
|
</div>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
import { Search as SearchIcon, Histogram as HistogramIcon, Document as DocumentIcon } from "@element-plus/icons-vue";
|
|
import { fetchSecurityAccessLogs } from "@/api/projectPermissions";
|
|
import TimeRangeSelector from "./TimeRangeSelector.vue";
|
|
import type { SecurityAccessLogItem, SecurityAccessLogsResponse } from "@/types/api";
|
|
import { saveFileWithFeedback } from "@/utils/fileTaskFeedback";
|
|
|
|
type MetricTone = "blue" | "cyan" | "danger" | "indigo";
|
|
type TimeRangePreset = "all" | "24h" | "7d" | "30d";
|
|
|
|
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 allSecurityEvents = ref<SecurityAccessLogItem[]>([]);
|
|
const securityTotal = ref(0);
|
|
const emptySecuritySummary: SecurityAccessLogsResponse["summary"] = {
|
|
total_count: 0,
|
|
anonymous_count: 0,
|
|
invalid_token_count: 0,
|
|
error_count: 0,
|
|
unique_ip_count: 0,
|
|
high_risk_count: 0,
|
|
server_error_count: 0,
|
|
category_counts: {},
|
|
severity_counts: {},
|
|
endpoint_type_counts: {},
|
|
top_ips: [],
|
|
};
|
|
const securitySummary = ref<SecurityAccessLogsResponse["summary"]>({ ...emptySecuritySummary });
|
|
const page = ref(1);
|
|
const pageSize = ref(10);
|
|
const timeRangePreset = ref<TimeRangePreset>("24h");
|
|
const keyword = ref("");
|
|
const severityFilter = ref("");
|
|
const categoryFilter = ref("");
|
|
const authFilter = ref("");
|
|
const detailVisible = ref(false);
|
|
const selectedEvent = ref<SecurityAccessLogItem | null>(null);
|
|
const analysisDialogVisible = ref(false);
|
|
const securityLogDialogVisible = ref(false);
|
|
const securityLogDialogLoading = ref(false);
|
|
const securityTerminalWindowRef = ref<HTMLElement | null>(null);
|
|
const securityEventsSectionRef = ref<HTMLElement | null>(null);
|
|
const keywordTimer = ref<number | null>(null);
|
|
const loadError = ref("");
|
|
const securityLogDialogError = ref("");
|
|
const lastUpdatedAt = ref<Date | 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 timeRangeOptions: Array<{ value: TimeRangePreset; label: string; hours: number | null }> = [
|
|
{ value: "all", label: "全部", hours: null },
|
|
{ value: "24h", label: "24小时", hours: 24 },
|
|
{ value: "7d", label: "7天", hours: 24 * 7 },
|
|
{ value: "30d", label: "30天", hours: 24 * 30 },
|
|
];
|
|
|
|
const categoryOptions = [
|
|
{ label: "敏感路径探测", value: "PROBE" },
|
|
{ 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 formatLastUpdated = (value: Date) => {
|
|
return `${value.getFullYear()}/${value.getMonth() + 1}/${value.getDate()} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}:${String(value.getSeconds()).padStart(2, "0")}`;
|
|
};
|
|
|
|
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 isScriptLikeUserAgent = (userAgent?: string | null) => {
|
|
const ua = String(userAgent || "").toLowerCase();
|
|
return ["curl", "wget", "python-requests", "go-http-client", "java/", "okhttp", "postman", "insomnia", "httpie"].some((marker) =>
|
|
ua.includes(marker),
|
|
);
|
|
};
|
|
|
|
const securityClientSourceLabel = (event: SecurityAccessLogItem) => {
|
|
const clientType = String(event.client_type || "").toLowerCase();
|
|
if (clientType === "web") return "网页端";
|
|
if (clientType === "desktop") return "桌面端";
|
|
if (isScriptLikeUserAgent(event.user_agent)) return "脚本/自动化";
|
|
return "未知/异常";
|
|
};
|
|
|
|
const securityClientMeta = (event: SecurityAccessLogItem) => {
|
|
const parts = [event.client_type, event.client_version, event.client_platform].filter(Boolean);
|
|
if (parts.length) return parts.join(" / ");
|
|
return event.user_agent ? "未标识客户端" : "未记录来源";
|
|
};
|
|
|
|
const securityBuildMeta = (event: SecurityAccessLogItem) =>
|
|
[event.build_channel, event.build_commit].filter(Boolean).join(" / ") || "未记录";
|
|
|
|
const securityRequestSnapshot = (event: SecurityAccessLogItem) => event.request_snapshot || {};
|
|
|
|
const hasSecurityRequestSnapshot = (event: SecurityAccessLogItem) => Object.keys(securityRequestSnapshot(event)).length > 0;
|
|
|
|
const securityRequestHeaderEntries = (event: SecurityAccessLogItem) =>
|
|
Object.entries(securityRequestSnapshot(event).headers || event.request_headers || {}).sort(([left], [right]) => left.localeCompare(right));
|
|
|
|
const securityRequestMethod = (event: SecurityAccessLogItem) => securityRequestSnapshot(event).method || event.method || "HTTP";
|
|
|
|
const securityRequestPath = (event: SecurityAccessLogItem) => securityRequestSnapshot(event).path || event.path || "-";
|
|
|
|
const securityRequestQueryString = (event: SecurityAccessLogItem) => securityRequestSnapshot(event).query_string || "";
|
|
|
|
const securityRequestTarget = (event: SecurityAccessLogItem) => {
|
|
const path = securityRequestPath(event);
|
|
const query = securityRequestQueryString(event);
|
|
if (!query || path.includes("?")) return path;
|
|
return `${path}?${query}`;
|
|
};
|
|
|
|
const securityRequestOverviewFields = (event: SecurityAccessLogItem) => [
|
|
{ label: "时间", value: formatTime(event.created_at) },
|
|
{ label: "接口类型", value: resolveEndpointType(event.path) },
|
|
{ label: "事件分类", value: categoryLabel(event.category) },
|
|
{ label: "认证状态", value: authLabel(event.auth_status) },
|
|
{ label: "风险等级", value: severityLabel(event.severity) },
|
|
];
|
|
|
|
const securityAuditMetadataFields = (event: SecurityAccessLogItem) => [
|
|
{ label: "日志ID", value: event.id },
|
|
{ label: "类型", value: "安全事件" },
|
|
{ label: "账号标识", value: event.user_identifier || "未知" },
|
|
];
|
|
|
|
const securityRequestSnapshotFields = (event: SecurityAccessLogItem) => {
|
|
if (!hasSecurityRequestSnapshot(event)) {
|
|
return [
|
|
{ label: "采集状态", value: "历史日志未采集原始请求快照" },
|
|
{ label: "可用请求信息", value: `${securityRequestMethod(event)} ${securityRequestTarget(event)}`.trim() },
|
|
{ label: "说明", value: "该记录创建于请求快照能力上线前,无法还原 Method/Path/Query/Headers 原文" },
|
|
{ label: "Body", value: "未采集明文" },
|
|
];
|
|
}
|
|
const snapshot = securityRequestSnapshot(event);
|
|
const client = snapshot.client || {};
|
|
const content = snapshot.content || {};
|
|
const body = snapshot.body || {};
|
|
return [
|
|
{ label: "HTTP Version", value: snapshot.http_version || "未记录" },
|
|
{ label: "Scheme", value: snapshot.scheme || "未记录" },
|
|
{ label: "Client Socket", value: [client.host, client.port].filter(Boolean).join(":") || "未记录" },
|
|
{ label: "Content-Type", value: content.type || "未记录" },
|
|
{ label: "Content-Length", value: content.length || "未记录" },
|
|
{ label: "Body", value: body.captured ? "已采集" : "未采集明文" },
|
|
{ label: "Body Policy", value: body.reason || "body_not_captured_by_audit_policy" },
|
|
];
|
|
};
|
|
|
|
const securityRawRequestBodyLine = (event: SecurityAccessLogItem) => {
|
|
const body = securityRequestSnapshot(event).body || {};
|
|
if (body.captured) return "[request body captured]";
|
|
return `[request body not captured: ${body.reason || "body_not_captured_by_audit_policy"}]`;
|
|
};
|
|
|
|
const buildSecurityRawRequestBlock = (event: SecurityAccessLogItem) => {
|
|
if (!hasSecurityRequestSnapshot(event) && !event.request_headers) {
|
|
return [
|
|
"# 历史日志未采集原始 HTTP 请求",
|
|
`# request: ${securityRequestMethod(event)} ${securityRequestTarget(event)}`,
|
|
`# created_at: ${formatTime(event.created_at)}`,
|
|
"# Method/Path 为结构化安全事件字段,Headers 与 Body 无法追溯",
|
|
].join("\n");
|
|
}
|
|
|
|
const snapshot = securityRequestSnapshot(event);
|
|
const version = snapshot.http_version ? `HTTP/${snapshot.http_version}` : "HTTP";
|
|
const lines = [`${securityRequestMethod(event)} ${securityRequestTarget(event)} ${version}`.trim()];
|
|
const headers = securityRequestHeaderEntries(event);
|
|
if (headers.length) {
|
|
headers.forEach(([name, value]) => lines.push(`${name}: ${value}`));
|
|
} else if (event.user_agent) {
|
|
lines.push(`user-agent: ${event.user_agent}`);
|
|
}
|
|
lines.push("");
|
|
lines.push(securityRawRequestBodyLine(event));
|
|
return lines.join("\n");
|
|
};
|
|
|
|
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 buildSecurityTerminalLine = (event: SecurityAccessLogItem) => {
|
|
const ip = event.client_ip || "未知 IP";
|
|
const account = event.account_label || "未知账号";
|
|
const auth = `${event.auth_status}/${authLabel(event.auth_status)}`;
|
|
const client = [event.client_type, event.client_version, event.client_platform].filter(Boolean).join("/") || "unknown";
|
|
const userAgent = event.user_agent || "-";
|
|
return `${formatTime(event.created_at)} [${severityLabel(event.severity)}:${categoryLabel(event.category)}] status=${event.status_code} auth=${auth} account=${account} client=${client} ip=${ip} loc=${formatIpLocation(event)} method=${securityRequestMethod(event)} path=${securityRequestTarget(event)} ua=${userAgent} cost=${event.elapsed_ms.toFixed(1)}ms`;
|
|
};
|
|
|
|
const securityTerminalLines = computed(() =>
|
|
[...allSecurityEvents.value]
|
|
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
|
.map(buildSecurityTerminalLine),
|
|
);
|
|
|
|
const paginatedEvents = computed(() => statsEvents.value);
|
|
|
|
const metricCards = computed<Array<{ label: string; value: string; tone: MetricTone; icon: any }>>(() => {
|
|
const highRiskCount = securitySummary.value.high_risk_count;
|
|
const serverErrorCount = securitySummary.value.server_error_count;
|
|
return [
|
|
{ label: "安全事件", value: formatNumber(securitySummary.value.total_count), tone: "blue", icon: MetricIconShield },
|
|
{ label: "风险来源 IP", value: formatNumber(securitySummary.value.unique_ip_count), 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(() => {
|
|
return securitySummary.value.top_ips.map((item) => ({
|
|
ip: item.ip,
|
|
location: item.location || "未知位置",
|
|
categoryText: categoryLabel(item.category),
|
|
total: item.total_count,
|
|
highRiskCount: item.high_risk_count,
|
|
lastSeenAt: item.last_seen_at || "",
|
|
}));
|
|
});
|
|
|
|
const topRiskPreview = computed(() => ipRiskStats.value.slice(0, 3));
|
|
|
|
const endpointTypeStats = computed(() => {
|
|
const items = Object.entries(securitySummary.value.endpoint_type_counts).map(([type, value]) => ({
|
|
type,
|
|
total: value.count,
|
|
samplePath: value.sample_path,
|
|
}));
|
|
const maxTotal = Math.max(1, ...items.map((item) => item.total));
|
|
return items
|
|
.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: securitySummary.value.severity_counts[item.value] || 0,
|
|
}));
|
|
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_EXPORT_PAGE_SIZE = 200;
|
|
|
|
const buildTimeRangeFromPreset = () => {
|
|
const option = timeRangeOptions.find((item) => item.value === timeRangePreset.value);
|
|
if (!option?.hours) return {};
|
|
const end = new Date();
|
|
const start = new Date(end.getTime() - option.hours * 60 * 60 * 1000);
|
|
return { start_time: start.toISOString(), end_time: end.toISOString() };
|
|
};
|
|
|
|
const securityQueryParams = (overrides: Record<string, unknown> = {}) => ({
|
|
events_only: true,
|
|
auth_status: authFilter.value || undefined,
|
|
category: categoryFilter.value || undefined,
|
|
severity: severityFilter.value || undefined,
|
|
keyword: keyword.value.trim() || undefined,
|
|
page: page.value,
|
|
page_size: pageSize.value,
|
|
...buildTimeRangeFromPreset(),
|
|
...overrides,
|
|
});
|
|
|
|
const loadSecurityEvents = async () => {
|
|
loading.value = true;
|
|
try {
|
|
await loadSecurityStats();
|
|
loadError.value = "";
|
|
lastUpdatedAt.value = new Date();
|
|
} catch {
|
|
loadError.value = "安全事件加载失败,页面中可能是上次成功获取的数据";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const loadSecurityStats = async () => {
|
|
const response = await fetchSecurityAccessLogs(securityQueryParams());
|
|
statsEvents.value = response.data.items;
|
|
securityTotal.value = response.data.total;
|
|
securitySummary.value = response.data.summary;
|
|
};
|
|
|
|
const fetchAllSecurityEvents = async () => {
|
|
const first = await fetchSecurityAccessLogs(
|
|
securityQueryParams({ page: 1, page_size: SECURITY_EXPORT_PAGE_SIZE }),
|
|
);
|
|
const items = [...first.data.items];
|
|
const totalPages = Math.ceil(first.data.total / SECURITY_EXPORT_PAGE_SIZE);
|
|
for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {
|
|
const response = await fetchSecurityAccessLogs(
|
|
securityQueryParams({ page: nextPage, page_size: SECURITY_EXPORT_PAGE_SIZE }),
|
|
);
|
|
items.push(...response.data.items);
|
|
}
|
|
return items;
|
|
};
|
|
|
|
const scrollSecurityTerminalToBottom = () => {
|
|
nextTick(() => {
|
|
const terminal = securityTerminalWindowRef.value;
|
|
if (!terminal) return;
|
|
terminal.scrollTop = terminal.scrollHeight;
|
|
});
|
|
};
|
|
|
|
const openSecurityLogDialog = async () => {
|
|
securityLogDialogVisible.value = true;
|
|
securityLogDialogLoading.value = true;
|
|
securityLogDialogError.value = "";
|
|
try {
|
|
allSecurityEvents.value = await fetchAllSecurityEvents();
|
|
scrollSecurityTerminalToBottom();
|
|
} catch {
|
|
securityLogDialogError.value = "安全日志加载失败,请稍后重试";
|
|
} finally {
|
|
securityLogDialogLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const downloadSecurityLog = async () => {
|
|
const blob = new Blob([`${securityTerminalLines.value.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
|
|
await saveFileWithFeedback(
|
|
{ suggestedName: "security-access-logs.log", mimeType: "text/plain;charset=utf-8", data: blob },
|
|
{
|
|
kind: "export",
|
|
title: "导出安全日志",
|
|
completedDetail: "安全日志文件已保存",
|
|
},
|
|
);
|
|
};
|
|
|
|
const onFilterChange = () => {
|
|
page.value = 1;
|
|
loadSecurityEvents();
|
|
};
|
|
|
|
const onTimeRangeChange = () => {
|
|
page.value = 1;
|
|
loadSecurityEvents();
|
|
};
|
|
|
|
const onPageSizeChange = (size: number) => {
|
|
pageSize.value = size;
|
|
page.value = 1;
|
|
loadSecurityEvents();
|
|
};
|
|
|
|
watch(keyword, () => {
|
|
page.value = 1;
|
|
if (keywordTimer.value) window.clearTimeout(keywordTimer.value);
|
|
keywordTimer.value = window.setTimeout(() => {
|
|
keywordTimer.value = null;
|
|
loadSecurityEvents();
|
|
}, 350);
|
|
});
|
|
|
|
const openDetail = (event: SecurityAccessLogItem) => {
|
|
selectedEvent.value = event;
|
|
detailVisible.value = true;
|
|
};
|
|
|
|
const openAnalysisDialog = () => {
|
|
analysisDialogVisible.value = true;
|
|
};
|
|
|
|
const jumpToSecurityEvents = async () => {
|
|
analysisDialogVisible.value = false;
|
|
await nextTick();
|
|
securityEventsSectionRef.value?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
};
|
|
|
|
onMounted(loadSecurityEvents);
|
|
|
|
onBeforeUnmount(() => {
|
|
if (keywordTimer.value) window.clearTimeout(keywordTimer.value);
|
|
});
|
|
|
|
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: 12px;
|
|
--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: 8px;
|
|
}
|
|
|
|
.security-overview {
|
|
padding: 8px 12px 6px;
|
|
border: 1px solid var(--audit-border);
|
|
border-radius: var(--card-radius);
|
|
background: #fff;
|
|
box-shadow: var(--card-shadow);
|
|
}
|
|
|
|
.security-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
min-height: 32px;
|
|
padding: 0 0 7px;
|
|
border-bottom: 1px solid #edf1f6;
|
|
}
|
|
|
|
.security-toolbar-title h3 {
|
|
margin: 0;
|
|
color: var(--audit-ink);
|
|
font-size: 15px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.security-toolbar-title p {
|
|
margin: 3px 0 0;
|
|
color: var(--audit-muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.security-toolbar-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
}
|
|
|
|
.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: 0;
|
|
padding-top: 5px;
|
|
}
|
|
|
|
.metric-card {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
min-height: 44px;
|
|
padding: 5px 12px;
|
|
border-left: 1px solid #edf1f6;
|
|
}
|
|
|
|
.metric-card:first-child {
|
|
border-left: 0;
|
|
}
|
|
|
|
.metric-card::before {
|
|
display: none;
|
|
}
|
|
|
|
.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: 28px;
|
|
height: 28px;
|
|
border-radius: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.metric-icon svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
|
|
.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: 1px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.metric-label {
|
|
color: var(--audit-muted);
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.metric-value {
|
|
color: var(--audit-ink);
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.security-analysis-card {
|
|
min-width: 0;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--audit-border);
|
|
border-radius: var(--card-radius);
|
|
background: #fff;
|
|
box-shadow: var(--card-shadow);
|
|
}
|
|
|
|
.analysis-dialog-panel {
|
|
min-width: 0;
|
|
padding: 20px;
|
|
border: 1px solid var(--audit-border);
|
|
border-radius: var(--card-radius);
|
|
background: #fff;
|
|
box-shadow: var(--card-shadow);
|
|
}
|
|
|
|
.security-table-wrap {
|
|
min-width: 0;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
border: 1px solid var(--audit-border);
|
|
border-radius: var(--card-radius);
|
|
background: #fff;
|
|
box-shadow: var(--card-shadow);
|
|
}
|
|
|
|
.security-table {
|
|
--el-table-header-bg-color: #f8fafc;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.security-table :deep(.el-table__cell) {
|
|
padding: 5px 0;
|
|
}
|
|
|
|
.security-table :deep(.el-table__header .cell) {
|
|
color: #64748b;
|
|
font-size: 11.5px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.security-table :deep(.cell) {
|
|
padding: 0 8px;
|
|
}
|
|
|
|
.security-table :deep(.el-scrollbar__wrap) {
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.security-table :deep(.el-scrollbar__bar.is-horizontal) {
|
|
display: none;
|
|
}
|
|
|
|
.time-cell,
|
|
.ip-cell,
|
|
.location-cell,
|
|
.elapsed-cell-strong {
|
|
color: #475569;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.analysis-summary-head {
|
|
align-items: center;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.analysis-summary-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.analysis-summary-body {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.8fr);
|
|
gap: 8px;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.risk-preview-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.risk-preview-row {
|
|
display: grid;
|
|
grid-template-columns: 34px minmax(0, 1fr) auto;
|
|
gap: 10px;
|
|
align-items: center;
|
|
min-height: 36px;
|
|
padding: 4px 6px;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.risk-preview-row:hover {
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.risk-preview-main,
|
|
.risk-preview-count {
|
|
display: flex;
|
|
min-width: 0;
|
|
}
|
|
|
|
.risk-preview-main {
|
|
align-items: baseline;
|
|
gap: 8px;
|
|
}
|
|
|
|
.risk-preview-main strong {
|
|
flex: 0 0 auto;
|
|
color: var(--audit-ink);
|
|
font-size: 13px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.risk-preview-main span {
|
|
overflow: hidden;
|
|
color: var(--audit-muted);
|
|
font-size: 12px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.risk-preview-count {
|
|
align-items: flex-end;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
}
|
|
|
|
.risk-preview-count strong {
|
|
color: var(--audit-ink);
|
|
font-size: 16px;
|
|
}
|
|
|
|
.risk-preview-count small {
|
|
color: var(--audit-muted);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.risk-preview-count .denied-highlight {
|
|
color: var(--audit-danger);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.risk-summary-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 7px;
|
|
padding-left: 12px;
|
|
border-left: 1px solid var(--audit-border);
|
|
}
|
|
|
|
.risk-summary-title {
|
|
color: var(--audit-ink);
|
|
font-size: 13px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.analysis-dialog-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(340px, 1fr) minmax(0, 1.15fr);
|
|
gap: 16px;
|
|
}
|
|
|
|
.analysis-dialog-panel {
|
|
box-shadow: none;
|
|
}
|
|
|
|
.analysis-ranking-dialog {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.analysis-ranking-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
color: var(--audit-muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.endpoint-summary-chips {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
|
|
.endpoint-summary-chips span {
|
|
padding: 5px 9px;
|
|
border-radius: 999px;
|
|
background: #f8fafc;
|
|
color: var(--audit-muted);
|
|
}
|
|
|
|
.endpoint-summary-chips strong {
|
|
margin-left: 3px;
|
|
color: var(--audit-ink);
|
|
}
|
|
|
|
.analysis-rank-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.analysis-rank-card {
|
|
display: flex;
|
|
min-width: 0;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
padding: 10px;
|
|
border: 1px solid #dbeafe;
|
|
border-radius: 8px;
|
|
background: #f8fafc;
|
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
|
}
|
|
|
|
.analysis-rank-card.is-high-risk {
|
|
border-color: #fecaca;
|
|
background: #fff7f7;
|
|
}
|
|
|
|
.analysis-rank-card:hover {
|
|
border-color: #cbd5e1;
|
|
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.06);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.analysis-rank-card-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 6px;
|
|
}
|
|
|
|
.analysis-rank-card-ip {
|
|
display: flex;
|
|
min-width: 0;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.analysis-rank-card-ip strong,
|
|
.analysis-rank-card-ip span,
|
|
.analysis-rank-card-meta {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.analysis-rank-card-ip strong {
|
|
color: var(--audit-ink);
|
|
font-size: 13.5px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.analysis-rank-card-ip span,
|
|
.analysis-rank-card-meta {
|
|
color: var(--audit-muted);
|
|
font-size: 10.5px;
|
|
}
|
|
|
|
.analysis-rank-card-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 6px;
|
|
}
|
|
|
|
.analysis-rank-card-stats div {
|
|
min-width: 0;
|
|
padding: 6px 7px;
|
|
border-radius: 7px;
|
|
background: rgba(248, 250, 252, 0.9);
|
|
}
|
|
|
|
.analysis-rank-card-stats span {
|
|
display: block;
|
|
color: var(--audit-muted);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.analysis-rank-card-stats strong {
|
|
display: block;
|
|
margin-top: 2px;
|
|
color: var(--audit-ink);
|
|
font-size: 15px;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.analysis-rank-card-stats .denied-highlight {
|
|
color: var(--audit-danger);
|
|
}
|
|
|
|
.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: 0;
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid var(--audit-border);
|
|
}
|
|
|
|
.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 {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.risk-level-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.pagination-wrap {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-top: 0;
|
|
padding: 8px 12px;
|
|
border-top: 1px solid var(--audit-border);
|
|
}
|
|
|
|
.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-log-dialog-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
width: 100%;
|
|
}
|
|
|
|
.security-log-dialog-head strong {
|
|
color: var(--audit-ink);
|
|
font-size: 17px;
|
|
}
|
|
|
|
.analysis-dialog-title {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
}
|
|
|
|
.analysis-dialog-title small {
|
|
color: var(--audit-muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.security-log-dialog-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.terminal-window {
|
|
min-height: 360px;
|
|
max-height: 520px;
|
|
overflow: auto;
|
|
padding: 10px;
|
|
border: 1px solid #1e293b;
|
|
border-radius: 10px;
|
|
background: linear-gradient(180deg, #1e293b, #0f172a);
|
|
}
|
|
|
|
.dialog-terminal-window {
|
|
height: 62vh;
|
|
min-height: 360px;
|
|
max-height: 68vh;
|
|
}
|
|
|
|
.terminal-lines {
|
|
margin: 0;
|
|
color: #e2e8f0;
|
|
font-family: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Menlo", monospace;
|
|
font-size: 11.5px;
|
|
line-height: 1.55;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.security-detail,
|
|
.detail-stack {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
}
|
|
|
|
.detail-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.detail-card,
|
|
.detail-section {
|
|
min-width: 0;
|
|
padding: 14px;
|
|
border: 1px solid var(--audit-border);
|
|
border-radius: 12px;
|
|
background: #fff;
|
|
}
|
|
|
|
.detail-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.detail-card span,
|
|
.detail-section h4 {
|
|
margin: 0;
|
|
color: var(--audit-muted);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.detail-card strong {
|
|
color: var(--audit-ink);
|
|
font-size: 15px;
|
|
}
|
|
|
|
.detail-card small,
|
|
.detail-section p {
|
|
margin: 0;
|
|
color: #475569;
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.detail-section h4 {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.detail-request-line {
|
|
margin-bottom: 10px !important;
|
|
padding: 9px 10px;
|
|
border-radius: 8px;
|
|
background: #f8fafc;
|
|
color: var(--audit-ink) !important;
|
|
font-family: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Menlo", monospace;
|
|
font-size: 12px !important;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.detail-field-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.detail-field-item {
|
|
min-width: 0;
|
|
padding: 8px 10px;
|
|
border-radius: 8px;
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.detail-field-item span {
|
|
display: block;
|
|
margin-bottom: 3px;
|
|
color: var(--audit-muted);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.detail-field-item strong {
|
|
display: block;
|
|
overflow-wrap: anywhere;
|
|
color: var(--audit-ink);
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.detail-raw-line {
|
|
margin: 0;
|
|
overflow: auto;
|
|
padding: 10px;
|
|
border-radius: 8px;
|
|
background: #0f172a;
|
|
color: #e2e8f0;
|
|
font-family: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Menlo", monospace;
|
|
font-size: 11.5px;
|
|
line-height: 1.55;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.detail-more {
|
|
border-top: 1px solid var(--audit-border);
|
|
border-bottom: 1px solid var(--audit-border);
|
|
}
|
|
|
|
.detail-more :deep(.el-collapse-item__header) {
|
|
height: 42px;
|
|
color: #475569;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.detail-more :deep(.el-collapse-item__content) {
|
|
padding-bottom: 4px;
|
|
}
|
|
|
|
.detail-subsection {
|
|
padding: 10px 0 14px;
|
|
}
|
|
|
|
.detail-subsection + .detail-subsection {
|
|
border-top: 1px solid #edf2f7;
|
|
}
|
|
|
|
.detail-subsection h5 {
|
|
margin: 0 0 8px;
|
|
color: var(--audit-muted);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.security-analysis-card .risk-level-list {
|
|
gap: 5px;
|
|
}
|
|
|
|
.security-analysis-card .risk-level-row {
|
|
min-height: 18px;
|
|
}
|
|
|
|
@media (max-width: 1100px) {
|
|
.audit-metrics {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.metric-card:nth-child(odd) {
|
|
border-left: 0;
|
|
}
|
|
|
|
.metric-card:nth-child(n + 3) {
|
|
border-top: 1px solid #edf1f6;
|
|
}
|
|
|
|
.analysis-summary-body,
|
|
.analysis-dialog-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.analysis-rank-grid {
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
}
|
|
|
|
.risk-summary-panel {
|
|
padding-top: 14px;
|
|
padding-left: 0;
|
|
border-top: 1px solid var(--audit-border);
|
|
border-left: 0;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.analysis-rank-grid {
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.security-toolbar,
|
|
.security-toolbar-actions {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.security-toolbar-actions {
|
|
width: 100%;
|
|
}
|
|
|
|
.analysis-ranking-head {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.endpoint-summary-chips {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.analysis-rank-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.audit-metrics {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.security-analysis-card {
|
|
padding: 10px;
|
|
}
|
|
|
|
.analysis-summary-head,
|
|
.analysis-summary-actions,
|
|
.risk-preview-main {
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.analysis-summary-head,
|
|
.risk-preview-main {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.analysis-summary-actions {
|
|
width: 100%;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.risk-preview-main {
|
|
gap: 2px;
|
|
}
|
|
|
|
.risk-preview-main span {
|
|
width: 100%;
|
|
}
|
|
|
|
.security-search {
|
|
width: 100%;
|
|
}
|
|
|
|
.detail-grid,
|
|
.detail-field-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.distribution-row {
|
|
grid-template-columns: 1fr 44px;
|
|
}
|
|
|
|
.distribution-meter {
|
|
grid-column: 1 / -1;
|
|
grid-row: 2;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 520px) {
|
|
.audit-metrics {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.metric-card {
|
|
border-top: 1px solid #edf1f6;
|
|
border-left: 0;
|
|
}
|
|
|
|
.metric-card:first-child {
|
|
border-top: 0;
|
|
}
|
|
|
|
.analysis-rank-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|