Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ jobs:
tests/unit/api/oauth-authorization-api.test.ts
tests/unit/api/notification-api.test.ts
tests/unit/api/captcha-scene-api.test.ts
tests/unit/views/monitoring-refresh.test.ts
tests/unit/views/gateway-monitoring.test.ts
tests/unit/views/monitoring-detail-layout.test.ts
tests/unit/views/monitoring-history-chart.test.ts

- name: Build production assets
run: pnpm build
Expand Down
7 changes: 6 additions & 1 deletion docs/modules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@
| 网关路由 | `system/gateway-route/` | 路由查看、编辑、校验与发布;必须与服务端权限和 Nacos 配置流程一致。 |
| 路由管理 | `system/gateway/api-routes/` | API 资产、发布声明和应用级路由查看;见 [API 路由管理](gateway-api-routes.md)。 |
| 发布中心 | `system/gateway/releases/` | 汇总全部待发布变更,执行预检、原子发布、实例确认、历史查看与版本回滚。 |
| 运行监控 | `system/gateway/monitoring/` | 展示 Nacos 网关实例、最近发布加载与探测状态,以及固定 PromQL 路由指标。 |
| 运行监控 | `system/gateway/monitoring/` | 展示 Nacos 网关实例、最近发布加载与探测状态,以及固定 PromQL 路由指标。支持自动刷新和趋势时间范围选择。 |
| 开发工具 | `development/`、`codegen/` | API 文档和开发辅助页面。 |
| 个人中心 | `profile/` | 个人资料与消息入口。 |

每个新页面/页面域应增加独立 Markdown,至少包含:介绍、功能、后端 API、权限、交互流程、验证方法和规划。

网关运行监控和服务管理的监控详情默认每30秒刷新,用户可选择关闭、10秒、30秒或1分钟;
服务详情关闭、页面卸载或缓存页面停用后暂停刷新,慢请求期间不叠加刷新请求。
流量与历史趋势支持 `15m`、`30m`、`1h`、`2h`、`6h`、`24h`、`7d`、`30d` 时间范围,
查询步长由后端确定(`30m` 为15秒、`2h` 为1分钟)。
2 changes: 1 addition & 1 deletion src/types/api/gateway-api-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export interface GatewayRouteMetricsSnapshot {
p95Latency: string;
}

export type MonitoringRange = "15m" | "1h" | "6h" | "24h" | "7d" | "30d";
export type MonitoringRange = "15m" | "30m" | "1h" | "2h" | "6h" | "24h" | "7d" | "30d";

export interface MonitoringHistory {
range: MonitoringRange;
Expand Down
25 changes: 20 additions & 5 deletions src/views/system/gateway/monitoring/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,25 @@
<div class="app-container">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="font-medium">网关运行状态</div>
<div class="mt-1 text-xs text-gray-500">
实例来自 Nacos,配置加载和路由探测来自最近一次发布确认。
</div>
</div>
<el-button :loading="loading" @click="load">刷新</el-button>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm">自动刷新</span>
<el-select v-model="refreshInterval" aria-label="自动刷新频率" class="!w-28">
<el-option
v-for="option in refreshOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-button :loading="refreshing" @click="refresh">刷新</el-button>
</div>
</div>
</template>
<el-alert
Expand Down Expand Up @@ -67,7 +78,7 @@
</el-card>
<el-card shadow="never" class="mt-4">
<template #header>
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="font-medium">网关流量趋势</div>
<div class="mt-1 text-xs text-gray-500">TPS、错误流量及 P50/P95/P99 延迟。</div>
Expand Down Expand Up @@ -277,6 +288,7 @@ import type {
} from "@/types/api/gateway-api-route";
import { selectGatewayInstances } from "./service-selection";
import { historyChartOptions } from "./history-chart";
import { rangeOptions, refreshOptions, useMonitoringRefresh } from "./refresh";

defineOptions({ name: "GatewayMonitoring" });
const loading = ref(false);
Expand All @@ -288,7 +300,8 @@ const loadWarnings = ref<string[]>([]);
const selectedRange = ref<MonitoringRange>("1h");
const history = ref<MonitoringHistory>();
const monitoringStatus = ref<MonitoringDataSourceStatus>();
const rangeOptions = ["15m", "1h", "6h", "24h", "7d", "30d"];
const { refreshInterval, refreshing, refresh } = useMonitoringRefresh(load);
let historyRequest = 0;
const routeMetrics = computed(() => {
const merged = new Map<
string,
Expand Down Expand Up @@ -405,10 +418,12 @@ async function load() {
}
}
async function loadHistory() {
const request = ++historyRequest;
const [historyResult, statusResult] = await Promise.allSettled([
GatewayApiRouteAPI.getRouteHistory({ range: selectedRange.value }),
GatewayApiRouteAPI.getMonitoringStatus(),
]);
if (request !== historyRequest) return;
history.value = historyResult.status === "fulfilled" ? historyResult.value : undefined;
monitoringStatus.value =
statusResult.status === "fulfilled"
Expand Down Expand Up @@ -457,7 +472,7 @@ function sourceLabel(row: GatewayInstanceRuntime, key: string) {
)[row.snapshot?.sources?.[key] || ""] || "未知"
);
}
onMounted(load);
onMounted(refresh);
</script>

<style scoped>
Expand Down
46 changes: 46 additions & 0 deletions src/views/system/gateway/monitoring/refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { computed, onActivated, onDeactivated, ref, watch, type Ref } from "vue";
import { useIntervalFn } from "@vueuse/core";
import type { MonitoringRange } from "@/types/api/gateway-api-route";

export const rangeOptions: MonitoringRange[] = ["15m", "30m", "1h", "2h", "6h", "24h", "7d", "30d"];
export const refreshOptions = [
{ label: "关闭", value: 0 },
{ label: "10秒", value: 10_000 },
{ label: "30秒", value: 30_000 },
{ label: "1分钟", value: 60_000 },
];

// VueUse handles timer disposal; this wrapper also guards async requests and cached pages.
export function useMonitoringRefresh(load: () => Promise<void>, enabled: Ref<boolean> = ref(true)) {
const refreshInterval = ref(30_000);
const refreshing = ref(false);
const active = ref(true);

async function refresh() {
if (refreshing.value) return;
refreshing.value = true;
try {
await load();
} finally {
refreshing.value = false;
}
}

const { pause, resume } = useIntervalFn(
() => void refresh().catch(() => undefined),
computed(() => refreshInterval.value || 30_000),
{ immediate: false }
);
watch(
[enabled, active, refreshInterval],
() => {
pause();
if (enabled.value && active.value && refreshInterval.value > 0) resume();
},
{ immediate: true, flush: "sync" }
);
onActivated(() => (active.value = true));
onDeactivated(() => (active.value = false));

return { refreshInterval, refreshing, refresh };
}
54 changes: 49 additions & 5 deletions src/views/system/gateway/services/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@
destroy-on-close
append-to-body
>
<div class="flex flex-wrap items-center justify-end gap-2 mb-4">
<span class="text-sm">自动刷新</span>
<el-select v-model="refreshInterval" aria-label="自动刷新频率" class="!w-28">
<el-option
v-for="option in refreshOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-button :loading="refreshing" @click="refresh">刷新</el-button>
</div>
<el-descriptions v-if="monitoringDialog.service" :column="3" border class="mb-4">
<el-descriptions-item label="服务名称">
{{ monitoringDialog.service.name }}
Expand All @@ -78,7 +90,7 @@
</el-descriptions-item>
</el-descriptions>
<section v-if="monitoringDialog.service" class="monitoring-section">
<div class="flex items-center justify-between mb-2">
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<div class="monitoring-section-title">历史趋势</div>
<el-segmented v-model="selectedRange" :options="rangeOptions" @change="loadHistory" />
</div>
Expand Down Expand Up @@ -231,6 +243,7 @@ import {
} from "./application-metrics";
import type { MonitoringHistory, MonitoringRange } from "@/types/api/gateway-api-route";
import { historyChartOptions } from "../monitoring/history-chart";
import { rangeOptions, refreshOptions, useMonitoringRefresh } from "../monitoring/refresh";

defineOptions({ name: "GatewayServices" });

Expand All @@ -248,7 +261,11 @@ const monitoringDialog = reactive<{
activeInstance: string;
}>({ visible: false, activeInstance: "" });
const selectedRange = ref<MonitoringRange>("1h");
const rangeOptions = ["15m", "1h", "6h", "24h", "7d", "30d"];
const { refreshInterval, refreshing, refresh } = useMonitoringRefresh(
refreshMonitoring,
computed(() => monitoringDialog.visible)
);
let historyRequest = 0;
const history = ref<MonitoringHistory>();
const trafficChartOptions = computed(() =>
historyChartOptions(history.value, [
Expand Down Expand Up @@ -286,7 +303,19 @@ async function loadServices() {
const result = await GatewayServiceAPI.list(query);
services.value = result.services || [];
total.value = result.total || 0;
loading.value = false;
if (monitoringDialog.visible && monitoringDialog.service) {
const current = services.value.find((item) => item.name === monitoringDialog.service?.name);
if (current) {
monitoringDialog.service = current;
if (
!current.instances.some((item) => instanceKey(item) === monitoringDialog.activeInstance)
) {
monitoringDialog.activeInstance = current.instances[0]
? instanceKey(current.instances[0])
: "";
}
}
}
const [metricSnapshot, actuatorSnapshots] = await Promise.all([
GatewayServiceAPI.getApplicationMetrics().catch(() => undefined),
GatewayServiceAPI.getActuatorMetrics(query).catch(() => []),
Expand Down Expand Up @@ -372,18 +401,33 @@ function instanceKey(instance: GatewayServiceInstance) {
}

function openMonitoringDetail(service: GatewayServiceSummary) {
history.value = undefined;
monitoringDialog.service = service;
monitoringDialog.activeInstance = service.instances[0] ? instanceKey(service.instances[0]) : "";
monitoringDialog.visible = true;
loadHistory();
}

async function refreshMonitoring() {
if (!monitoringDialog.visible) return;
await Promise.allSettled([loadServices(), loadHistory()]);
}

async function loadHistory() {
if (!monitoringDialog.service) return;
history.value = await GatewayServiceAPI.getApplicationHistory({
const request = ++historyRequest;
const application = monitoringDialog.service.name;
const result = await GatewayServiceAPI.getApplicationHistory({
range: selectedRange.value,
application: monitoringDialog.service.name,
application,
}).catch(() => undefined);
if (
request === historyRequest &&
monitoringDialog.visible &&
monitoringDialog.service?.name === application
) {
history.value = result;
}
}

async function viewApis(serviceId: string) {
Expand Down
114 changes: 114 additions & 0 deletions tests/unit/views/monitoring-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { defineComponent, h, KeepAlive, nextTick, ref } from "vue";
import { useMonitoringRefresh } from "@/views/system/gateway/monitoring/refresh";

describe("monitoring automatic refresh", () => {
afterEach(() => vi.useRealTimers());

function setup(load = vi.fn(async () => {}), enabled = ref(true)) {
let refresh!: ReturnType<typeof useMonitoringRefresh>;
const wrapper = mount(
defineComponent({
setup() {
refresh = useMonitoringRefresh(load, enabled);
return () => h("div");
},
})
);
return { wrapper, refresh, load, enabled };
}

it("defaults to 30 seconds, changes frequency, supports disabling and manual refresh", async () => {
vi.useFakeTimers();
const { wrapper, refresh, load } = setup();
await vi.advanceTimersByTimeAsync(29_999);
expect(load).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(load).toHaveBeenCalledTimes(1);
refresh.refreshInterval.value = 10_000;
await vi.advanceTimersByTimeAsync(10_000);
expect(load).toHaveBeenCalledTimes(2);
refresh.refreshInterval.value = 60_000;
await vi.advanceTimersByTimeAsync(59_999);
expect(load).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1);
expect(load).toHaveBeenCalledTimes(3);
refresh.refreshInterval.value = 0;
await vi.advanceTimersByTimeAsync(60_000);
expect(load).toHaveBeenCalledTimes(3);
await refresh.refresh();
expect(load).toHaveBeenCalledTimes(4);
wrapper.unmount();
});

it("stops when the detail closes and on unmount", async () => {
vi.useFakeTimers();
const { wrapper, load, enabled } = setup(undefined, ref(false));
await vi.advanceTimersByTimeAsync(30_000);
expect(load).not.toHaveBeenCalled();
enabled.value = true;
await vi.advanceTimersByTimeAsync(30_000);
expect(load).toHaveBeenCalledTimes(1);
enabled.value = false;
await vi.advanceTimersByTimeAsync(60_000);
expect(load).toHaveBeenCalledTimes(1);
enabled.value = true;
wrapper.unmount();
await vi.advanceTimersByTimeAsync(60_000);
expect(load).toHaveBeenCalledTimes(1);
});

it("waits for slow requests and recovers after failure", async () => {
vi.useFakeTimers();
let reject!: (reason: Error) => void;
const load = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<void>((_, fail) => {
reject = fail;
})
)
.mockResolvedValue(undefined);
const { wrapper, refresh } = setup(load);
await vi.advanceTimersByTimeAsync(30_000);
expect(refresh.refreshing.value).toBe(true);
await vi.advanceTimersByTimeAsync(60_000);
await refresh.refresh();
expect(load).toHaveBeenCalledTimes(1);
reject(new Error("Unavailable"));
await vi.advanceTimersByTimeAsync(30_000);
expect(load).toHaveBeenCalledTimes(2);
expect(refresh.refreshing.value).toBe(false);
wrapper.unmount();
});

it("pauses cached pages when navigating away and resumes on return", async () => {
vi.useFakeTimers();
const visible = ref(true);
const load = vi.fn(async () => {});
const page = defineComponent({
setup() {
useMonitoringRefresh(load);
return () => h("div");
},
});
const wrapper = mount(
defineComponent({
setup: () => () => h(KeepAlive, () => (visible.value ? h(page) : null)),
})
);
await vi.advanceTimersByTimeAsync(30_000);
expect(load).toHaveBeenCalledTimes(1);
visible.value = false;
await nextTick();
await vi.advanceTimersByTimeAsync(60_000);
expect(load).toHaveBeenCalledTimes(1);
visible.value = true;
await nextTick();
await vi.advanceTimersByTimeAsync(30_000);
expect(load).toHaveBeenCalledTimes(2);
wrapper.unmount();
});
});
Loading