From cee5646668d4550ed3a753314492b45bf2c7eec8 Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 18 Jun 2026 16:49:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=88=86=E6=9E=90?= =?UTF-8?q?=E9=A1=B5=E4=B8=BA=E7=8E=AF=E5=A2=83=E6=80=BB=E8=A7=88=E6=80=BB?= =?UTF-8?q?=E6=8E=A7=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/api/system/environment.spec.ts | 43 ++ .../src/api/system/environment.ts | 196 ++++++ .../src/locales/langs/en-US/page.json | 2 +- .../src/locales/langs/zh-CN/page.json | 2 +- .../components/EnvironmentEventStream.vue | 121 ++++ .../components/EnvironmentEvidencePanel.vue | 180 ++++++ .../components/EnvironmentSiteRail.vue | 134 ++++ .../components/EnvironmentStatusBar.vue | 163 +++++ .../components/EnvironmentTopology.vue | 177 ++++++ .../useEnvironmentDashboardStream.ts | 170 +++++ .../analytics/environment-dashboard.spec.tsx | 535 ++++++++++++++++ .../src/views/dashboard/analytics/index.vue | 601 +++++++++++++++--- .../src/views/dashboard/analytics/types.ts | 13 + 13 files changed, 2258 insertions(+), 79 deletions(-) create mode 100644 apps/web-antdv-next/src/api/system/environment.spec.ts create mode 100644 apps/web-antdv-next/src/api/system/environment.ts create mode 100644 apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentEventStream.vue create mode 100644 apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentEvidencePanel.vue create mode 100644 apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentSiteRail.vue create mode 100644 apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentStatusBar.vue create mode 100644 apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentTopology.vue create mode 100644 apps/web-antdv-next/src/views/dashboard/analytics/composables/useEnvironmentDashboardStream.ts create mode 100644 apps/web-antdv-next/src/views/dashboard/analytics/environment-dashboard.spec.tsx create mode 100644 apps/web-antdv-next/src/views/dashboard/analytics/types.ts diff --git a/apps/web-antdv-next/src/api/system/environment.spec.ts b/apps/web-antdv-next/src/api/system/environment.spec.ts new file mode 100644 index 0000000..9fd4960 --- /dev/null +++ b/apps/web-antdv-next/src/api/system/environment.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { requestClient } from '#/api/request'; + +import { + getEnvironmentDashboard, + getEnvironmentDashboardEventsUrl, + runEnvironmentSelfCheck, +} from './environment'; + +vi.mock('#/api/request', () => ({ + requestClient: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +describe('environment dashboard api', () => { + it('loads the aggregate dashboard', async () => { + await getEnvironmentDashboard(); + + expect(requestClient.get).toHaveBeenCalledWith( + '/system/environment/dashboard', + ); + }); + + it('runs readonly self check', async () => { + await runEnvironmentSelfCheck(); + + expect(requestClient.post).toHaveBeenCalledWith( + '/system/environment/self-check', + ); + }); + + it('builds the SSE stream url without exposing MQTT config', () => { + expect(getEnvironmentDashboardEventsUrl()).toBe( + '/system/environment/events/stream', + ); + expect(getEnvironmentDashboardEventsUrl('evt-1')).toBe( + '/system/environment/events/stream?lastEventId=evt-1', + ); + }); +}); diff --git a/apps/web-antdv-next/src/api/system/environment.ts b/apps/web-antdv-next/src/api/system/environment.ts new file mode 100644 index 0000000..08d153f --- /dev/null +++ b/apps/web-antdv-next/src/api/system/environment.ts @@ -0,0 +1,196 @@ +import { requestClient } from '#/api/request'; + +export namespace EnvironmentDashboardApi { + export type EnvironmentHealthStatus = + | 'blocked' + | 'degraded' + | 'down' + | 'isolated' + | 'ok' + | 'unknown' + | 'unwired'; + + export type EnvironmentSiteStatus = + | 'degraded' + | 'isolated' + | 'online' + | 'unknown'; + + export type EnvironmentSignalSourceKind = + | 'cached' + | 'configured' + | 'derived' + | 'external-link' + | 'live' + | 'unwired'; + + export interface EnvironmentEvidence { + metadata?: Record; + observedAt?: string; + source: string; + summary: string; + type?: 'error' | EnvironmentSignalSourceKind; + url?: string; + } + + export interface EnvironmentSignal { + evidence: EnvironmentEvidence[]; + id: string; + label: string; + observedAt?: string; + sourceKind: EnvironmentSignalSourceKind; + staleAfterSeconds?: number; + status: EnvironmentHealthStatus; + summary: string; + } + + export interface EnvironmentService { + id: string; + label: string; + signals: EnvironmentSignal[]; + status: EnvironmentHealthStatus; + summary: string; + } + + export interface EnvironmentNode { + id: string; + label: string; + services: EnvironmentService[]; + status: EnvironmentHealthStatus; + summary?: string; + } + + export interface EnvironmentSite { + id: string; + label: string; + nodes: EnvironmentNode[]; + status: EnvironmentSiteStatus; + summary: string; + } + + export interface EnvironmentDashboardSummary { + blocked: number; + degraded: number; + down: number; + ok: number; + totalSignals: number; + unknown: number; + unwired: number; + } + + export interface EnvironmentTopologyEdge { + from: string; + id: string; + label: string; + to: string; + } + + export interface EnvironmentTopologyNode { + id: string; + label: string; + siteId: string; + status: EnvironmentHealthStatus; + type: 'node' | 'service' | 'site'; + } + + export interface EnvironmentTopology { + edges: EnvironmentTopologyEdge[]; + nodes: EnvironmentTopologyNode[]; + } + + export interface EnvironmentAction { + disabledReason?: string; + enabled: boolean; + id: string; + label: string; + riskLevel: 'high' | 'low' | 'medium'; + serviceId?: string; + siteId?: string; + } + + export interface EnvironmentEvent { + eventId: string; + evidence?: EnvironmentEvidence[]; + expiresAt?: string; + nodeId?: string; + observedAt: string; + retained?: boolean; + serviceId?: string; + severity: EnvironmentHealthStatus; + signalId?: string; + siteId: string; + sourceKind: 'local' | 'mqtt' | EnvironmentSignalSourceKind; + summary: string; + topic: string; + } + + export interface EnvironmentDashboardResponse { + actions: EnvironmentAction[]; + events: EnvironmentEvent[]; + generatedAt: string; + refreshedAt: string; + sites: EnvironmentSite[]; + summary: EnvironmentDashboardSummary; + topology: EnvironmentTopology; + } + + export type EnvironmentStreamEventType = + | 'environment-event' + | 'environment-signal' + | 'error' + | 'heartbeat' + | 'snapshot-required'; +} + +/** + * Loads the aggregate Admin environment dashboard snapshot. + * + * @returns Dashboard response unwrapped by the shared Vben request client. + */ +export function getEnvironmentDashboard() { + return requestClient.get( + '/system/environment/dashboard', + ); +} + +/** + * Runs the API read-only self-check endpoint without exposing write actions. + * + * @returns Fresh dashboard response after backend read-only probes complete. + */ +export function runEnvironmentSelfCheck() { + return requestClient.post( + '/system/environment/self-check', + ); +} + +/** + * Builds the EventSource URL for API-emitted dashboard updates. + * + * @param lastEventId Optional SSE replay cursor held only in the current page instance. + * @returns Absolute API URL when the request client exposes a base URL; otherwise a relative path. + */ +export function getEnvironmentDashboardEventsUrl(lastEventId?: string) { + const query = lastEventId + ? `?lastEventId=${encodeURIComponent(lastEventId)}` + : ''; + return buildApiUrl(`/system/environment/events/stream${query}`); +} + +/** + * Mirrors the existing API URL joining convention used by local SSE helpers. + * + * @param path API path produced by the environment dashboard wrapper. + * @returns Browser-ready URL that respects proxy or absolute API base configuration. + */ +function buildApiUrl(path: string) { + const getBaseUrl = (requestClient as unknown as { getBaseUrl?: () => string }) + .getBaseUrl; + const baseUrl = getBaseUrl?.() || ''; + if (!baseUrl) return path; + if (/^https?:\/\//i.test(path)) return path; + if (/^https?:\/\//i.test(baseUrl)) { + return new URL(path, baseUrl).toString(); + } + return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; +} diff --git a/apps/web-antdv-next/src/locales/langs/en-US/page.json b/apps/web-antdv-next/src/locales/langs/en-US/page.json index 32e323c..ec77de5 100644 --- a/apps/web-antdv-next/src/locales/langs/en-US/page.json +++ b/apps/web-antdv-next/src/locales/langs/en-US/page.json @@ -11,7 +11,7 @@ }, "dashboard": { "title": "Dashboard", - "analytics": "Analytics", + "analytics": "Environment", "workspace": "Workspace" } } diff --git a/apps/web-antdv-next/src/locales/langs/zh-CN/page.json b/apps/web-antdv-next/src/locales/langs/zh-CN/page.json index 437068c..e91296a 100644 --- a/apps/web-antdv-next/src/locales/langs/zh-CN/page.json +++ b/apps/web-antdv-next/src/locales/langs/zh-CN/page.json @@ -11,7 +11,7 @@ }, "dashboard": { "title": "概览", - "analytics": "分析页", + "analytics": "环境总览", "workspace": "工作台" } } diff --git a/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentEventStream.vue b/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentEventStream.vue new file mode 100644 index 0000000..fbd3944 --- /dev/null +++ b/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentEventStream.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentEvidencePanel.vue b/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentEvidencePanel.vue new file mode 100644 index 0000000..e5fd869 --- /dev/null +++ b/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentEvidencePanel.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentSiteRail.vue b/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentSiteRail.vue new file mode 100644 index 0000000..41cea48 --- /dev/null +++ b/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentSiteRail.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentStatusBar.vue b/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentStatusBar.vue new file mode 100644 index 0000000..a93748f --- /dev/null +++ b/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentStatusBar.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentTopology.vue b/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentTopology.vue new file mode 100644 index 0000000..5dd3055 --- /dev/null +++ b/apps/web-antdv-next/src/views/dashboard/analytics/components/EnvironmentTopology.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/apps/web-antdv-next/src/views/dashboard/analytics/composables/useEnvironmentDashboardStream.ts b/apps/web-antdv-next/src/views/dashboard/analytics/composables/useEnvironmentDashboardStream.ts new file mode 100644 index 0000000..55e9541 --- /dev/null +++ b/apps/web-antdv-next/src/views/dashboard/analytics/composables/useEnvironmentDashboardStream.ts @@ -0,0 +1,170 @@ +import type { EnvironmentDashboardApi } from '#/api/system/environment'; + +import { ref } from 'vue'; + +import { getEnvironmentDashboardEventsUrl } from '#/api/system/environment'; + +type StreamEvent = EnvironmentDashboardApi.EnvironmentEvent; + +export type EnvironmentStreamConnectionState = + | 'closed' + | 'connecting' + | 'error' + | 'idle' + | 'open'; + +export interface UseEnvironmentDashboardStreamOptions { + onEnvironmentEvent: (event: StreamEvent) => void; + onEnvironmentSignal: (event: StreamEvent) => void; + onError?: (event: StreamEvent) => void; + onSnapshotRequired: (event: StreamEvent) => void; +} + +/** + * Opens one browser EventSource for Admin environment updates after the first HTTP snapshot. + * + * @param options Page-owned callbacks that merge SSE payloads into visible state. + * @returns Controls for starting and closing the stream plus connection state for display. + */ +export function useEnvironmentDashboardStream( + options: UseEnvironmentDashboardStreamOptions, +) { + const connectionState = ref('idle'); + const lastEventId = ref(); + let source: EventSource | undefined; + + /** + * Starts the SSE connection once and lets the browser handle native reconnects. + */ + function start() { + if (source) return; + connectionState.value = 'connecting'; + source = new EventSource( + getEnvironmentDashboardEventsUrl(lastEventId.value), + { + withCredentials: true, + }, + ); + source.addEventListener('open', handleOpen); + source.addEventListener('environment-event', handleEnvironmentEvent); + source.addEventListener('environment-signal', handleEnvironmentSignal); + source.addEventListener('snapshot-required', handleSnapshotRequired); + source.addEventListener('heartbeat', handleHeartbeat); + source.addEventListener('error', handleError); + } + + /** + * Closes the EventSource when the route leaves or the component unmounts. + */ + function close() { + if (!source) return; + source.removeEventListener('open', handleOpen); + source.removeEventListener('environment-event', handleEnvironmentEvent); + source.removeEventListener('environment-signal', handleEnvironmentSignal); + source.removeEventListener('snapshot-required', handleSnapshotRequired); + source.removeEventListener('heartbeat', handleHeartbeat); + source.removeEventListener('error', handleError); + source.close(); + source = undefined; + connectionState.value = 'closed'; + } + + /** + * Marks the stream as open after the browser confirms the connection. + */ + function handleOpen() { + connectionState.value = 'open'; + } + + /** + * Applies a backend event entry to the visible event stream. + * + * @param event Browser SSE message carrying an EnvironmentEvent JSON payload. + */ + function handleEnvironmentEvent(event: Event) { + const payload = parseStreamEvent(event); + if (!payload) return; + rememberEventId(payload); + options.onEnvironmentEvent(payload); + } + + /** + * Applies one signal-level update without reloading the dashboard snapshot. + * + * @param event Browser SSE message carrying an EnvironmentEvent JSON payload. + */ + function handleEnvironmentSignal(event: Event) { + const payload = parseStreamEvent(event); + if (!payload) return; + rememberEventId(payload); + options.onEnvironmentSignal(payload); + } + + /** + * Notifies the page that the API replay buffer requires one fresh snapshot. + * + * @param event Browser SSE message carrying the replay-gap event envelope. + */ + function handleSnapshotRequired(event: Event) { + const payload = parseStreamEvent(event); + if (!payload) return; + rememberEventId(payload); + options.onSnapshotRequired(payload); + } + + /** + * Leaves dashboard state unchanged for keepalive events. + */ + function handleHeartbeat() { + if (connectionState.value === 'connecting') { + connectionState.value = 'open'; + } + } + + /** + * Records stream errors without starting polling or manual reconnect loops. + * + * @param event Optional API error payload when the server sends a typed error event. + */ + function handleError(event: Event) { + connectionState.value = 'error'; + const payload = parseStreamEvent(event); + if (payload) { + rememberEventId(payload); + options.onError?.(payload); + } + } + + /** + * Parses JSON payloads from typed SSE events. + * + * @param event Browser EventSource event that may include string data. + * @returns Environment event payload, or undefined when malformed. + */ + function parseStreamEvent(event: Event) { + const data = (event as MessageEvent).data; + if (!data) return undefined; + try { + return JSON.parse(data) as StreamEvent; + } catch { + return undefined; + } + } + + /** + * Keeps the replay cursor scoped to the current route instance. + * + * @param event Parsed environment stream event from the API. + */ + function rememberEventId(event: StreamEvent) { + if (event.eventId) { + lastEventId.value = event.eventId; + } + } + + return { + close, + connectionState, + start, + }; +} diff --git a/apps/web-antdv-next/src/views/dashboard/analytics/environment-dashboard.spec.tsx b/apps/web-antdv-next/src/views/dashboard/analytics/environment-dashboard.spec.tsx new file mode 100644 index 0000000..323d0e2 --- /dev/null +++ b/apps/web-antdv-next/src/views/dashboard/analytics/environment-dashboard.spec.tsx @@ -0,0 +1,535 @@ +/* @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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getEnvironmentDashboard, + runEnvironmentSelfCheck, +} from '#/api/system/environment'; + +import EnvironmentDashboardPage from './index.vue'; + +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: String, + type: String, + }, + setup(props, { slots }) { + return () => + h('div', { role: 'alert' }, [props.message, slots.description?.()]); + }, + }), + Badge: defineComponent({ + name: 'MockBadge', + props: { + status: String, + text: String, + }, + setup(props) { + return () => h('span', props.text as string); + }, + }), + Button: defineComponent({ + name: 'MockButton', + props: { + disabled: Boolean, + loading: Boolean, + 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: 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: String, + }, + setup(_, { slots }) { + return () => h('span', slots.default?.()); + }, + }), + Tooltip: defineComponent({ + name: 'MockTooltip', + props: { + title: String, + }, + setup(_, { slots }) { + return () => h('span', slots.default?.()); + }, + }), +})); + +type FakeEventSourceListener = (event: MessageEvent) => void; + +class FakeEventSource { + static instances: FakeEventSource[] = []; + + closed = false; + readonly listeners = new Map>(); + readonly url: string; + + /** + * Records the stream URL used by the page so tests can dispatch typed SSE messages. + * + * @param url Browser EventSource URL built from the Admin API wrapper. + */ + constructor(url: string) { + this.url = url; + FakeEventSource.instances.push(this); + } + + /** + * Stores typed SSE listeners registered by the production stream composable. + * + * @param type SSE event type supplied by the backend stream. + * @param listener Component-side event handler receiving the JSON payload. + */ + addEventListener(type: string, listener: FakeEventSourceListener) { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + /** + * Marks the connection as closed so the unmount lifecycle can be asserted. + */ + close() { + this.closed = true; + } + + /** + * Delivers one typed SSE message to the mounted page. + * + * @param type SSE event name sent by the API. + * @param payload JSON-serializable event payload. + */ + dispatch(type: string, payload: unknown) { + const event = new MessageEvent(type, { + data: JSON.stringify(payload), + }); + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } + + /** + * Removes an SSE listener during component cleanup. + * + * @param type SSE event type supplied by the backend stream. + * @param listener Previously registered component handler. + */ + removeEventListener(type: string, listener: FakeEventSourceListener) { + this.listeners.get(type)?.delete(listener); + } +} + +/** + * Flushes Vue microtasks created by API promises and reactive state updates. + */ +async function flushDashboardUpdates() { + await Promise.resolve(); + await nextTick(); + await Promise.resolve(); + await nextTick(); +} + +/** + * Builds a complete four-site dashboard fixture that mirrors the backend contract. + * + * @param includeMqttEvent Whether the API snapshot should already contain a MQTT-origin event. + */ +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); + }); +}); diff --git a/apps/web-antdv-next/src/views/dashboard/analytics/index.vue b/apps/web-antdv-next/src/views/dashboard/analytics/index.vue index 5e3d6d2..f29ff7b 100644 --- a/apps/web-antdv-next/src/views/dashboard/analytics/index.vue +++ b/apps/web-antdv-next/src/views/dashboard/analytics/index.vue @@ -1,90 +1,537 @@ + + diff --git a/apps/web-antdv-next/src/views/dashboard/analytics/types.ts b/apps/web-antdv-next/src/views/dashboard/analytics/types.ts new file mode 100644 index 0000000..49026a9 --- /dev/null +++ b/apps/web-antdv-next/src/views/dashboard/analytics/types.ts @@ -0,0 +1,13 @@ +import type { EnvironmentDashboardApi } from '#/api/system/environment'; + +export type EnvironmentAction = EnvironmentDashboardApi.EnvironmentAction; +export type EnvironmentDashboard = + EnvironmentDashboardApi.EnvironmentDashboardResponse; +export type EnvironmentEvidence = EnvironmentDashboardApi.EnvironmentEvidence; +export type EnvironmentEvent = EnvironmentDashboardApi.EnvironmentEvent; +export type EnvironmentHealthStatus = + EnvironmentDashboardApi.EnvironmentHealthStatus; +export type EnvironmentNode = EnvironmentDashboardApi.EnvironmentNode; +export type EnvironmentService = EnvironmentDashboardApi.EnvironmentService; +export type EnvironmentSignal = EnvironmentDashboardApi.EnvironmentSignal; +export type EnvironmentSite = EnvironmentDashboardApi.EnvironmentSite;