feat(监控): 完善系统监控、登录状态与访问审计能力
This commit is contained in:
@@ -3,50 +3,72 @@
|
||||
<div class="trend-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">系统性能趋势</span>
|
||||
<span v-if="lastUpdatedAt" class="ctms-updated-at">最近成功更新:{{ formatLastUpdated(lastUpdatedAt) }}</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<TimeRangeSelector v-model="timeRangePreset" @change="loadData" />
|
||||
<el-button size="small" :loading="loading" @click="loadData">刷新</el-button>
|
||||
</div>
|
||||
<el-radio-group v-model="period" size="small" @change="loadData">
|
||||
<el-radio-button value="24h">24小时</el-radio-button>
|
||||
<el-radio-button value="7d">7天</el-radio-button>
|
||||
<el-radio-button value="30d">30天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="loadError" :title="loadError" type="warning" show-icon :closable="false" class="load-alert" />
|
||||
|
||||
<div v-loading="loading" class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<div class="chart-card" :class="{ 'is-empty': !hasEventSamples }">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon blue">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
</div>
|
||||
<h4>监测事件趋势</h4>
|
||||
<div class="chart-title-group">
|
||||
<h4>监测事件趋势</h4>
|
||||
<span>允许与拒绝</span>
|
||||
</div>
|
||||
<strong class="chart-latest">{{ latestEventLabel }}</strong>
|
||||
</div>
|
||||
<v-chart :option="checksChartOption" autoresize class="chart" />
|
||||
<v-chart v-if="hasEventSamples" :option="checksChartOption" autoresize class="chart" />
|
||||
<div v-else class="trend-empty-state">所选时段暂无监测事件</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-card" :class="{ 'is-empty': !hasResponseSamples }">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon cyan">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</div>
|
||||
<h4>响应时间趋势</h4>
|
||||
<div class="chart-title-group">
|
||||
<h4>响应时间趋势</h4>
|
||||
<span>平均值与最大值</span>
|
||||
</div>
|
||||
<strong class="chart-latest">{{ latestResponseLabel }}</strong>
|
||||
</div>
|
||||
<v-chart :option="responseTimeOption" autoresize class="chart" />
|
||||
<v-chart v-if="hasResponseSamples" :option="responseTimeOption" autoresize class="chart" />
|
||||
<div v-else class="trend-empty-state">暂无有效响应时间样本</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-card" :class="{ 'is-empty': !hasCacheSamples }">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon green">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<h4>缓存命中率</h4>
|
||||
<div class="chart-title-group">
|
||||
<h4>缓存命中率</h4>
|
||||
<span>命中 / 总访问</span>
|
||||
</div>
|
||||
<strong class="chart-latest">{{ latestCacheLabel }}</strong>
|
||||
</div>
|
||||
<v-chart :option="cacheHitOption" autoresize class="chart" />
|
||||
<v-chart v-if="hasCacheSamples" :option="cacheHitOption" autoresize class="chart" />
|
||||
<div v-else class="trend-empty-state">暂无缓存访问样本</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-card" :class="{ 'is-empty': !hasDenySamples }">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon danger">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
|
||||
</div>
|
||||
<h4>异常拒绝率趋势</h4>
|
||||
<div class="chart-title-group">
|
||||
<h4>异常拒绝率趋势</h4>
|
||||
<span>拒绝事件占比</span>
|
||||
</div>
|
||||
<strong class="chart-latest">{{ latestDenyLabel }}</strong>
|
||||
</div>
|
||||
<v-chart :option="denyRateOption" autoresize class="chart" />
|
||||
<v-chart v-if="hasDenySamples" :option="denyRateOption" autoresize class="chart" />
|
||||
<div v-else class="trend-empty-state">暂无权限检查样本</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,21 +88,65 @@ import {
|
||||
} from "echarts/components";
|
||||
import { fetchPermissionTrends } from "@/api/projectPermissions";
|
||||
import type { TrendDataPoint } from "@/types/api";
|
||||
import TimeRangeSelector from "./TimeRangeSelector.vue";
|
||||
|
||||
use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent]);
|
||||
|
||||
const period = ref<"24h" | "7d" | "30d">("24h");
|
||||
type TimeRangePreset = "all" | "24h" | "7d" | "30d";
|
||||
const timeRangePreset = ref<TimeRangePreset>("24h");
|
||||
const timeRangeOptions = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "24h", label: "24小时" },
|
||||
{ value: "7d", label: "7天" },
|
||||
{ value: "30d", label: "30天" },
|
||||
] as const;
|
||||
const loading = ref(false);
|
||||
const dataPoints = ref<TrendDataPoint[]>([]);
|
||||
const loadError = ref("");
|
||||
const lastUpdatedAt = ref<Date | null>(null);
|
||||
|
||||
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 formatTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (period.value === "24h") return `${d.getHours()}:00`;
|
||||
if (timeRangePreset.value === "24h") return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:00`;
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
};
|
||||
|
||||
const xLabels = computed(() => dataPoints.value.map((p: TrendDataPoint) => formatTime(p.bucket_time)));
|
||||
|
||||
const latestPoint = computed(() => dataPoints.value.at(-1) || null);
|
||||
const hasEventSamples = computed(() => dataPoints.value.some((point) => point.total_checks > 0));
|
||||
const hasResponseSamples = computed(() =>
|
||||
dataPoints.value.some((point) => point.avg_elapsed_ms > 0 || point.max_elapsed_ms > 0)
|
||||
);
|
||||
const hasCacheSamples = computed(() =>
|
||||
dataPoints.value.some((point) => point.cache_hits + point.cache_misses > 0)
|
||||
);
|
||||
const hasDenySamples = computed(() => dataPoints.value.some((point) => point.total_checks > 0));
|
||||
|
||||
const latestEventLabel = computed(() =>
|
||||
latestPoint.value && latestPoint.value.total_checks > 0
|
||||
? `${latestPoint.value.total_checks} 次`
|
||||
: "暂无样本"
|
||||
);
|
||||
const latestResponseLabel = computed(() =>
|
||||
latestPoint.value && (latestPoint.value.avg_elapsed_ms > 0 || latestPoint.value.max_elapsed_ms > 0)
|
||||
? `${latestPoint.value.avg_elapsed_ms.toFixed(1)} ms`
|
||||
: "暂无样本"
|
||||
);
|
||||
const latestCacheLabel = computed(() =>
|
||||
latestPoint.value && latestPoint.value.cache_hits + latestPoint.value.cache_misses > 0
|
||||
? `${latestPoint.value.cache_hit_rate.toFixed(1)}%`
|
||||
: "暂无样本"
|
||||
);
|
||||
const latestDenyLabel = computed(() => {
|
||||
if (!latestPoint.value || latestPoint.value.total_checks <= 0) return "暂无样本";
|
||||
return `${((latestPoint.value.denied_checks / latestPoint.value.total_checks) * 100).toFixed(1)}%`;
|
||||
});
|
||||
|
||||
const baseGridOption = {
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 25 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
@@ -186,7 +252,7 @@ const denyRateOption = computed(() => ({
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) =>
|
||||
p.total_checks > 0 ? Math.round((p.denied_checks / p.total_checks) * 1000) / 10 : 0
|
||||
p.total_checks > 0 ? Math.round((p.denied_checks / p.total_checks) * 1000) / 10 : null
|
||||
),
|
||||
itemStyle: { color: "#ef4444" },
|
||||
lineStyle: { width: 2.5 },
|
||||
@@ -198,10 +264,15 @@ const denyRateOption = computed(() => ({
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetchPermissionTrends(period.value);
|
||||
dataPoints.value = res.data.data_points;
|
||||
const apiPeriod = timeRangePreset.value === "all" ? "30d" : timeRangePreset.value;
|
||||
const res = await fetchPermissionTrends(apiPeriod);
|
||||
dataPoints.value = [...res.data.data_points].sort(
|
||||
(left, right) => new Date(left.bucket_time).getTime() - new Date(right.bucket_time).getTime(),
|
||||
);
|
||||
loadError.value = "";
|
||||
lastUpdatedAt.value = new Date();
|
||||
} catch {
|
||||
dataPoints.value = [];
|
||||
loadError.value = "趋势数据加载失败,图表中可能是上次成功获取的数据";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -217,16 +288,21 @@ defineExpose({ refresh: loadData });
|
||||
--trend-ink: #1a2332;
|
||||
--trend-muted: #64748b;
|
||||
--trend-border: #e2e8f0;
|
||||
--trend-radius: 16px;
|
||||
--trend-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
--trend-radius: 14px;
|
||||
--trend-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 6px 18px rgba(15, 23, 42, 0.025);
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0 12px 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.trend-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
min-height: 48px;
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--trend-border);
|
||||
border-radius: var(--trend-radius);
|
||||
@@ -235,8 +311,8 @@ defineExpose({ refresh: loadData });
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
@@ -245,22 +321,37 @@ defineExpose({ refresh: loadData });
|
||||
color: var(--trend-ink);
|
||||
}
|
||||
|
||||
.toolbar-desc {
|
||||
font-size: 12px;
|
||||
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-updated-at {
|
||||
color: var(--trend-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.load-alert {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--trend-border);
|
||||
border-radius: var(--trend-radius);
|
||||
padding: 18px;
|
||||
min-height: 276px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: var(--trend-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
@@ -274,22 +365,22 @@ defineExpose({ refresh: loadData });
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.chart-icon.blue { background: #eff6ff; color: #3b82f6; }
|
||||
@@ -304,20 +395,80 @@ defineExpose({ refresh: loadData });
|
||||
color: var(--trend-ink);
|
||||
}
|
||||
|
||||
.chart-title-group {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chart-title-group span {
|
||||
color: var(--trend-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chart-latest {
|
||||
padding: 4px 7px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: var(--trend-ink);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 240px;
|
||||
height: 212px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 212px;
|
||||
border: 1px dashed #dbe3ef;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.trend-toolbar {
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
min-height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.trend-charts {
|
||||
padding: 0 8px 10px;
|
||||
}
|
||||
|
||||
.trend-toolbar,
|
||||
.toolbar-left {
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user