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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"analytics": "Analytics",
|
"analytics": "Environment",
|
||||||
"workspace": "Workspace"
|
"workspace": "Workspace"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "概览",
|
"title": "概览",
|
||||||
"analytics": "分析页",
|
"analytics": "环境总览",
|
||||||
"workspace": "工作台"
|
"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>
|
<script lang="ts" setup>
|
||||||
import type { AnalysisOverviewItem } from '@vben/common-ui';
|
import type {
|
||||||
import type { TabOption } from '@vben/types';
|
EnvironmentDashboard,
|
||||||
|
EnvironmentEvent,
|
||||||
|
EnvironmentHealthStatus,
|
||||||
|
EnvironmentNode,
|
||||||
|
EnvironmentService,
|
||||||
|
EnvironmentSignal,
|
||||||
|
EnvironmentSite,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AnalysisChartCard,
|
getEnvironmentDashboard,
|
||||||
AnalysisChartsTabs,
|
runEnvironmentSelfCheck,
|
||||||
AnalysisOverview,
|
} from '#/api/system/environment';
|
||||||
} from '@vben/common-ui';
|
|
||||||
import {
|
|
||||||
SvgBellIcon,
|
|
||||||
SvgCakeIcon,
|
|
||||||
SvgCardIcon,
|
|
||||||
SvgDownloadIcon,
|
|
||||||
} from '@vben/icons';
|
|
||||||
|
|
||||||
import AnalyticsTrends from './analytics-trends.vue';
|
import EnvironmentEventStream from './components/EnvironmentEventStream.vue';
|
||||||
import AnalyticsVisitsData from './analytics-visits-data.vue';
|
import EnvironmentEvidencePanel from './components/EnvironmentEvidencePanel.vue';
|
||||||
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
|
import EnvironmentSiteRail from './components/EnvironmentSiteRail.vue';
|
||||||
import AnalyticsVisitsSource from './analytics-visits-source.vue';
|
import EnvironmentStatusBar from './components/EnvironmentStatusBar.vue';
|
||||||
import AnalyticsVisits from './analytics-visits.vue';
|
import EnvironmentTopology from './components/EnvironmentTopology.vue';
|
||||||
|
import { useEnvironmentDashboardStream } from './composables/useEnvironmentDashboardStream';
|
||||||
|
|
||||||
const overviewItems: AnalysisOverviewItem[] = [
|
type SnapshotLoadReason = 'initial' | 'manual' | 'snapshot-required';
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const chartTabs: TabOption[] = [
|
const dashboard = ref<EnvironmentDashboard>();
|
||||||
{
|
const errorText = ref('');
|
||||||
label: '流量趋势',
|
const loading = ref(false);
|
||||||
value: 'trends',
|
const recentEvents = ref<EnvironmentEvent[]>([]);
|
||||||
},
|
const selectedServiceId = ref<string>();
|
||||||
{
|
const selectedSignalId = ref<string>();
|
||||||
label: '月访问量',
|
const selectedSiteId = ref<string>();
|
||||||
value: 'visits',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-5">
|
<div class="environment-dashboard-page">
|
||||||
<AnalysisOverview :items="overviewItems" />
|
<EnvironmentStatusBar
|
||||||
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
|
:dashboard="dashboard"
|
||||||
<template #trends>
|
:error-text="errorText"
|
||||||
<AnalyticsTrends />
|
:loading="loading"
|
||||||
</template>
|
:self-checking="selfChecking"
|
||||||
<template #visits>
|
:stream-state="streamState"
|
||||||
<AnalyticsVisits />
|
@refresh="handleManualRefresh"
|
||||||
</template>
|
@self-check="handleSelfCheck"
|
||||||
</AnalysisChartsTabs>
|
/>
|
||||||
|
|
||||||
<div class="mt-5 w-full md:flex">
|
<div class="environment-dashboard-page__main">
|
||||||
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问数量">
|
<EnvironmentSiteRail
|
||||||
<AnalyticsVisitsData />
|
:selected-site-id="selectedSiteId"
|
||||||
</AnalysisChartCard>
|
:sites="dashboard?.sites ?? []"
|
||||||
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问来源">
|
@select-site="handleSiteSelect"
|
||||||
<AnalyticsVisitsSource />
|
/>
|
||||||
</AnalysisChartCard>
|
<EnvironmentTopology
|
||||||
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="访问来源">
|
:selected-service-id="selectedServiceId"
|
||||||
<AnalyticsVisitsSales />
|
:site="selectedSite"
|
||||||
</AnalysisChartCard>
|
@select-service="handleServiceSelect"
|
||||||
|
/>
|
||||||
|
<EnvironmentEvidencePanel
|
||||||
|
:actions="dashboard?.actions ?? []"
|
||||||
|
:selected-service="selectedService"
|
||||||
|
:selected-signal="selectedSignal"
|
||||||
|
:selected-site="selectedSite"
|
||||||
|
:self-checking="selfChecking"
|
||||||
|
@self-check="handleSelfCheck"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EnvironmentEventStream :events="sortedEvents" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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