feat: 发布环境总览总控面板页面
This commit is contained in:
commit
2fde3c4e74
43
apps/web-antdv-next/src/api/system/environment.spec.ts
Normal file
43
apps/web-antdv-next/src/api/system/environment.spec.ts
Normal file
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
196
apps/web-antdv-next/src/api/system/environment.ts
Normal file
196
apps/web-antdv-next/src/api/system/environment.ts
Normal file
@ -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<string, unknown>;
|
||||
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<EnvironmentDashboardApi.EnvironmentDashboardResponse>(
|
||||
'/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<EnvironmentDashboardApi.EnvironmentDashboardResponse>(
|
||||
'/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(/^\/+/, '')}`;
|
||||
}
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"analytics": "Analytics",
|
||||
"analytics": "Environment",
|
||||
"workspace": "Workspace"
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "概览",
|
||||
"analytics": "分析页",
|
||||
"analytics": "环境总览",
|
||||
"workspace": "工作台"
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,121 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EnvironmentEvent, EnvironmentHealthStatus } from '../types';
|
||||
|
||||
import { Empty, Tag } from 'antdv-next';
|
||||
|
||||
defineProps<{
|
||||
events: EnvironmentEvent[];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Maps event severity to a compact tag color.
|
||||
*
|
||||
* @param status Event severity from the API stream or snapshot.
|
||||
* @returns Antdv tag color value.
|
||||
*/
|
||||
function getStatusColor(status: EnvironmentHealthStatus) {
|
||||
if (status === 'ok') return 'success';
|
||||
if (status === 'degraded') return 'warning';
|
||||
if (status === 'blocked' || status === 'down') return 'error';
|
||||
if (status === 'isolated') return 'purple';
|
||||
return 'default';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="environment-event-stream">
|
||||
<div class="environment-event-stream__header">
|
||||
<h2>事件流</h2>
|
||||
<span>{{ events.length }} events</span>
|
||||
</div>
|
||||
<Empty v-if="events.length === 0" />
|
||||
<ol v-else class="environment-event-stream__list">
|
||||
<li v-for="event in events" :key="event.eventId">
|
||||
<span class="environment-event-stream__time">
|
||||
{{ event.observedAt }}
|
||||
</span>
|
||||
<strong>{{ event.summary }}</strong>
|
||||
<span class="environment-event-stream__tags">
|
||||
<Tag :color="getStatusColor(event.severity)">
|
||||
{{ event.severity }}
|
||||
</Tag>
|
||||
<Tag>{{ event.sourceKind }}</Tag>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.environment-event-stream {
|
||||
padding: 16px;
|
||||
border: 1px solid hsl(214deg 18% 86%);
|
||||
border-radius: 8px;
|
||||
background: hsl(0deg 0% 100%);
|
||||
}
|
||||
|
||||
.environment-event-stream__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.environment-event-stream__header h2 {
|
||||
margin: 0;
|
||||
color: hsl(219deg 22% 18%);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.environment-event-stream__header span,
|
||||
.environment-event-stream__time {
|
||||
color: hsl(215deg 12% 38%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.environment-event-stream__list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.environment-event-stream__list li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, auto) 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid hsl(210deg 18% 91%);
|
||||
border-radius: 8px;
|
||||
background: hsl(210deg 25% 98%);
|
||||
}
|
||||
|
||||
.environment-event-stream__list strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: hsl(219deg 22% 18%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.environment-event-stream__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.environment-event-stream__list li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.environment-event-stream__tags {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,180 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
EnvironmentAction,
|
||||
EnvironmentEvidence,
|
||||
EnvironmentService,
|
||||
EnvironmentSignal,
|
||||
EnvironmentSite,
|
||||
} from '../types';
|
||||
|
||||
import { Button, Empty, Tag } from 'antdv-next';
|
||||
|
||||
defineProps<{
|
||||
actions: EnvironmentAction[];
|
||||
selectedService?: EnvironmentService;
|
||||
selectedSignal?: EnvironmentSignal;
|
||||
selectedSite?: EnvironmentSite;
|
||||
selfChecking: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
selfCheck: [];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Returns evidence rows for the selected service or signal.
|
||||
*
|
||||
* @param service Selected topology service from the current site.
|
||||
* @param signal Selected signal when the user drills into one.
|
||||
* @returns Evidence list shown in the right-side panel.
|
||||
*/
|
||||
function getEvidenceRows(
|
||||
service?: EnvironmentService,
|
||||
signal?: EnvironmentSignal,
|
||||
) {
|
||||
if (signal) return signal.evidence;
|
||||
return (
|
||||
service?.signals.flatMap((serviceSignal) => serviceSignal.evidence) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels evidence with a stable source and missing-config status.
|
||||
*
|
||||
* @param evidence Evidence record from the API contract.
|
||||
* @returns Human-readable evidence title.
|
||||
*/
|
||||
function getEvidenceTitle(evidence: EnvironmentEvidence) {
|
||||
return `${evidence.type || 'evidence'} · ${evidence.source}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="environment-evidence-panel">
|
||||
<section class="environment-evidence-panel__section">
|
||||
<p class="environment-evidence-panel__eyebrow">Evidence</p>
|
||||
<h2>{{ selectedService?.label || selectedSite?.label || '未选择' }}</h2>
|
||||
<p class="environment-evidence-panel__summary">
|
||||
{{
|
||||
selectedService?.summary || selectedSite?.summary || '暂无环境证据'
|
||||
}}
|
||||
</p>
|
||||
<Tag v-if="selectedService">{{ selectedService.status }}</Tag>
|
||||
</section>
|
||||
|
||||
<section class="environment-evidence-panel__section">
|
||||
<h3>证据链</h3>
|
||||
<Empty
|
||||
v-if="getEvidenceRows(selectedService, selectedSignal).length === 0"
|
||||
/>
|
||||
<ul v-else class="environment-evidence-panel__evidence-list">
|
||||
<li
|
||||
v-for="evidence in getEvidenceRows(selectedService, selectedSignal)"
|
||||
:key="`${evidence.source}-${evidence.summary}`"
|
||||
>
|
||||
<strong>{{ getEvidenceTitle(evidence) }}</strong>
|
||||
<span>{{ evidence.summary }}</span>
|
||||
<small v-if="evidence.observedAt">{{ evidence.observedAt }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="environment-evidence-panel__section">
|
||||
<h3>动作</h3>
|
||||
<div class="environment-evidence-panel__actions">
|
||||
<div
|
||||
v-for="action in actions"
|
||||
:key="action.id"
|
||||
class="environment-evidence-panel__action"
|
||||
>
|
||||
<Button
|
||||
:disabled="!action.enabled"
|
||||
:loading="action.id === 'run-self-check' && selfChecking"
|
||||
@click="
|
||||
action.id === 'run-self-check' && action.enabled
|
||||
? $emit('selfCheck')
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
{{ action.label }}
|
||||
</Button>
|
||||
<span v-if="!action.enabled">{{ action.disabledReason }}</span>
|
||||
<span v-else-if="action.riskLevel === 'low'">只读动作</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.environment-evidence-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid hsl(214deg 18% 86%);
|
||||
border-radius: 8px;
|
||||
background: hsl(0deg 0% 100%);
|
||||
}
|
||||
|
||||
.environment-evidence-panel__section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.environment-evidence-panel__section h2,
|
||||
.environment-evidence-panel__section h3,
|
||||
.environment-evidence-panel__eyebrow,
|
||||
.environment-evidence-panel__summary {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.environment-evidence-panel__section h2 {
|
||||
color: hsl(219deg 22% 18%);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.environment-evidence-panel__section h3 {
|
||||
margin-bottom: 10px;
|
||||
color: hsl(219deg 22% 18%);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.environment-evidence-panel__eyebrow,
|
||||
.environment-evidence-panel__summary,
|
||||
.environment-evidence-panel__action span {
|
||||
color: hsl(215deg 12% 38%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.environment-evidence-panel__evidence-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.environment-evidence-panel__evidence-list li,
|
||||
.environment-evidence-panel__action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid hsl(210deg 18% 91%);
|
||||
border-radius: 8px;
|
||||
background: hsl(210deg 25% 98%);
|
||||
}
|
||||
|
||||
.environment-evidence-panel__evidence-list strong,
|
||||
.environment-evidence-panel__evidence-list span,
|
||||
.environment-evidence-panel__evidence-list small {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.environment-evidence-panel__actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,134 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EnvironmentSite } from '../types';
|
||||
|
||||
import { Badge, Tag } from 'antdv-next';
|
||||
|
||||
const props = defineProps<{
|
||||
selectedSiteId?: string;
|
||||
sites: EnvironmentSite[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
selectSite: [siteId: string];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Counts service signals under a site for compact rail evidence.
|
||||
*
|
||||
* @param site Site record from the dashboard snapshot.
|
||||
* @returns Total number of service signals in the site tree.
|
||||
*/
|
||||
function countSignals(site: EnvironmentSite) {
|
||||
return site.nodes.reduce(
|
||||
(count, node) =>
|
||||
count +
|
||||
node.services.reduce(
|
||||
(serviceCount, service) => serviceCount + service.signals.length,
|
||||
0,
|
||||
),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts unwired signals so missing config remains visible.
|
||||
*
|
||||
* @param site Site record from the dashboard snapshot.
|
||||
* @returns Number of signals marked as unwired by the API.
|
||||
*/
|
||||
function countUnwiredSignals(site: EnvironmentSite) {
|
||||
return site.nodes.reduce(
|
||||
(count, node) =>
|
||||
count +
|
||||
node.services.reduce(
|
||||
(serviceCount, service) =>
|
||||
serviceCount +
|
||||
service.signals.filter((signal) => signal.status === 'unwired')
|
||||
.length,
|
||||
0,
|
||||
),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps site status to antdv badge status.
|
||||
*
|
||||
* @param site Site record from the dashboard snapshot.
|
||||
* @returns Badge status that preserves unknown/unwired as non-green.
|
||||
*/
|
||||
function getBadgeStatus(site: EnvironmentSite) {
|
||||
if (site.status === 'online') return 'success';
|
||||
if (site.status === 'degraded') return 'warning';
|
||||
if (site.status === 'isolated') return 'error';
|
||||
return 'default';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="environment-site-rail">
|
||||
<button
|
||||
v-for="site in props.sites"
|
||||
:key="site.id"
|
||||
class="environment-site-rail__item"
|
||||
:class="[{ 'is-selected': site.id === selectedSiteId }]"
|
||||
type="button"
|
||||
@click="$emit('selectSite', site.id)"
|
||||
>
|
||||
<span class="environment-site-rail__topline">
|
||||
<strong>{{ site.label }}</strong>
|
||||
<Badge :status="getBadgeStatus(site)" :text="site.status" />
|
||||
</span>
|
||||
<span class="environment-site-rail__summary">{{ site.summary }}</span>
|
||||
<span class="environment-site-rail__meta">
|
||||
<Tag>{{ countSignals(site) }} signals</Tag>
|
||||
<Tag v-if="countUnwiredSignals(site) > 0">
|
||||
{{ countUnwiredSignals(site) }} unwired
|
||||
</Tag>
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.environment-site-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.environment-site-rail__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
color: hsl(219deg 22% 18%);
|
||||
text-align: left;
|
||||
border: 1px solid hsl(214deg 18% 86%);
|
||||
border-radius: 8px;
|
||||
background: hsl(0deg 0% 100%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.environment-site-rail__item.is-selected {
|
||||
border-color: hsl(198deg 82% 42%);
|
||||
box-shadow: inset 3px 0 0 hsl(198deg 82% 42%);
|
||||
}
|
||||
|
||||
.environment-site-rail__topline,
|
||||
.environment-site-rail__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.environment-site-rail__summary {
|
||||
color: hsl(215deg 12% 38%);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,163 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EnvironmentStreamConnectionState } from '../composables/useEnvironmentDashboardStream';
|
||||
import type { EnvironmentDashboard, EnvironmentHealthStatus } from '../types';
|
||||
|
||||
import { Alert, Button, Space, Spin, Tag } from 'antdv-next';
|
||||
|
||||
const props = defineProps<{
|
||||
dashboard?: EnvironmentDashboard;
|
||||
errorText?: string;
|
||||
loading: boolean;
|
||||
selfChecking: boolean;
|
||||
streamState: EnvironmentStreamConnectionState;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
refresh: [];
|
||||
selfCheck: [];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Chooses the strongest global status from summary counters.
|
||||
*
|
||||
* @returns Status tag value used by the top dashboard bar.
|
||||
*/
|
||||
function getGlobalStatus(): EnvironmentHealthStatus {
|
||||
const summary = props.dashboard?.summary;
|
||||
if (!summary) return 'unknown';
|
||||
if (summary.blocked > 0) return 'blocked';
|
||||
if (summary.down > 0) return 'down';
|
||||
if (summary.degraded > 0) return 'degraded';
|
||||
if (summary.unwired > 0 || summary.unknown > 0) return 'unknown';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps health status to antdv tag colors without hiding unwired integrations.
|
||||
*
|
||||
* @param status Global or signal health status from the API.
|
||||
* @returns Antdv tag color name.
|
||||
*/
|
||||
function getStatusColor(status: EnvironmentHealthStatus) {
|
||||
if (status === 'ok') return 'success';
|
||||
if (status === 'degraded') return 'warning';
|
||||
if (status === 'blocked' || status === 'down') return 'error';
|
||||
if (status === 'isolated') return 'purple';
|
||||
return 'default';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="environment-status-bar">
|
||||
<div class="environment-status-bar__summary">
|
||||
<Spin :spinning="loading">
|
||||
<div class="environment-status-bar__heading">
|
||||
<div>
|
||||
<p class="environment-status-bar__eyebrow">Environment Command</p>
|
||||
<h1>环境总览</h1>
|
||||
</div>
|
||||
<Tag :color="getStatusColor(getGlobalStatus())">
|
||||
{{ getGlobalStatus() }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="environment-status-bar__meta">
|
||||
<span>生成 {{ dashboard?.generatedAt || 'unknown' }}</span>
|
||||
<span>刷新 {{ dashboard?.refreshedAt || 'unknown' }}</span>
|
||||
<span>SSE {{ streamState }}</span>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
<div class="environment-status-bar__counts">
|
||||
<span>Signals {{ dashboard?.summary.totalSignals ?? 0 }}</span>
|
||||
<span>OK {{ dashboard?.summary.ok ?? 0 }}</span>
|
||||
<span>Unwired {{ dashboard?.summary.unwired ?? 0 }}</span>
|
||||
<span>Degraded {{ dashboard?.summary.degraded ?? 0 }}</span>
|
||||
<span>Down {{ dashboard?.summary.down ?? 0 }}</span>
|
||||
</div>
|
||||
<Space class="environment-status-bar__actions">
|
||||
<Button :loading="loading" @click="$emit('refresh')">刷新快照</Button>
|
||||
<Button
|
||||
:loading="selfChecking"
|
||||
type="primary"
|
||||
@click="$emit('selfCheck')"
|
||||
>
|
||||
只读自检
|
||||
</Button>
|
||||
</Space>
|
||||
<Alert
|
||||
v-if="errorText"
|
||||
:message="errorText"
|
||||
class="environment-status-bar__alert"
|
||||
type="error"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.environment-status-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(260px, auto) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 18px;
|
||||
border: 1px solid hsl(214deg 18% 86%);
|
||||
border-radius: 8px;
|
||||
background: hsl(0deg 0% 100%);
|
||||
}
|
||||
|
||||
.environment-status-bar__summary {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.environment-status-bar__heading {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.environment-status-bar__heading h1 {
|
||||
margin: 0;
|
||||
color: hsl(219deg 22% 18%);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.environment-status-bar__eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: hsl(215deg 12% 45%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.environment-status-bar__meta,
|
||||
.environment-status-bar__counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
color: hsl(215deg 12% 38%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.environment-status-bar__counts span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.environment-status-bar__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.environment-status-bar__alert {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.environment-status-bar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.environment-status-bar__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,177 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
EnvironmentHealthStatus,
|
||||
EnvironmentService,
|
||||
EnvironmentSite,
|
||||
} from '../types';
|
||||
|
||||
import { Empty, Tag } from 'antdv-next';
|
||||
|
||||
defineProps<{
|
||||
selectedServiceId?: string;
|
||||
site?: EnvironmentSite;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
selectService: [serviceId: string];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Maps service health to a compact tag color.
|
||||
*
|
||||
* @param status Service or signal status from the dashboard model.
|
||||
* @returns Antdv tag color value.
|
||||
*/
|
||||
function getStatusColor(status: EnvironmentHealthStatus) {
|
||||
if (status === 'ok') return 'success';
|
||||
if (status === 'degraded') return 'warning';
|
||||
if (status === 'blocked' || status === 'down') return 'error';
|
||||
if (status === 'isolated') return 'purple';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts unwired signals in a service.
|
||||
*
|
||||
* @param service Service record selected from the current site.
|
||||
* @returns Number of signals that are visibly missing configuration.
|
||||
*/
|
||||
function countUnwiredSignals(service: EnvironmentService) {
|
||||
return service.signals.filter((signal) => signal.status === 'unwired').length;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="environment-topology">
|
||||
<div class="environment-topology__header">
|
||||
<div>
|
||||
<p>Topology</p>
|
||||
<h2>{{ site?.label || 'No site selected' }}</h2>
|
||||
</div>
|
||||
<Tag v-if="site">{{ site.status }}</Tag>
|
||||
</div>
|
||||
|
||||
<Empty v-if="!site || site.nodes.length === 0" />
|
||||
<div v-else class="environment-topology__nodes">
|
||||
<section
|
||||
v-for="node in site.nodes"
|
||||
:key="node.id"
|
||||
class="environment-topology__node"
|
||||
>
|
||||
<div class="environment-topology__node-heading">
|
||||
<strong>{{ node.label }}</strong>
|
||||
<Tag :color="getStatusColor(node.status)">{{ node.status }}</Tag>
|
||||
</div>
|
||||
<div class="environment-topology__services">
|
||||
<button
|
||||
v-for="service in node.services"
|
||||
:key="service.id"
|
||||
class="environment-topology__service"
|
||||
:class="[{ 'is-selected': service.id === selectedServiceId }]"
|
||||
type="button"
|
||||
@click="$emit('selectService', service.id)"
|
||||
>
|
||||
<span class="environment-topology__service-name">
|
||||
{{ service.label }}
|
||||
</span>
|
||||
<span class="environment-topology__service-summary">
|
||||
{{ service.summary }}
|
||||
</span>
|
||||
<span class="environment-topology__service-tags">
|
||||
<Tag :color="getStatusColor(service.status)">
|
||||
{{ service.status }}
|
||||
</Tag>
|
||||
<Tag v-if="countUnwiredSignals(service) > 0">
|
||||
{{ countUnwiredSignals(service) }} unwired
|
||||
</Tag>
|
||||
<Tag v-for="signal in service.signals" :key="signal.id">
|
||||
{{ signal.sourceKind }}
|
||||
</Tag>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.environment-topology {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid hsl(214deg 18% 86%);
|
||||
border-radius: 8px;
|
||||
background: hsl(0deg 0% 100%);
|
||||
}
|
||||
|
||||
.environment-topology__header,
|
||||
.environment-topology__node-heading,
|
||||
.environment-topology__service-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.environment-topology__header p {
|
||||
margin: 0 0 4px;
|
||||
color: hsl(215deg 12% 45%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.environment-topology__header h2 {
|
||||
margin: 0;
|
||||
color: hsl(219deg 22% 18%);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.environment-topology__nodes {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.environment-topology__node {
|
||||
padding: 12px;
|
||||
border: 1px solid hsl(210deg 18% 91%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.environment-topology__services {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.environment-topology__service {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 124px;
|
||||
padding: 12px;
|
||||
color: hsl(219deg 22% 18%);
|
||||
text-align: left;
|
||||
border: 1px solid hsl(214deg 18% 86%);
|
||||
border-radius: 8px;
|
||||
background: hsl(210deg 25% 98%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.environment-topology__service.is-selected {
|
||||
border-color: hsl(198deg 82% 42%);
|
||||
background: hsl(198deg 42% 96%);
|
||||
}
|
||||
|
||||
.environment-topology__service-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.environment-topology__service-summary {
|
||||
flex: 1;
|
||||
color: hsl(215deg 12% 38%);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
@ -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<EnvironmentStreamConnectionState>('idle');
|
||||
const lastEventId = ref<string>();
|
||||
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<string>).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,
|
||||
};
|
||||
}
|
||||
@ -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: { 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;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@ -1,90 +1,537 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AnalysisOverviewItem } from '@vben/common-ui';
|
||||
import type { TabOption } from '@vben/types';
|
||||
import type {
|
||||
EnvironmentDashboard,
|
||||
EnvironmentEvent,
|
||||
EnvironmentHealthStatus,
|
||||
EnvironmentNode,
|
||||
EnvironmentService,
|
||||
EnvironmentSignal,
|
||||
EnvironmentSite,
|
||||
} from './types';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
AnalysisChartCard,
|
||||
AnalysisChartsTabs,
|
||||
AnalysisOverview,
|
||||
} from '@vben/common-ui';
|
||||
import {
|
||||
SvgBellIcon,
|
||||
SvgCakeIcon,
|
||||
SvgCardIcon,
|
||||
SvgDownloadIcon,
|
||||
} from '@vben/icons';
|
||||
getEnvironmentDashboard,
|
||||
runEnvironmentSelfCheck,
|
||||
} from '#/api/system/environment';
|
||||
|
||||
import AnalyticsTrends from './analytics-trends.vue';
|
||||
import AnalyticsVisitsData from './analytics-visits-data.vue';
|
||||
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
|
||||
import AnalyticsVisitsSource from './analytics-visits-source.vue';
|
||||
import AnalyticsVisits from './analytics-visits.vue';
|
||||
import EnvironmentEventStream from './components/EnvironmentEventStream.vue';
|
||||
import EnvironmentEvidencePanel from './components/EnvironmentEvidencePanel.vue';
|
||||
import EnvironmentSiteRail from './components/EnvironmentSiteRail.vue';
|
||||
import EnvironmentStatusBar from './components/EnvironmentStatusBar.vue';
|
||||
import EnvironmentTopology from './components/EnvironmentTopology.vue';
|
||||
import { useEnvironmentDashboardStream } from './composables/useEnvironmentDashboardStream';
|
||||
|
||||
const overviewItems: AnalysisOverviewItem[] = [
|
||||
{
|
||||
icon: SvgCardIcon,
|
||||
title: '用户量',
|
||||
totalTitle: '总用户量',
|
||||
totalValue: 120_000,
|
||||
value: 2000,
|
||||
},
|
||||
{
|
||||
icon: SvgCakeIcon,
|
||||
title: '访问量',
|
||||
totalTitle: '总访问量',
|
||||
totalValue: 500_000,
|
||||
value: 20_000,
|
||||
},
|
||||
{
|
||||
icon: SvgDownloadIcon,
|
||||
title: '下载量',
|
||||
totalTitle: '总下载量',
|
||||
totalValue: 120_000,
|
||||
value: 8000,
|
||||
},
|
||||
{
|
||||
icon: SvgBellIcon,
|
||||
title: '使用量',
|
||||
totalTitle: '总使用量',
|
||||
totalValue: 50_000,
|
||||
value: 5000,
|
||||
},
|
||||
];
|
||||
type SnapshotLoadReason = 'initial' | 'manual' | 'snapshot-required';
|
||||
|
||||
const chartTabs: TabOption[] = [
|
||||
{
|
||||
label: '流量趋势',
|
||||
value: 'trends',
|
||||
},
|
||||
{
|
||||
label: '月访问量',
|
||||
value: 'visits',
|
||||
},
|
||||
];
|
||||
const dashboard = ref<EnvironmentDashboard>();
|
||||
const errorText = ref('');
|
||||
const loading = ref(false);
|
||||
const recentEvents = ref<EnvironmentEvent[]>([]);
|
||||
const selectedServiceId = ref<string>();
|
||||
const selectedSignalId = ref<string>();
|
||||
const selectedSiteId = ref<string>();
|
||||
const selfChecking = ref(false);
|
||||
const snapshotRequestInFlight = ref(false);
|
||||
|
||||
const selectedSite = computed(resolveSelectedSite);
|
||||
const selectedService = computed(resolveSelectedService);
|
||||
const selectedSignal = computed(resolveSelectedSignal);
|
||||
const sortedEvents = computed(resolveSortedEvents);
|
||||
|
||||
const environmentStream = useEnvironmentDashboardStream({
|
||||
onEnvironmentEvent: handleEnvironmentEvent,
|
||||
onEnvironmentSignal: handleEnvironmentSignal,
|
||||
onError: handleStreamError,
|
||||
onSnapshotRequired: handleSnapshotRequired,
|
||||
});
|
||||
const streamState = environmentStream.connectionState;
|
||||
|
||||
onMounted(handleMounted);
|
||||
onBeforeUnmount(handleBeforeUnmount);
|
||||
|
||||
/**
|
||||
* Starts the initial snapshot load when the Vben route component mounts.
|
||||
*/
|
||||
function handleMounted() {
|
||||
void loadDashboardSnapshot('initial');
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the SSE stream when route transitions remove this page.
|
||||
*/
|
||||
function handleBeforeUnmount() {
|
||||
environmentStream.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles explicit user refresh without creating timer-based polling.
|
||||
*/
|
||||
function handleManualRefresh() {
|
||||
void loadDashboardSnapshot('manual');
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the read-only backend self-check and replaces visible evidence with its response.
|
||||
*/
|
||||
async function handleSelfCheck() {
|
||||
selfChecking.value = true;
|
||||
errorText.value = '';
|
||||
try {
|
||||
applyDashboard(await runEnvironmentSelfCheck());
|
||||
} catch (error) {
|
||||
errorText.value = getErrorMessage(error);
|
||||
} finally {
|
||||
selfChecking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a dashboard snapshot for first render, manual refresh, or replay gap recovery.
|
||||
*
|
||||
* @param reason Call origin that controls loading state and snapshot-required de-duping.
|
||||
*/
|
||||
async function loadDashboardSnapshot(reason: SnapshotLoadReason) {
|
||||
if (reason === 'snapshot-required') {
|
||||
if (snapshotRequestInFlight.value) return;
|
||||
snapshotRequestInFlight.value = true;
|
||||
}
|
||||
loading.value = reason !== 'snapshot-required';
|
||||
errorText.value = '';
|
||||
try {
|
||||
applyDashboard(await getEnvironmentDashboard());
|
||||
if (reason === 'initial') {
|
||||
environmentStream.start();
|
||||
}
|
||||
} catch (error) {
|
||||
errorText.value = getErrorMessage(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (reason === 'snapshot-required') {
|
||||
snapshotRequestInFlight.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a full dashboard response while preserving still-valid selections.
|
||||
*
|
||||
* @param next Dashboard response from GET snapshot or POST self-check.
|
||||
*/
|
||||
function applyDashboard(next: EnvironmentDashboard) {
|
||||
dashboard.value = next;
|
||||
recentEvents.value = [...(next.events ?? [])];
|
||||
ensureSelection(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserves selected service when possible, otherwise selects the first risky service.
|
||||
*
|
||||
* @param next Dashboard response that becomes the new source of truth.
|
||||
*/
|
||||
function ensureSelection(next: EnvironmentDashboard) {
|
||||
const existingSite = selectedSiteId.value
|
||||
? next.sites.find((site) => site.id === selectedSiteId.value)
|
||||
: undefined;
|
||||
const existingService = existingSite
|
||||
? findService(existingSite, selectedServiceId.value)
|
||||
: undefined;
|
||||
if (existingSite && existingService) {
|
||||
selectedSignalId.value =
|
||||
findSignal(existingService, selectedSignalId.value)?.id ??
|
||||
existingService.signals[0]?.id;
|
||||
return;
|
||||
}
|
||||
|
||||
const preferred = findFirstAttentionService(next.sites);
|
||||
const fallbackSite = preferred?.site ?? next.sites[0];
|
||||
const fallbackService = preferred?.service ?? getFirstService(fallbackSite);
|
||||
selectedSiteId.value = fallbackSite?.id;
|
||||
selectedServiceId.value = fallbackService?.id;
|
||||
selectedSignalId.value = fallbackService?.signals[0]?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates selected site and moves service selection to that site's first service.
|
||||
*
|
||||
* @param siteId Site ID emitted by the left rail.
|
||||
*/
|
||||
function handleSiteSelect(siteId: string) {
|
||||
selectedSiteId.value = siteId;
|
||||
const site = dashboard.value?.sites.find((item) => item.id === siteId);
|
||||
const service = getFirstService(site);
|
||||
selectedServiceId.value = service?.id;
|
||||
selectedSignalId.value = service?.signals[0]?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates selected service inside the active site.
|
||||
*
|
||||
* @param serviceId Service ID emitted by the topology panel.
|
||||
*/
|
||||
function handleServiceSelect(serviceId: string) {
|
||||
const service = findService(selectedSite.value, serviceId);
|
||||
selectedServiceId.value = service?.id;
|
||||
selectedSignalId.value = service?.signals[0]?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a stream event to the visible bottom event list.
|
||||
*
|
||||
* @param event SSE event envelope sent by the API stream.
|
||||
*/
|
||||
function handleEnvironmentEvent(event: EnvironmentEvent) {
|
||||
addRecentEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges one signal update into the current dashboard tree without re-fetching.
|
||||
*
|
||||
* @param event SSE signal envelope sent by the API stream.
|
||||
*/
|
||||
function handleEnvironmentSignal(event: EnvironmentEvent) {
|
||||
addRecentEvent(event);
|
||||
applySignalEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one snapshot load when the API reports a replay buffer gap.
|
||||
*
|
||||
* @param event Snapshot-required event envelope from the API stream.
|
||||
*/
|
||||
function handleSnapshotRequired(event: EnvironmentEvent) {
|
||||
addRecentEvent(event);
|
||||
void loadDashboardSnapshot('snapshot-required');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows stream errors as evidence without starting a polling fallback.
|
||||
*
|
||||
* @param event Error event envelope from the API stream.
|
||||
*/
|
||||
function handleStreamError(event: EnvironmentEvent) {
|
||||
addRecentEvent(event);
|
||||
errorText.value = event.summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one service or signal-level update in-place for visible topology state.
|
||||
*
|
||||
* @param event Parsed SSE environment-signal payload.
|
||||
*/
|
||||
function applySignalEvent(event: EnvironmentEvent) {
|
||||
const site = dashboard.value?.sites.find((item) => item.id === event.siteId);
|
||||
if (!site || !event.serviceId) return;
|
||||
const node = findNodeByServiceId(site, event.serviceId);
|
||||
const service = node ? findServiceInNode(node, event.serviceId) : undefined;
|
||||
if (!node || !service) return;
|
||||
|
||||
service.status = event.severity;
|
||||
service.summary = event.summary;
|
||||
const signal = event.signalId
|
||||
? findSignal(service, event.signalId)
|
||||
: service.signals[0];
|
||||
if (signal) {
|
||||
signal.status = event.severity;
|
||||
signal.summary = event.summary;
|
||||
signal.observedAt = event.observedAt;
|
||||
signal.sourceKind = mapEventSourceToSignalSource(event.sourceKind);
|
||||
if (event.evidence) {
|
||||
signal.evidence = event.evidence;
|
||||
}
|
||||
}
|
||||
node.status = pickWorstHealthStatus(node.services.map((item) => item.status));
|
||||
site.status = mapSiteStatus(site.nodes.map((item) => item.status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a recent event newest-first while de-duping by event ID.
|
||||
*
|
||||
* @param event Event from the initial snapshot or SSE stream.
|
||||
*/
|
||||
function addRecentEvent(event: EnvironmentEvent) {
|
||||
recentEvents.value = [
|
||||
event,
|
||||
...recentEvents.value.filter((item) => item.eventId !== event.eventId),
|
||||
].slice(0, 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the currently selected site from reactive IDs.
|
||||
*
|
||||
* @returns Selected site or undefined when the dashboard has not loaded.
|
||||
*/
|
||||
function resolveSelectedSite() {
|
||||
return dashboard.value?.sites.find(
|
||||
(site) => site.id === selectedSiteId.value,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the currently selected service from the selected site.
|
||||
*
|
||||
* @returns Selected service or undefined when no service is selected.
|
||||
*/
|
||||
function resolveSelectedService() {
|
||||
return findService(selectedSite.value, selectedServiceId.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the currently selected signal from the selected service.
|
||||
*
|
||||
* @returns Selected signal or undefined when no signal is selected.
|
||||
*/
|
||||
function resolveSelectedSignal() {
|
||||
return findSignal(selectedService.value, selectedSignalId.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts events newest-first using their observed time while keeping stable IDs.
|
||||
*
|
||||
* @returns Event list used by the bottom stream panel.
|
||||
*/
|
||||
function resolveSortedEvents() {
|
||||
return recentEvents.value.toSorted(
|
||||
(left, right) => Date.parse(right.observedAt) - Date.parse(left.observedAt),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first non-green service as the default operational focus.
|
||||
*
|
||||
* @param sites Dashboard sites from the latest snapshot.
|
||||
* @returns Preferred site/service pair or undefined when all sites are empty.
|
||||
*/
|
||||
function findFirstAttentionService(sites: EnvironmentSite[]) {
|
||||
for (const site of sites) {
|
||||
for (const node of site.nodes) {
|
||||
const service = node.services.find(
|
||||
(item) => item.status !== 'ok' && item.status !== 'unknown',
|
||||
);
|
||||
if (service) return { service, site };
|
||||
}
|
||||
}
|
||||
for (const site of sites) {
|
||||
const service = getFirstService(site);
|
||||
if (service) return { service, site };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first service under a site.
|
||||
*
|
||||
* @param site Site selected by route state or fallback selection.
|
||||
* @returns First service in the site tree.
|
||||
*/
|
||||
function getFirstService(site?: EnvironmentSite) {
|
||||
return site?.nodes.flatMap((node) => node.services)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one service by ID within a site.
|
||||
*
|
||||
* @param site Site tree to inspect.
|
||||
* @param serviceId Service ID from user selection or persisted state.
|
||||
* @returns Matching service when still present.
|
||||
*/
|
||||
function findService(site?: EnvironmentSite, serviceId?: string) {
|
||||
if (!site || !serviceId) return undefined;
|
||||
for (const node of site.nodes) {
|
||||
const service = findServiceInNode(node, serviceId);
|
||||
if (service) return service;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one service by ID within a single node.
|
||||
*
|
||||
* @param node Node that owns service records.
|
||||
* @param serviceId Service ID from an SSE event or user selection.
|
||||
* @returns Matching service when present.
|
||||
*/
|
||||
function findServiceInNode(node: EnvironmentNode, serviceId: string) {
|
||||
return node.services.find((service) => service.id === serviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the node that owns a service ID.
|
||||
*
|
||||
* @param site Site tree to inspect.
|
||||
* @param serviceId Service ID from the SSE signal envelope.
|
||||
* @returns Owning node when the service exists.
|
||||
*/
|
||||
function findNodeByServiceId(site: EnvironmentSite, serviceId: string) {
|
||||
return site.nodes.find((node) =>
|
||||
node.services.some((service) => service.id === serviceId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one signal by ID within a service.
|
||||
*
|
||||
* @param service Service that owns signal records.
|
||||
* @param signalId Signal ID from selection or an SSE event.
|
||||
* @returns Matching signal when present.
|
||||
*/
|
||||
function findSignal(service?: EnvironmentService, signalId?: string) {
|
||||
if (!service || !signalId) return undefined;
|
||||
return service.signals.find((signal) => signal.id === signalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts event source metadata into signal source kinds supported by node badges.
|
||||
*
|
||||
* @param sourceKind Event source kind from the SSE envelope.
|
||||
* @returns Signal source kind stored on the affected signal.
|
||||
*/
|
||||
function mapEventSourceToSignalSource(
|
||||
sourceKind: EnvironmentEvent['sourceKind'],
|
||||
): EnvironmentSignal['sourceKind'] {
|
||||
if (sourceKind === 'mqtt' || sourceKind === 'local') return 'live';
|
||||
return sourceKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the strongest status for a node after a signal update.
|
||||
*
|
||||
* @param statuses Service statuses under the same node.
|
||||
* @returns Strongest health status according to dashboard severity rules.
|
||||
*/
|
||||
function pickWorstHealthStatus(
|
||||
statuses: EnvironmentHealthStatus[],
|
||||
): EnvironmentHealthStatus {
|
||||
const weights: Record<EnvironmentHealthStatus, number> = {
|
||||
blocked: 5,
|
||||
degraded: 2,
|
||||
down: 4,
|
||||
isolated: 3,
|
||||
ok: 0,
|
||||
unknown: 1,
|
||||
unwired: 1,
|
||||
};
|
||||
let worst: EnvironmentHealthStatus = 'ok';
|
||||
for (const status of statuses) {
|
||||
if (weights[status] > weights[worst]) {
|
||||
worst = status;
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps node health back into the coarser site status union.
|
||||
*
|
||||
* @param statuses Node statuses under the same site.
|
||||
* @returns Site-level status that never marks unknown/unwired integrations green.
|
||||
*/
|
||||
function mapSiteStatus(statuses: EnvironmentHealthStatus[]) {
|
||||
const worst = pickWorstHealthStatus(statuses);
|
||||
if (worst === 'ok') return 'online';
|
||||
if (worst === 'isolated') return 'isolated';
|
||||
if (worst === 'degraded' || worst === 'down' || worst === 'blocked') {
|
||||
return 'degraded';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes unknown thrown values from request wrappers into alert text.
|
||||
*
|
||||
* @param error Error thrown by the request client or runtime code.
|
||||
* @returns User-visible error summary.
|
||||
*/
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === 'string') return error;
|
||||
if (error && typeof error === 'object') {
|
||||
const record = error as Record<string, unknown>;
|
||||
return `${record.err || record.message || record.msg || '环境总览请求失败'}`;
|
||||
}
|
||||
return '环境总览请求失败';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<AnalysisOverview :items="overviewItems" />
|
||||
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
|
||||
<template #trends>
|
||||
<AnalyticsTrends />
|
||||
</template>
|
||||
<template #visits>
|
||||
<AnalyticsVisits />
|
||||
</template>
|
||||
</AnalysisChartsTabs>
|
||||
<div class="environment-dashboard-page">
|
||||
<EnvironmentStatusBar
|
||||
:dashboard="dashboard"
|
||||
:error-text="errorText"
|
||||
:loading="loading"
|
||||
:self-checking="selfChecking"
|
||||
:stream-state="streamState"
|
||||
@refresh="handleManualRefresh"
|
||||
@self-check="handleSelfCheck"
|
||||
/>
|
||||
|
||||
<div class="mt-5 w-full md:flex">
|
||||
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问数量">
|
||||
<AnalyticsVisitsData />
|
||||
</AnalysisChartCard>
|
||||
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问来源">
|
||||
<AnalyticsVisitsSource />
|
||||
</AnalysisChartCard>
|
||||
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="访问来源">
|
||||
<AnalyticsVisitsSales />
|
||||
</AnalysisChartCard>
|
||||
<div class="environment-dashboard-page__main">
|
||||
<EnvironmentSiteRail
|
||||
:selected-site-id="selectedSiteId"
|
||||
:sites="dashboard?.sites ?? []"
|
||||
@select-site="handleSiteSelect"
|
||||
/>
|
||||
<EnvironmentTopology
|
||||
:selected-service-id="selectedServiceId"
|
||||
:site="selectedSite"
|
||||
@select-service="handleServiceSelect"
|
||||
/>
|
||||
<EnvironmentEvidencePanel
|
||||
:actions="dashboard?.actions ?? []"
|
||||
:selected-service="selectedService"
|
||||
:selected-signal="selectedSignal"
|
||||
:selected-site="selectedSite"
|
||||
:self-checking="selfChecking"
|
||||
@self-check="handleSelfCheck"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EnvironmentEventStream :events="sortedEvents" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.environment-dashboard-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
background: hsl(210deg 25% 96%);
|
||||
}
|
||||
|
||||
.environment-dashboard-page__main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 260px) minmax(360px, 1fr) minmax(
|
||||
280px,
|
||||
340px
|
||||
);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (width <= 1180px) {
|
||||
.environment-dashboard-page__main {
|
||||
grid-template-columns: minmax(190px, 240px) 1fr;
|
||||
}
|
||||
|
||||
.environment-dashboard-page__main > :last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.environment-dashboard-page {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.environment-dashboard-page__main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.environment-dashboard-page__main > :last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
13
apps/web-antdv-next/src/views/dashboard/analytics/types.ts
Normal file
13
apps/web-antdv-next/src/views/dashboard/analytics/types.ts
Normal file
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user