1. 遥测系统 (Telemetry) 实现:在 telemetry.ts 中实现了路由变化API 请求及全局错误的自动化追踪,并修复了 ESLint 错误。2. 性能优化 (Performance):针对 ECharts 实现了动态导入和按需组件注册,减小首屏体积,优化分包策略。3. UI E2E 稳定性提升:完善 Playwright 配置,新增 Docker 运行脚本及详细指南,更CI 流程。4. 其它改进与修复:修复 401 自动跳转逻辑及多处 ESLint 问题。develop
parent
917b0aa9e4
commit
769f5e5d50
@ -0,0 +1,64 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElStatistic: typeof import('element-plus/es')['ElStatistic']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
function seedAuth(storage: { role?: "admin" | "operator" | "observer" } = {}) {
|
||||
const role = storage.role ?? "admin";
|
||||
return (window: Window) => {
|
||||
window.localStorage.setItem(
|
||||
"cm_user",
|
||||
JSON.stringify({ id: 1, username: "e2e", role })
|
||||
);
|
||||
window.localStorage.setItem("cm_token", "e2e-token");
|
||||
};
|
||||
}
|
||||
|
||||
test("未登录访问 diagnosis 会跳转到 login", async ({ page }) => {
|
||||
await page.goto("/#/diagnosis");
|
||||
await expect(page).toHaveURL(/#\/login/);
|
||||
await expect(page.getByText("登录", { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
test("已登录可打开 diagnosis 并完成一次聊天流式渲染", async ({ page }) => {
|
||||
await page.addInitScript(seedAuth({ role: "admin" }));
|
||||
|
||||
await page.route("**/api/v1/ai/history**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/clusters**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
clusters: [{ id: "c1", uuid: "c1", name: "cluster-1" }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/nodes**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
nodes: [{ name: "node-1", status: "running" }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/diagnosis/logs**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ logs: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/ai/chat**", async (route) => {
|
||||
const body =
|
||||
'data: {"content":"OK-1"}\n' + 'data: {"content":"OK-2"}\n' + "data: [DONE]\n";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
body,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/#/diagnosis");
|
||||
|
||||
await expect(page.getByText("诊断助手")).toBeVisible();
|
||||
await page.getByPlaceholder("支持Markdown输入... Enter 发送").fill("hello");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
|
||||
await expect(page.getByText("OK-1")).toBeVisible();
|
||||
await expect(page.getByText("OK-2")).toBeVisible();
|
||||
});
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("dev server 返回 index.html", async ({ request }) => {
|
||||
const res = await request.get("/");
|
||||
expect(res.status()).toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html).toContain('id="app"');
|
||||
});
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
outputDir: "test-results",
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { open: "never", outputFolder: "playwright-report" }],
|
||||
],
|
||||
use: { baseURL: "http://127.0.0.1:5173" },
|
||||
webServer: {
|
||||
command: "pnpm dev --host 127.0.0.1 --port 5173",
|
||||
url: "http://127.0.0.1:5173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "api",
|
||||
testMatch: /.*\.api\.spec\.ts/,
|
||||
},
|
||||
{
|
||||
name: "ui-chromium",
|
||||
testMatch: /.*\.ui\.spec\.ts/,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
trace: "retain-on-failure",
|
||||
video: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE="${PLAYWRIGHT_IMAGE:-mcr.microsoft.com/playwright:v1.57.0-jammy}"
|
||||
|
||||
docker run --rm -t \
|
||||
-v "${ROOT_DIR}:/work" \
|
||||
-w /work \
|
||||
"${IMAGE}" \
|
||||
bash -lc 'corepack enable && pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile && pnpm run e2e:ui'
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import type { EChartsType } from "echarts/core";
|
||||
|
||||
type EchartsModule = {
|
||||
init: (dom: HTMLElement, theme?: unknown, opts?: unknown) => EChartsType;
|
||||
use: (plugins: unknown[]) => void;
|
||||
};
|
||||
|
||||
let modulePromise: Promise<EchartsModule> | null = null;
|
||||
|
||||
export async function loadEcharts(): Promise<EchartsModule> {
|
||||
if (modulePromise) return modulePromise;
|
||||
modulePromise = (async () => {
|
||||
const echarts = (await import("echarts/core")) as unknown as EchartsModule;
|
||||
const { PieChart, LineChart } = await import("echarts/charts");
|
||||
const {
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
DatasetComponent,
|
||||
} = await import("echarts/components");
|
||||
const { CanvasRenderer } = await import("echarts/renderers");
|
||||
|
||||
echarts.use([
|
||||
PieChart,
|
||||
LineChart,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
DatasetComponent,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
return echarts;
|
||||
})();
|
||||
return modulePromise;
|
||||
}
|
||||
|
||||
@ -0,0 +1,118 @@
|
||||
type TelemetryLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
type TelemetryEvent = {
|
||||
name: string;
|
||||
level: TelemetryLevel;
|
||||
ts: number;
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type TelemetryConfig = {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
sampleRate: number;
|
||||
getContext?: () => Record<string, unknown>;
|
||||
};
|
||||
|
||||
let config: TelemetryConfig = {
|
||||
enabled: false,
|
||||
endpoint: "",
|
||||
sampleRate: 1,
|
||||
};
|
||||
|
||||
function clamp01(n: number) {
|
||||
if (Number.isNaN(n)) return 1;
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
|
||||
function safeString(v: unknown, maxLen = 400) {
|
||||
const s = typeof v === "string" ? v : JSON.stringify(v);
|
||||
return s.length > maxLen ? s.slice(0, maxLen) : s;
|
||||
}
|
||||
|
||||
function normalizeError(e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
return {
|
||||
name: e.name,
|
||||
message: safeString(e.message, 800),
|
||||
stack: safeString(e.stack || "", 2000),
|
||||
};
|
||||
}
|
||||
return { message: safeString(e, 800) };
|
||||
}
|
||||
|
||||
function shouldSend() {
|
||||
if (!config.enabled) return false;
|
||||
const r = clamp01(config.sampleRate);
|
||||
if (r >= 1) return true;
|
||||
return Math.random() < r;
|
||||
}
|
||||
|
||||
function mergeContext(extra?: Record<string, unknown>) {
|
||||
const base = config.getContext ? config.getContext() : {};
|
||||
return { ...base, ...(extra || {}) };
|
||||
}
|
||||
|
||||
function postEvent(evt: TelemetryEvent) {
|
||||
if (!config.endpoint) return;
|
||||
const payload = JSON.stringify(evt);
|
||||
|
||||
try {
|
||||
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
|
||||
const blob = new Blob([payload], { type: "application/json" });
|
||||
navigator.sendBeacon(config.endpoint, blob);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
|
||||
try {
|
||||
fetch(config.endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
}
|
||||
|
||||
export function initTelemetry(next: Partial<TelemetryConfig>) {
|
||||
config = {
|
||||
...config,
|
||||
...next,
|
||||
enabled: !!next.enabled,
|
||||
sampleRate: clamp01(Number(next.sampleRate ?? config.sampleRate)),
|
||||
endpoint: String(next.endpoint ?? config.endpoint),
|
||||
};
|
||||
}
|
||||
|
||||
export function trackEvent(name: string, context?: Record<string, unknown>, level: TelemetryLevel = "info") {
|
||||
if (!shouldSend()) return;
|
||||
postEvent({ name, level, ts: Date.now(), context: mergeContext(context) });
|
||||
}
|
||||
|
||||
export function trackError(name: string, error: unknown, context?: Record<string, unknown>) {
|
||||
postEvent({
|
||||
name,
|
||||
level: "error",
|
||||
ts: Date.now(),
|
||||
context: mergeContext({ error: normalizeError(error), ...(context || {}) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function installGlobalErrorHandlers() {
|
||||
window.addEventListener("error", (ev) => {
|
||||
trackError("window_error", ev.error || ev.message, {
|
||||
filename: ev.filename,
|
||||
lineno: ev.lineno,
|
||||
colno: ev.colno,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (ev) => {
|
||||
trackError("unhandledrejection", ev.reason);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue