Files
ctms/frontend/src/components/PermissionTrendCharts.vue
T
Cheng Zhou f11a5c84d9
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
feat(监控): 完善系统监控、登录状态与访问审计能力
2026-07-10 17:12:13 +08:00

475 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="trend-charts">
<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>
</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" :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>
<div class="chart-title-group">
<h4>监测事件趋势</h4>
<span>允许与拒绝</span>
</div>
<strong class="chart-latest">{{ latestEventLabel }}</strong>
</div>
<v-chart v-if="hasEventSamples" :option="checksChartOption" autoresize class="chart" />
<div v-else class="trend-empty-state">所选时段暂无监测事件</div>
</div>
<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>
<div class="chart-title-group">
<h4>响应时间趋势</h4>
<span>平均值与最大值</span>
</div>
<strong class="chart-latest">{{ latestResponseLabel }}</strong>
</div>
<v-chart v-if="hasResponseSamples" :option="responseTimeOption" autoresize class="chart" />
<div v-else class="trend-empty-state">暂无有效响应时间样本</div>
</div>
<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>
<div class="chart-title-group">
<h4>缓存命中率</h4>
<span>命中 / 总访问</span>
</div>
<strong class="chart-latest">{{ latestCacheLabel }}</strong>
</div>
<v-chart v-if="hasCacheSamples" :option="cacheHitOption" autoresize class="chart" />
<div v-else class="trend-empty-state">暂无缓存访问样本</div>
</div>
<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>
<div class="chart-title-group">
<h4>异常拒绝率趋势</h4>
<span>拒绝事件占比</span>
</div>
<strong class="chart-latest">{{ latestDenyLabel }}</strong>
</div>
<v-chart v-if="hasDenySamples" :option="denyRateOption" autoresize class="chart" />
<div v-else class="trend-empty-state">暂无权限检查样本</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import VChart from "vue-echarts";
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { LineChart, BarChart } from "echarts/charts";
import {
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
} 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]);
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 (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 },
};
const checksChartOption = computed(() => ({
grid: { left: 50, right: 20, top: 20, bottom: 50 },
tooltip: { trigger: "axis" as const },
legend: { data: ["允许", "拒绝"], bottom: 0, itemGap: 20 },
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
yAxis: { type: "value", minInterval: 1, splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [
{
name: "允许",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => p.allowed_checks),
itemStyle: { color: "#10b981" },
lineStyle: { width: 2.5 },
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(16,185,129,0.2)" }, { offset: 1, color: "rgba(16,185,129,0)" }] } },
},
{
name: "拒绝",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => p.denied_checks),
itemStyle: { color: "#ef4444" },
lineStyle: { width: 2.5 },
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(239,68,68,0.15)" }, { offset: 1, color: "rgba(239,68,68,0)" }] } },
},
],
}));
const responseTimeOption = computed(() => ({
grid: { left: 50, right: 20, top: 20, bottom: 50 },
tooltip: { trigger: "axis" as const },
legend: { data: ["平均响应时间", "最大响应时间"], bottom: 0, itemGap: 20 },
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
yAxis: { type: "value", name: "ms", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [
{
name: "平均响应时间",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => p.avg_elapsed_ms),
itemStyle: { color: "#3b82f6" },
lineStyle: { width: 2.5 },
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(59,130,246,0.15)" }, { offset: 1, color: "rgba(59,130,246,0)" }] } },
},
{
name: "最大响应时间",
type: "line",
smooth: true,
symbol: "diamond",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => p.max_elapsed_ms),
itemStyle: { color: "#f59e0b" },
lineStyle: { width: 2, type: "dashed" },
},
],
}));
const cacheHitOption = computed(() => ({
grid: { left: 50, right: 20, top: 20, bottom: 25 },
tooltip: { trigger: "axis" as const },
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [
{
name: "缓存命中率",
type: "bar",
barMaxWidth: 24,
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: (params: any) => {
const val = params.value;
if (val >= 80) return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#34d399" }, { offset: 1, color: "#10b981" }] };
if (val >= 50) return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#fbbf24" }, { offset: 1, color: "#f59e0b" }] };
return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#f87171" }, { offset: 1, color: "#ef4444" }] };
},
},
data: dataPoints.value.map((p: TrendDataPoint) => p.cache_hit_rate),
},
],
}));
const denyRateOption = computed(() => ({
grid: { left: 50, right: 20, top: 20, bottom: 25 },
tooltip: { trigger: "axis" as const },
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [
{
name: "异常拒绝率",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) =>
p.total_checks > 0 ? Math.round((p.denied_checks / p.total_checks) * 1000) / 10 : null
),
itemStyle: { color: "#ef4444" },
lineStyle: { width: 2.5 },
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(239,68,68,0.2)" }, { offset: 1, color: "rgba(239,68,68,0)" }] } },
},
],
}));
const loadData = async () => {
loading.value = true;
try {
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 {
loadError.value = "趋势数据加载失败,图表中可能是上次成功获取的数据";
} finally {
loading.value = false;
}
};
onMounted(loadData);
defineExpose({ refresh: loadData });
</script>
<style scoped>
.trend-charts {
--trend-ink: #1a2332;
--trend-muted: #64748b;
--trend-border: #e2e8f0;
--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;
min-height: 48px;
padding: 8px 14px;
margin-bottom: 8px;
background: #fff;
border: 1px solid var(--trend-border);
border-radius: var(--trend-radius);
box-shadow: var(--trend-shadow);
}
.toolbar-left {
display: flex;
align-items: baseline;
gap: 8px;
}
.toolbar-title {
font-size: 15px;
font-weight: 600;
color: var(--trend-ink);
}
.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: 10px;
}
.load-alert {
margin-bottom: 8px;
}
.chart-card {
background: #fff;
border: 1px solid var(--trend-border);
border-radius: var(--trend-radius);
min-height: 276px;
padding: 12px 14px;
box-shadow: var(--trend-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.chart-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.07);
}
.chart-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.chart-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
flex-shrink: 0;
}
.chart-icon svg {
width: 14px;
height: 14px;
}
.chart-icon.blue { background: #eff6ff; color: #3b82f6; }
.chart-icon.cyan { background: #ecfeff; color: #06b6d4; }
.chart-icon.green { background: #ecfdf5; color: #10b981; }
.chart-icon.danger { background: #fef2f2; color: #ef4444; }
.chart-card h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
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: 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-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;
flex-direction: column;
}
.toolbar-actions {
width: 100%;
justify-content: flex-start;
}
}
</style>