feat: 重构分析页为环境总览总控面板

This commit is contained in:
sunlei 2026-06-18 16:49:06 +08:00
parent e5c8bd2826
commit cee5646668
13 changed files with 2258 additions and 79 deletions

View 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',
);
});
});

View 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(/^\/+/, '')}`;
}

View File

@ -11,7 +11,7 @@
},
"dashboard": {
"title": "Dashboard",
"analytics": "Analytics",
"analytics": "Environment",
"workspace": "Workspace"
}
}

View File

@ -11,7 +11,7 @@
},
"dashboard": {
"title": "概览",
"analytics": "分析页",
"analytics": "环境总览",
"workspace": "工作台"
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
};
}

View File

@ -0,0 +1,535 @@
/* @vitest-environment happy-dom */
/* eslint-disable vue/one-component-per-file */
import type { EnvironmentDashboardApi } from '#/api/system/environment';
import { mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
getEnvironmentDashboard,
runEnvironmentSelfCheck,
} from '#/api/system/environment';
import EnvironmentDashboardPage from './index.vue';
vi.mock('#/api/system/environment', () => ({
getEnvironmentDashboard: vi.fn(),
getEnvironmentDashboardEventsUrl: vi.fn((lastEventId?: string) =>
lastEventId
? `/system/environment/events/stream?lastEventId=${encodeURIComponent(lastEventId)}`
: '/system/environment/events/stream',
),
runEnvironmentSelfCheck: vi.fn(),
}));
vi.mock('antdv-next', () => ({
Alert: defineComponent({
name: 'MockAlert',
props: {
message: String,
type: String,
},
setup(props, { slots }) {
return () =>
h('div', { role: 'alert' }, [props.message, slots.description?.()]);
},
}),
Badge: defineComponent({
name: 'MockBadge',
props: {
status: String,
text: String,
},
setup(props) {
return () => h('span', props.text as string);
},
}),
Button: defineComponent({
name: 'MockButton',
props: {
disabled: Boolean,
loading: Boolean,
type: String,
},
emits: ['click'],
setup(props, { emit, slots }) {
return () =>
h(
'button',
{
disabled: props.disabled,
type: 'button',
onClick: () => emit('click'),
},
slots.default?.(),
);
},
}),
Card: defineComponent({
name: 'MockCard',
props: {
title: String,
},
setup(props, { slots }) {
return () =>
h('section', [
props.title ? h('h2', props.title as string) : null,
slots.default?.(),
]);
},
}),
Empty: defineComponent({
name: 'MockEmpty',
setup() {
return () => h('div', 'empty');
},
}),
Space: defineComponent({
name: 'MockSpace',
setup(_, { slots }) {
return () => h('div', slots.default?.());
},
}),
Spin: defineComponent({
name: 'MockSpin',
props: {
spinning: Boolean,
},
setup(_, { slots }) {
return () => h('div', slots.default?.());
},
}),
Tag: defineComponent({
name: 'MockTag',
props: {
color: String,
},
setup(_, { slots }) {
return () => h('span', slots.default?.());
},
}),
Tooltip: defineComponent({
name: 'MockTooltip',
props: {
title: String,
},
setup(_, { slots }) {
return () => h('span', slots.default?.());
},
}),
}));
type FakeEventSourceListener = (event: MessageEvent<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);
});
});

View File

@ -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>

View 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;