501 lines
14 KiB
TypeScript
501 lines
14 KiB
TypeScript
/* @vitest-environment happy-dom */
|
|
/* eslint-disable vue/one-component-per-file */
|
|
|
|
import type { EnvironmentDashboardApi } from '#/api/system/environment';
|
|
|
|
import { mount } from '@vue/test-utils';
|
|
import { defineComponent, h, nextTick } from 'vue';
|
|
|
|
import EnvironmentDashboardPage from '@test-source/apps/web-antdv-next/src/views/dashboard/analytics/index.vue';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
getEnvironmentDashboard,
|
|
runEnvironmentSelfCheck,
|
|
} from '#/api/system/environment';
|
|
|
|
vi.mock('#/api/system/environment', () => ({
|
|
getEnvironmentDashboard: vi.fn(),
|
|
getEnvironmentDashboardEventsUrl: vi.fn((lastEventId?: string) =>
|
|
lastEventId
|
|
? `/system/environment/events/stream?lastEventId=${encodeURIComponent(lastEventId)}`
|
|
: '/system/environment/events/stream',
|
|
),
|
|
runEnvironmentSelfCheck: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('antdv-next', () => ({
|
|
Alert: defineComponent({
|
|
name: 'MockAlert',
|
|
props: {
|
|
message: { default: '', type: String },
|
|
type: { default: 'info', type: String },
|
|
},
|
|
setup(props, { slots }) {
|
|
return () =>
|
|
h('div', { role: 'alert' }, [props.message, slots.description?.()]);
|
|
},
|
|
}),
|
|
Badge: defineComponent({
|
|
name: 'MockBadge',
|
|
props: {
|
|
status: { default: 'default', type: String },
|
|
text: { default: '', type: String },
|
|
},
|
|
setup(props) {
|
|
return () => h('span', props.text as string);
|
|
},
|
|
}),
|
|
Button: defineComponent({
|
|
name: 'MockButton',
|
|
props: {
|
|
disabled: Boolean,
|
|
loading: Boolean,
|
|
type: { default: 'default', type: String },
|
|
},
|
|
emits: ['click'],
|
|
setup(props, { emit, slots }) {
|
|
return () =>
|
|
h(
|
|
'button',
|
|
{
|
|
disabled: props.disabled,
|
|
type: 'button',
|
|
onClick: () => emit('click'),
|
|
},
|
|
slots.default?.(),
|
|
);
|
|
},
|
|
}),
|
|
Card: defineComponent({
|
|
name: 'MockCard',
|
|
props: {
|
|
title: { default: '', type: String },
|
|
},
|
|
setup(props, { slots }) {
|
|
return () =>
|
|
h('section', [
|
|
props.title ? h('h2', props.title as string) : null,
|
|
slots.default?.(),
|
|
]);
|
|
},
|
|
}),
|
|
Empty: defineComponent({
|
|
name: 'MockEmpty',
|
|
setup() {
|
|
return () => h('div', 'empty');
|
|
},
|
|
}),
|
|
Space: defineComponent({
|
|
name: 'MockSpace',
|
|
setup(_, { slots }) {
|
|
return () => h('div', slots.default?.());
|
|
},
|
|
}),
|
|
Spin: defineComponent({
|
|
name: 'MockSpin',
|
|
props: {
|
|
spinning: Boolean,
|
|
},
|
|
setup(_, { slots }) {
|
|
return () => h('div', slots.default?.());
|
|
},
|
|
}),
|
|
Tag: defineComponent({
|
|
name: 'MockTag',
|
|
props: {
|
|
color: { default: 'default', type: String },
|
|
},
|
|
setup(_, { slots }) {
|
|
return () => h('span', slots.default?.());
|
|
},
|
|
}),
|
|
Tooltip: defineComponent({
|
|
name: 'MockTooltip',
|
|
props: {
|
|
title: { default: '', type: String },
|
|
},
|
|
setup(_, { slots }) {
|
|
return () => h('span', slots.default?.());
|
|
},
|
|
}),
|
|
}));
|
|
|
|
type FakeEventSourceListener = (event: MessageEvent<string>) => void;
|
|
|
|
class FakeEventSource {
|
|
static instances: FakeEventSource[] = [];
|
|
|
|
closed = false;
|
|
readonly listeners = new Map<string, Set<FakeEventSourceListener>>();
|
|
readonly url: string;
|
|
|
|
constructor(url: string) {
|
|
this.url = url;
|
|
FakeEventSource.instances.push(this);
|
|
}
|
|
|
|
addEventListener(type: string, listener: FakeEventSourceListener) {
|
|
const listeners = this.listeners.get(type) ?? new Set();
|
|
listeners.add(listener);
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
|
|
close() {
|
|
this.closed = true;
|
|
}
|
|
|
|
dispatch(type: string, payload: unknown) {
|
|
const event = new MessageEvent(type, {
|
|
data: JSON.stringify(payload),
|
|
});
|
|
for (const listener of this.listeners.get(type) ?? []) {
|
|
listener(event);
|
|
}
|
|
}
|
|
|
|
removeEventListener(type: string, listener: FakeEventSourceListener) {
|
|
this.listeners.get(type)?.delete(listener);
|
|
}
|
|
}
|
|
|
|
async function flushDashboardUpdates() {
|
|
await Promise.resolve();
|
|
await nextTick();
|
|
await Promise.resolve();
|
|
await nextTick();
|
|
}
|
|
|
|
function createDashboardFixture(
|
|
includeMqttEvent = false,
|
|
): EnvironmentDashboardApi.EnvironmentDashboardResponse {
|
|
return {
|
|
actions: [
|
|
{
|
|
disabledReason: '第一版只允许只读自检',
|
|
enabled: true,
|
|
id: 'run-self-check',
|
|
label: '只读自检',
|
|
riskLevel: 'low',
|
|
},
|
|
{
|
|
disabledReason: '高风险操作需要人工审批',
|
|
enabled: false,
|
|
id: 'trigger-jenkins-deploy',
|
|
label: '触发 Jenkins 部署',
|
|
riskLevel: 'high',
|
|
},
|
|
],
|
|
events: includeMqttEvent
|
|
? [
|
|
{
|
|
eventId: 'evt-mqtt-1',
|
|
observedAt: '2026-06-18 10:02:00',
|
|
severity: 'degraded',
|
|
siteId: 'nas-prod',
|
|
sourceKind: 'mqtt',
|
|
summary: 'MQTT reported NapCat degraded',
|
|
topic: 'kt/env/nas-prod/napcat',
|
|
},
|
|
]
|
|
: [],
|
|
generatedAt: '2026-06-18 10:00:00',
|
|
refreshedAt: '2026-06-18 10:00:01',
|
|
sites: [
|
|
{
|
|
id: 'local-dev',
|
|
label: 'Local Dev',
|
|
nodes: [
|
|
{
|
|
id: 'local-dev-host',
|
|
label: 'Local Host',
|
|
services: [
|
|
{
|
|
id: 'admin-local',
|
|
label: 'Admin Local',
|
|
signals: [
|
|
{
|
|
evidence: [],
|
|
id: 'admin-local-http',
|
|
label: 'HTTP',
|
|
sourceKind: 'live',
|
|
status: 'ok',
|
|
summary: 'Vite reachable',
|
|
},
|
|
],
|
|
status: 'ok',
|
|
summary: 'Admin is reachable',
|
|
},
|
|
],
|
|
status: 'ok',
|
|
},
|
|
],
|
|
status: 'online',
|
|
summary: 'local ready',
|
|
},
|
|
{
|
|
id: 'nas-prod',
|
|
label: 'NAS Production',
|
|
nodes: [
|
|
{
|
|
id: 'nas-node',
|
|
label: 'NAS Node',
|
|
services: [
|
|
{
|
|
id: 'jenkins',
|
|
label: 'Jenkins',
|
|
signals: [
|
|
{
|
|
evidence: [
|
|
{
|
|
observedAt: '2026-06-18 10:00:01',
|
|
source: 'ENV_DASHBOARD_JENKINS_URL',
|
|
summary: 'ENV_DASHBOARD_JENKINS_URL missing',
|
|
type: 'unwired',
|
|
},
|
|
],
|
|
id: 'jenkins-config',
|
|
label: 'Read-only config',
|
|
sourceKind: 'unwired',
|
|
status: 'unwired',
|
|
summary: 'Jenkins read-only config missing',
|
|
},
|
|
],
|
|
status: 'unwired',
|
|
summary: 'Jenkins read-only config missing',
|
|
},
|
|
{
|
|
id: 'k8s',
|
|
label: 'K8s',
|
|
signals: [
|
|
{
|
|
evidence: [
|
|
{
|
|
observedAt: '2026-06-18 10:00:01',
|
|
source: 'ENV_DASHBOARD_K8S_API_SERVER',
|
|
summary: 'ENV_DASHBOARD_K8S_API_SERVER missing',
|
|
type: 'unwired',
|
|
},
|
|
],
|
|
id: 'k8s-config',
|
|
label: 'Read-only config',
|
|
sourceKind: 'unwired',
|
|
status: 'unwired',
|
|
summary: 'K8s read-only config missing',
|
|
},
|
|
],
|
|
status: 'unwired',
|
|
summary: 'K8s read-only config missing',
|
|
},
|
|
{
|
|
id: 'napcat',
|
|
label: 'NapCat',
|
|
signals: [
|
|
{
|
|
evidence: [],
|
|
id: 'napcat-login',
|
|
label: 'Login',
|
|
sourceKind: 'live',
|
|
status: 'ok',
|
|
summary: 'NapCat online',
|
|
},
|
|
],
|
|
status: 'ok',
|
|
summary: 'NapCat online',
|
|
},
|
|
],
|
|
status: 'unwired',
|
|
},
|
|
],
|
|
status: 'unknown',
|
|
summary: 'remote evidence partial',
|
|
},
|
|
{
|
|
id: 'tencent-cloud',
|
|
label: 'Tencent Cloud',
|
|
nodes: [],
|
|
status: 'unknown',
|
|
summary: 'cloud config pending',
|
|
},
|
|
{
|
|
id: 'r4se',
|
|
label: 'r4se',
|
|
nodes: [],
|
|
status: 'isolated',
|
|
summary: 'remote site isolated',
|
|
},
|
|
],
|
|
summary: {
|
|
blocked: 0,
|
|
degraded: includeMqttEvent ? 1 : 0,
|
|
down: 0,
|
|
ok: 2,
|
|
totalSignals: 4,
|
|
unknown: 0,
|
|
unwired: 2,
|
|
},
|
|
topology: {
|
|
edges: [
|
|
{
|
|
from: 'local-dev',
|
|
id: 'edge-local-api',
|
|
label: 'serves',
|
|
to: 'admin-local',
|
|
},
|
|
],
|
|
nodes: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('environment dashboard page', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
FakeEventSource.instances = [];
|
|
vi.stubGlobal('EventSource', FakeEventSource);
|
|
vi.stubGlobal('mqtt', {
|
|
connect: vi.fn(),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('renders all four sites from the dashboard snapshot', async () => {
|
|
vi.mocked(getEnvironmentDashboard).mockResolvedValue(
|
|
createDashboardFixture(),
|
|
);
|
|
|
|
const wrapper = mount(EnvironmentDashboardPage);
|
|
await flushDashboardUpdates();
|
|
|
|
expect(wrapper.text()).toContain('Local Dev');
|
|
expect(wrapper.text()).toContain('NAS Production');
|
|
expect(wrapper.text()).toContain('Tencent Cloud');
|
|
expect(wrapper.text()).toContain('r4se');
|
|
});
|
|
|
|
it('renders disabled write actions with their reason', async () => {
|
|
vi.mocked(getEnvironmentDashboard).mockResolvedValue(
|
|
createDashboardFixture(),
|
|
);
|
|
|
|
const wrapper = mount(EnvironmentDashboardPage);
|
|
await flushDashboardUpdates();
|
|
|
|
const deployButton = wrapper
|
|
.findAll('button')
|
|
.find((button) => button.text().includes('触发 Jenkins 部署'));
|
|
expect(deployButton?.attributes('disabled')).toBeDefined();
|
|
expect(wrapper.text()).toContain('高风险操作需要人工审批');
|
|
});
|
|
|
|
it('renders API-provided MQTT events without instantiating a MQTT client', async () => {
|
|
vi.mocked(getEnvironmentDashboard).mockResolvedValue(
|
|
createDashboardFixture(true),
|
|
);
|
|
|
|
const wrapper = mount(EnvironmentDashboardPage);
|
|
await flushDashboardUpdates();
|
|
|
|
expect(wrapper.text()).toContain('MQTT reported NapCat degraded');
|
|
expect((globalThis as any).mqtt.connect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('updates one node from an environment-signal SSE without reloading the dashboard', async () => {
|
|
vi.mocked(getEnvironmentDashboard).mockResolvedValue(
|
|
createDashboardFixture(),
|
|
);
|
|
|
|
const wrapper = mount(EnvironmentDashboardPage);
|
|
await flushDashboardUpdates();
|
|
|
|
FakeEventSource.instances[0]?.dispatch('environment-signal', {
|
|
eventId: 'evt-signal-1',
|
|
observedAt: '2026-06-18 10:03:00',
|
|
serviceId: 'napcat',
|
|
severity: 'degraded',
|
|
signalId: 'napcat-login',
|
|
siteId: 'nas-prod',
|
|
sourceKind: 'mqtt',
|
|
summary: 'NapCat login degraded by SSE',
|
|
topic: 'kt/env/nas-prod/napcat',
|
|
});
|
|
await flushDashboardUpdates();
|
|
|
|
expect(getEnvironmentDashboard).toHaveBeenCalledTimes(1);
|
|
expect(wrapper.text()).toContain('NapCat login degraded by SSE');
|
|
});
|
|
|
|
it('loads exactly one snapshot when SSE requests a snapshot', async () => {
|
|
vi.mocked(getEnvironmentDashboard)
|
|
.mockResolvedValueOnce(createDashboardFixture())
|
|
.mockResolvedValueOnce(createDashboardFixture(true));
|
|
|
|
mount(EnvironmentDashboardPage);
|
|
await flushDashboardUpdates();
|
|
|
|
FakeEventSource.instances[0]?.dispatch('snapshot-required', {
|
|
eventId: 'evt-gap',
|
|
observedAt: '2026-06-18 10:04:00',
|
|
severity: 'unknown',
|
|
siteId: 'nas-prod',
|
|
sourceKind: 'local',
|
|
summary: 'Replay gap requires one snapshot',
|
|
topic: 'kt/env/snapshot-required',
|
|
});
|
|
await flushDashboardUpdates();
|
|
|
|
expect(getEnvironmentDashboard).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not register dashboard polling or timer-based refresh', async () => {
|
|
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval');
|
|
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
|
vi.mocked(getEnvironmentDashboard).mockResolvedValue(
|
|
createDashboardFixture(),
|
|
);
|
|
|
|
mount(EnvironmentDashboardPage);
|
|
await flushDashboardUpdates();
|
|
|
|
expect(setIntervalSpy).not.toHaveBeenCalled();
|
|
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('runs readonly self-check from the action button', async () => {
|
|
vi.mocked(getEnvironmentDashboard).mockResolvedValue(
|
|
createDashboardFixture(),
|
|
);
|
|
vi.mocked(runEnvironmentSelfCheck).mockResolvedValue(
|
|
createDashboardFixture(true),
|
|
);
|
|
|
|
const wrapper = mount(EnvironmentDashboardPage);
|
|
await flushDashboardUpdates();
|
|
|
|
await wrapper
|
|
.findAll('button')
|
|
.find((button) => button.text().includes('只读自检'))
|
|
?.trigger('click');
|
|
await flushDashboardUpdates();
|
|
|
|
expect(runEnvironmentSelfCheck).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('closes EventSource when the route page unmounts', async () => {
|
|
vi.mocked(getEnvironmentDashboard).mockResolvedValue(
|
|
createDashboardFixture(),
|
|
);
|
|
|
|
const wrapper = mount(EnvironmentDashboardPage);
|
|
await flushDashboardUpdates();
|
|
|
|
wrapper.unmount();
|
|
|
|
expect(FakeEventSource.instances[0]?.closed).toBe(true);
|
|
});
|
|
});
|