1. 页面结构:拆分 ResizableBar、SplitPane、LogViewer、ClusterNodeSelector、DiagnosisChatPanel 2. 逻辑抽离:新增 useCollapsiblePercent/useIsMobile/useScrollBottomHint/useClusterTree/useDiagnosisChat 3. 错误处理:新增 lib/errors.ts, Diagnosis/ClusterList 复用 4. 测试体系:引入 Vitest@vue/test-utils、happy-dom,新增 Diagnosis 关键链路单测 5. 工程化:新增 GitHub Actions 工作流,自动跑 typecheck/test/build;新增前端 scriptsdevelop
parent
2fbe40edac
commit
917b0aa9e4
@ -0,0 +1,36 @@
|
||||
name: frontend-vue-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "frontend-vue/**"
|
||||
- ".github/workflows/frontend-vue-ci.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "frontend-vue/**"
|
||||
- ".github/workflows/frontend-vue-ci.yml"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend-vue
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend-vue/pnpm-lock.yaml
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
- name: Test
|
||||
run: pnpm run test
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<el-card
|
||||
class="selection-card-vertical"
|
||||
shadow="never"
|
||||
:style="isMobile ? { height: percent + '%' } : { width: percent + '%' }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">集群与节点</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-scrollbar>
|
||||
<div class="cluster-groups-vertical">
|
||||
<div v-for="g in filteredGroups" :key="g.id" class="cluster-group-v">
|
||||
<div class="group-header" @click="toggleGroup(g)">
|
||||
<el-icon class="icon-mr">
|
||||
<ArrowDown v-if="g.open" />
|
||||
<ArrowRight v-else />
|
||||
</el-icon>
|
||||
<span class="group-name">{{ g.name }}</span>
|
||||
</div>
|
||||
<div v-if="g.open" class="node-list-v">
|
||||
<div
|
||||
v-for="n in nodesForGroup(g)"
|
||||
:key="n.name"
|
||||
class="node-item-v"
|
||||
:class="{ 'is-active': selectedNode === n.name }"
|
||||
@click="selectNode(n.name)"
|
||||
>
|
||||
<span class="status-dot icon-mr" :class="statusDot(n)"></span>
|
||||
<span class="node-name">{{ n.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowDown, ArrowRight } from "@element-plus/icons-vue";
|
||||
|
||||
type NodeItem = { name: string; status: string };
|
||||
type Group = { id: string; uuid: string; name: string; open: boolean; nodes: NodeItem[]; count?: number };
|
||||
|
||||
defineProps<{
|
||||
isMobile: boolean;
|
||||
percent: number;
|
||||
filteredGroups: Group[];
|
||||
selectedNode: string;
|
||||
nodesForGroup: (g: Group) => NodeItem[];
|
||||
toggleGroup: (g: Group) => void | Promise<void>;
|
||||
selectNode: (n: string) => void;
|
||||
statusDot: (n: NodeItem) => string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selection-card-vertical {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cluster-groups-vertical {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.cluster-group-v {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--app-text-primary);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.group-header:hover {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.node-list-v {
|
||||
margin-top: 4px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.node-item-v {
|
||||
padding: 6px 10px;
|
||||
margin: 2px 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.node-item-v:hover {
|
||||
background: var(--app-content-bg);
|
||||
}
|
||||
|
||||
.node-item-v.is-active {
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot-running {
|
||||
background-color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.status-dot-warning {
|
||||
background-color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.status-dot-error {
|
||||
background-color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.icon-mr {
|
||||
margin-right: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<el-card class="chat-card-full" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">诊断助手</span>
|
||||
<div class="agent-selectors">
|
||||
<el-select
|
||||
:model-value="model"
|
||||
size="small"
|
||||
style="width: 160px"
|
||||
@update:model-value="(v) => emit('update:model', String(v || ''))"
|
||||
>
|
||||
<el-option label="DeepSeek-V3" value="deepseek-ai/DeepSeek-V3" />
|
||||
<el-option label="DeepSeek-R1" value="Pro/deepseek-ai/DeepSeek-R1" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="chat-container">
|
||||
<div class="chat-history" :ref="setChatHistoryEl">
|
||||
<div
|
||||
v-for="(m, i) in visibleMessages"
|
||||
:key="'msg-' + i"
|
||||
class="chat-item"
|
||||
:class="m.role === 'user' ? 'chat-item-user' : 'chat-item-assistant'"
|
||||
>
|
||||
<div class="chat-role">{{ roleLabel(m.role) }}</div>
|
||||
<div class="chat-content">
|
||||
<div v-if="m.reasoning" class="reasoning-container">
|
||||
<el-collapse>
|
||||
<el-collapse-item title="推理过程">
|
||||
<pre class="reasoning-text">{{ m.reasoning }}</pre>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
<div class="message-text">{{ m.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="el-fade-in">
|
||||
<el-button
|
||||
v-if="showScrollBottom"
|
||||
class="scroll-bottom-btn"
|
||||
type="primary"
|
||||
circle
|
||||
@click="emit('scrollBottom')"
|
||||
>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
</transition>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<el-input
|
||||
:model-value="inputMsg"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="支持Markdown输入... Enter 发送"
|
||||
:disabled="sending"
|
||||
@keydown.enter.exact.prevent="emit('send')"
|
||||
@update:model-value="(v) => emit('update:inputMsg', String(v || ''))"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<div class="option-checks">
|
||||
<el-checkbox
|
||||
:model-value="useWebSearch"
|
||||
label="搜索"
|
||||
size="small"
|
||||
@update:model-value="(v) => emit('update:useWebSearch', !!v)"
|
||||
/>
|
||||
<el-checkbox
|
||||
:model-value="useClusterOps"
|
||||
label="操作"
|
||||
size="small"
|
||||
@update:model-value="(v) => emit('update:useClusterOps', !!v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<el-button v-if="!sending" type="primary" :disabled="!inputMsg.trim()" @click="emit('send')">
|
||||
发送
|
||||
</el-button>
|
||||
<el-button v-else type="danger" plain @click="emit('stop')">停止</el-button>
|
||||
<el-button type="warning" plain :disabled="sending" @click="emit('diagnose')">深度诊断</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowDown } from "@element-plus/icons-vue";
|
||||
|
||||
type Message = { role: "user" | "assistant" | "system"; content: string; reasoning?: string };
|
||||
|
||||
defineProps<{
|
||||
model: string;
|
||||
visibleMessages: Message[];
|
||||
showScrollBottom: boolean;
|
||||
inputMsg: string;
|
||||
sending: boolean;
|
||||
useWebSearch: boolean;
|
||||
useClusterOps: boolean;
|
||||
roleLabel: (r: string) => string;
|
||||
setChatHistoryEl: (el: Element | null) => void;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:model", v: string): void;
|
||||
(e: "update:inputMsg", v: string): void;
|
||||
(e: "update:useWebSearch", v: boolean): void;
|
||||
(e: "update:useClusterOps", v: boolean): void;
|
||||
(e: "scrollBottom"): void;
|
||||
(e: "send"): void;
|
||||
(e: "stop"): void;
|
||||
(e: "diagnose"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-card-full {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
border-left: 1px solid var(--app-border-color);
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-history {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 8px;
|
||||
margin-bottom: 16px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.scroll-bottom-btn {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 160px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chat-role {
|
||||
font-size: 12px;
|
||||
color: var(--app-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-item-user .chat-role {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--app-card-bg);
|
||||
border: 1px solid var(--app-border-color);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-item-user .chat-content {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--app-border-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="log-viewer">
|
||||
<div v-if="!node" class="empty-preview">请选择节点预览日志</div>
|
||||
<div v-else class="log-preview-container">
|
||||
<div class="filter-bar">
|
||||
<el-select
|
||||
:model-value="level"
|
||||
placeholder="级别"
|
||||
size="small"
|
||||
clearable
|
||||
style="width: 80px"
|
||||
@update:model-value="(v) => emit('update:level', String(v || ''))"
|
||||
>
|
||||
<el-option label="INFO" value="info" />
|
||||
<el-option label="WARN" value="warning" />
|
||||
<el-option label="ERROR" value="error" />
|
||||
</el-select>
|
||||
<el-select
|
||||
:model-value="timeRange"
|
||||
placeholder="时间"
|
||||
size="small"
|
||||
clearable
|
||||
style="width: 90px"
|
||||
@update:model-value="(v) => emit('update:timeRange', String(v || ''))"
|
||||
>
|
||||
<el-option label="1h" value="1h" />
|
||||
<el-option label="6h" value="6h" />
|
||||
<el-option label="24h" value="24h" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="log-list-wrapper">
|
||||
<div class="log-list-head">
|
||||
<div class="cell time">时间</div>
|
||||
<div class="cell level">级别</div>
|
||||
<div class="cell msg">消息</div>
|
||||
</div>
|
||||
|
||||
<div ref="viewportEl" class="log-viewport" @scroll="onScroll">
|
||||
<div class="log-spacer" :style="{ height: totalHeight + 'px' }"></div>
|
||||
<div
|
||||
class="log-items"
|
||||
:style="{ transform: `translateY(${offsetY}px)` }"
|
||||
>
|
||||
<div v-for="row in visibleLogs" :key="row.id" class="log-row">
|
||||
<div class="cell time">
|
||||
{{ row.time.split("T")[1]?.slice(0, 8) || row.time }}
|
||||
</div>
|
||||
<div class="cell level">
|
||||
<el-tag
|
||||
:type="getLevelType(row.level)"
|
||||
size="small"
|
||||
effect="dark"
|
||||
>
|
||||
{{ row.level.charAt(0).toUpperCase() }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="cell msg" :title="row.message">{{ row.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { LogService } from "../api/log.service";
|
||||
|
||||
type LogItem = {
|
||||
id: number | string;
|
||||
time: string;
|
||||
level: string;
|
||||
source: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
node: string;
|
||||
level: string;
|
||||
timeRange: string;
|
||||
paused?: boolean;
|
||||
size?: number;
|
||||
pollMs?: number;
|
||||
}>(),
|
||||
{ paused: false, size: 50, pollMs: 5000 }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:level", v: string): void;
|
||||
(e: "update:timeRange", v: string): void;
|
||||
}>();
|
||||
|
||||
const logs = ref<LogItem[]>([]);
|
||||
const viewportEl = ref<HTMLDivElement | null>(null);
|
||||
const scrollTop = ref(0);
|
||||
const viewportHeight = ref(0);
|
||||
const rowHeight = 34;
|
||||
const overscan = 8;
|
||||
let timer: any = null;
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
function onScroll() {
|
||||
if (!viewportEl.value) return;
|
||||
scrollTop.value = viewportEl.value.scrollTop;
|
||||
}
|
||||
|
||||
const totalHeight = computed(() => logs.value.length * rowHeight);
|
||||
|
||||
const startIndex = computed(() => {
|
||||
const base = Math.floor(scrollTop.value / rowHeight);
|
||||
return Math.max(0, base - overscan);
|
||||
});
|
||||
|
||||
const endIndex = computed(() => {
|
||||
const count = Math.ceil(viewportHeight.value / rowHeight) + overscan * 2;
|
||||
return Math.min(logs.value.length, startIndex.value + count);
|
||||
});
|
||||
|
||||
const offsetY = computed(() => startIndex.value * rowHeight);
|
||||
const visibleLogs = computed(() =>
|
||||
logs.value.slice(startIndex.value, endIndex.value)
|
||||
);
|
||||
|
||||
function getLevelType(level: string) {
|
||||
switch (level.toLowerCase()) {
|
||||
case "error":
|
||||
return "danger";
|
||||
case "warning":
|
||||
case "warn":
|
||||
return "warning";
|
||||
case "info":
|
||||
return "info";
|
||||
case "success":
|
||||
return "success";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
if (!props.node) {
|
||||
logs.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const params: any = { node: props.node, size: props.size };
|
||||
if (props.level) params.level = props.level;
|
||||
if (props.timeRange) {
|
||||
const now = Date.now();
|
||||
const r = props.timeRange;
|
||||
const span =
|
||||
r === "1h"
|
||||
? 60 * 60 * 1000
|
||||
: r === "6h"
|
||||
? 6 * 60 * 60 * 1000
|
||||
: r === "24h"
|
||||
? 24 * 60 * 60 * 1000
|
||||
: r === "7d"
|
||||
? 7 * 24 * 60 * 60 * 1000
|
||||
: 0;
|
||||
if (span) params.time_from = new Date(now - span).toISOString();
|
||||
}
|
||||
const { items } = await LogService.list(params);
|
||||
logs.value = (items || []).map((d: any, i: number) => ({
|
||||
id: d.log_id || d.id || i,
|
||||
time: d.log_time || d.time || d.timestamp || new Date().toISOString(),
|
||||
level: String(d.level || "info").toLowerCase(),
|
||||
source: String(
|
||||
d.title || d.source || d.node_host || d.node || d.host || ""
|
||||
),
|
||||
message: d.info || d.message || "",
|
||||
}));
|
||||
} catch {
|
||||
logs.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
timer = setInterval(() => {
|
||||
if (props.node && !props.paused) loadLogs();
|
||||
}, props.pollMs);
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.node, props.level, props.timeRange],
|
||||
() => {
|
||||
loadLogs();
|
||||
if (viewportEl.value) viewportEl.value.scrollTop = 0;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (viewportEl.value) viewportHeight.value = viewportEl.value.clientHeight;
|
||||
ro = new ResizeObserver(() => {
|
||||
if (viewportEl.value) viewportHeight.value = viewportEl.value.clientHeight;
|
||||
});
|
||||
if (viewportEl.value) ro.observe(viewportEl.value);
|
||||
startPoll();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPoll();
|
||||
if (ro && viewportEl.value) ro.unobserve(viewportEl.value);
|
||||
ro = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-viewer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-preview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--app-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-preview-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.log-list-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--app-border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.log-list-head {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 80px 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--app-text-secondary);
|
||||
border-bottom: 1px solid var(--app-border-color);
|
||||
background: var(--app-card-bg);
|
||||
}
|
||||
|
||||
.log-viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.log-spacer {
|
||||
width: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.log-items {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
height: 34px;
|
||||
display: grid;
|
||||
grid-template-columns: 90px 80px 1fr;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--app-border-color);
|
||||
}
|
||||
|
||||
.cell.msg {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="resizer-bar" :class="barClass" @mousedown="start">
|
||||
<div class="resizer-handle"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
container: HTMLElement | null;
|
||||
isMobile: boolean;
|
||||
modelValue: number;
|
||||
maxPercent: number;
|
||||
collapseThreshold?: number;
|
||||
barClass?: string;
|
||||
}>(),
|
||||
{ collapseThreshold: 5, barClass: "" }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", v: number): void;
|
||||
(e: "dragging", v: boolean): void;
|
||||
}>();
|
||||
|
||||
let isDragging = false;
|
||||
|
||||
function computePercent(e: MouseEvent) {
|
||||
const containerRect = props.container?.getBoundingClientRect();
|
||||
if (!containerRect) return null;
|
||||
if (props.isMobile) {
|
||||
return ((e.clientY - containerRect.top) / containerRect.height) * 100;
|
||||
}
|
||||
return ((e.clientX - containerRect.left) / containerRect.width) * 100;
|
||||
}
|
||||
|
||||
function onMove(e: MouseEvent) {
|
||||
if (!isDragging) return;
|
||||
const next = computePercent(e);
|
||||
if (next == null) return;
|
||||
if (next < props.collapseThreshold) {
|
||||
emit("update:modelValue", 0);
|
||||
return;
|
||||
}
|
||||
if (next < props.maxPercent) {
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
emit("dragging", false);
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", stop);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}
|
||||
|
||||
function start(e: MouseEvent) {
|
||||
if (!props.container) return;
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
emit("dragging", true);
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", stop);
|
||||
document.body.style.cursor = props.isMobile ? "row-resize" : "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => stop());
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resizer-bar {
|
||||
width: 8px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resizer-bar:hover {
|
||||
background: var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.resizer-handle {
|
||||
width: 2px;
|
||||
height: 40px;
|
||||
background: var(--app-border-color);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.inner-resizer {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.resizer-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
.resizer-handle {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.inner-resizer {
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<slot name="first"></slot>
|
||||
<ResizableBar
|
||||
v-if="resizable"
|
||||
:container="container"
|
||||
:is-mobile="isMobile"
|
||||
:max-percent="maxPercent"
|
||||
:bar-class="barClass"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="(v) => emit('update:modelValue', v)"
|
||||
@dragging="(v) => emit('dragging', v)"
|
||||
/>
|
||||
<slot name="second"></slot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ResizableBar from "./ResizableBar.vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
container: HTMLElement | null;
|
||||
isMobile: boolean;
|
||||
modelValue: number;
|
||||
maxPercent: number;
|
||||
resizable?: boolean;
|
||||
barClass?: string;
|
||||
}>(),
|
||||
{ resizable: true, barClass: "" }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", v: number): void;
|
||||
(e: "dragging", v: boolean): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@ -0,0 +1,135 @@
|
||||
import { computed, reactive, ref, type Ref } from "vue";
|
||||
import { ClusterService } from "../api/cluster.service";
|
||||
import { NodeService } from "../api/node.service";
|
||||
|
||||
type NodeItem = { name: string; status: string };
|
||||
export type ClusterGroup = {
|
||||
id: string;
|
||||
uuid: string;
|
||||
name: string;
|
||||
open: boolean;
|
||||
nodes: NodeItem[];
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export function useClusterTree(options: {
|
||||
kw: Ref<string>;
|
||||
filters: { cluster: string; node: string };
|
||||
selectedNode: Ref<string>;
|
||||
setError: (e: any, def: string) => void;
|
||||
}) {
|
||||
const groups = reactive<ClusterGroup[]>([]);
|
||||
const loadingSidebar = ref(false);
|
||||
|
||||
function pad3(n: number) {
|
||||
return String(n).padStart(3, "0");
|
||||
}
|
||||
|
||||
async function loadClusters() {
|
||||
loadingSidebar.value = true;
|
||||
try {
|
||||
const list = await ClusterService.list();
|
||||
const mapped: ClusterGroup[] = list
|
||||
.map((x: any) => ({
|
||||
id: String(x.uuid || x.id || x.host || x.name || ""),
|
||||
uuid: String(x.uuid || x.id || ""),
|
||||
name: String(x.host || x.name || x.uuid || ""),
|
||||
open: false,
|
||||
nodes: [],
|
||||
count: Number(x.count) || 0,
|
||||
}))
|
||||
.filter((g: any) => g.id && g.name);
|
||||
groups.splice(0, groups.length, ...mapped);
|
||||
} catch (e: any) {
|
||||
options.setError(e, "集群列表加载失败");
|
||||
} finally {
|
||||
loadingSidebar.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNodesFor(clusterUuid: string) {
|
||||
const g = groups.find((x) => x.uuid === clusterUuid);
|
||||
if (!g) return;
|
||||
const clusterName = g.name;
|
||||
try {
|
||||
const nodes = await NodeService.listByCluster(clusterUuid);
|
||||
const mappedNodes = nodes
|
||||
.map((x: any) => ({
|
||||
name: String(x?.name || x),
|
||||
status: x?.status || "running",
|
||||
}))
|
||||
.filter((x: any) => x.name);
|
||||
if (mappedNodes.length) g.nodes = mappedNodes;
|
||||
else if ((g.count || 0) > 0)
|
||||
g.nodes = Array.from({ length: g.count as number }, (_, i) => ({
|
||||
name: `${clusterName}-${pad3(i + 1)}`,
|
||||
status: "running",
|
||||
}));
|
||||
} catch (e: any) {
|
||||
if ((g.count || 0) > 0 && g.nodes.length === 0)
|
||||
g.nodes = Array.from({ length: g.count as number }, (_, i) => ({
|
||||
name: `${clusterName}-${pad3(i + 1)}`,
|
||||
status: "running",
|
||||
}));
|
||||
options.setError(e, "节点列表加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleGroup(g: ClusterGroup) {
|
||||
g.open = !g.open;
|
||||
if (g.open && g.nodes.length === 0) await loadNodesFor(g.uuid);
|
||||
}
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const kraw = options.kw.value.trim().toLowerCase();
|
||||
let base = groups.filter((g) => !options.filters.cluster || g.name === options.filters.cluster);
|
||||
if (kraw) {
|
||||
base = base.filter(
|
||||
(g) =>
|
||||
g.name.toLowerCase().includes(kraw) ||
|
||||
g.nodes.some((n) => n.name.toLowerCase().includes(kraw))
|
||||
);
|
||||
}
|
||||
if (options.filters.node) {
|
||||
base = base.filter((g) => g.nodes.some((n) => n.name === options.filters.node));
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
function nodesForGroup(g: ClusterGroup) {
|
||||
const k = options.kw.value.trim().toLowerCase();
|
||||
let nodes = g.nodes;
|
||||
if (k) nodes = nodes.filter((n) => n.name.toLowerCase().includes(k) || g.name.toLowerCase().includes(k));
|
||||
if (options.filters.node) nodes = nodes.filter((n) => n.name === options.filters.node);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function statusDot(n: { name: string; status: string }) {
|
||||
if (n.status === "running") return "status-dot-running";
|
||||
if (n.status === "warning") return "status-dot-warning";
|
||||
if (n.status === "error") return "status-dot-error";
|
||||
return n.name.includes("003")
|
||||
? "status-dot-error"
|
||||
: n.name.includes("002")
|
||||
? "status-dot-warning"
|
||||
: "status-dot-running";
|
||||
}
|
||||
|
||||
const selectedClusterUuid = computed(() => {
|
||||
if (!options.selectedNode.value) return "";
|
||||
const group = groups.find((g) => g.nodes.some((n) => n.name === options.selectedNode.value));
|
||||
return group ? group.uuid : "";
|
||||
});
|
||||
|
||||
return {
|
||||
groups,
|
||||
loadingSidebar,
|
||||
filteredGroups,
|
||||
nodesForGroup,
|
||||
toggleGroup,
|
||||
statusDot,
|
||||
selectedClusterUuid,
|
||||
loadClusters,
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
export function useCollapsiblePercent(options: {
|
||||
initialPercent: number;
|
||||
defaultExpandedPercent: number;
|
||||
minRememberPercent?: number;
|
||||
}) {
|
||||
const minRememberPercent = options.minRememberPercent ?? 0;
|
||||
const percent = ref(options.initialPercent);
|
||||
const lastPercent = ref(options.initialPercent);
|
||||
const collapsed = ref(options.initialPercent <= 0);
|
||||
|
||||
function toggle() {
|
||||
if (collapsed.value) {
|
||||
percent.value =
|
||||
lastPercent.value > minRememberPercent
|
||||
? lastPercent.value
|
||||
: options.defaultExpandedPercent;
|
||||
collapsed.value = false;
|
||||
} else {
|
||||
lastPercent.value = percent.value;
|
||||
percent.value = 0;
|
||||
collapsed.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
watch(percent, (v) => {
|
||||
if (v > 0) {
|
||||
collapsed.value = false;
|
||||
lastPercent.value = v;
|
||||
} else {
|
||||
collapsed.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { percent, lastPercent, collapsed, toggle };
|
||||
}
|
||||
|
||||
@ -0,0 +1,211 @@
|
||||
import { computed, nextTick, reactive, ref, type Ref } from "vue";
|
||||
import type { ClusterGroup } from "./useClusterTree";
|
||||
import { DiagnosisService } from "../api/diagnosis.service";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
type Message = { role: "user" | "assistant" | "system"; content: string; reasoning?: string };
|
||||
|
||||
export function useDiagnosisChat(options: {
|
||||
authToken: Ref<string | null>;
|
||||
agent: Ref<string>;
|
||||
model: Ref<string>;
|
||||
selectedNode: Ref<string>;
|
||||
selectedClusterUuid: Ref<string>;
|
||||
groups: ClusterGroup[];
|
||||
useWebSearch: Ref<boolean>;
|
||||
useClusterOps: Ref<boolean>;
|
||||
setError: (e: any, def: string) => void;
|
||||
clearError: () => void;
|
||||
scrollToLatest: () => void;
|
||||
}) {
|
||||
const messages = ref<Message[]>([
|
||||
{ role: "system", content: "欢迎使用多智能体诊断面板" },
|
||||
{ role: "assistant", content: "请在左侧选择节点并开始诊断" },
|
||||
]);
|
||||
const visibleMessages = computed(() => messages.value.filter((m) => m.role !== "system"));
|
||||
|
||||
const inputMsg = ref("");
|
||||
const sending = ref(false);
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
function stopGeneration() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
abortController = null;
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionIdOf() {
|
||||
return options.selectedNode.value ? `diagnosis-${options.selectedNode.value}` : "diagnosis-global";
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
options.clearError();
|
||||
try {
|
||||
const r = await DiagnosisService.getHistory(sessionIdOf());
|
||||
const list = Array.isArray(r?.messages) ? r.messages : [];
|
||||
messages.value = list.map((m: any) => ({
|
||||
role: m.role || "assistant",
|
||||
content: String(m.content || ""),
|
||||
reasoning: m.reasoning || m.reasoning_content,
|
||||
}));
|
||||
await nextTick();
|
||||
options.scrollToLatest();
|
||||
} catch (e: any) {
|
||||
options.setError(e, "历史记录加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const msg = inputMsg.value.trim();
|
||||
if (!msg) return;
|
||||
sending.value = true;
|
||||
options.clearError();
|
||||
abortController = new AbortController();
|
||||
|
||||
const userMsg = { role: "user" as const, content: msg };
|
||||
messages.value.push(userMsg);
|
||||
|
||||
const assistantMsg = reactive({ role: "assistant" as const, content: "", reasoning: "" });
|
||||
messages.value.push(assistantMsg);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v1/ai/chat", {
|
||||
method: "POST",
|
||||
signal: abortController.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
...(options.authToken.value ? { Authorization: `Bearer ${options.authToken.value}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: sessionIdOf(),
|
||||
message: msg,
|
||||
stream: true,
|
||||
context: {
|
||||
webSearch: options.useWebSearch.value,
|
||||
clusterOps: options.useClusterOps.value,
|
||||
agent: options.agent.value,
|
||||
node: options.selectedNode.value || "",
|
||||
cluster: options.selectedClusterUuid.value || "",
|
||||
model: options.model.value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error("无法读取响应流");
|
||||
|
||||
inputMsg.value = "";
|
||||
|
||||
let buffer = "";
|
||||
let hasReceivedContent = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith("data: ")) continue;
|
||||
|
||||
const jsonStr = trimmed.slice(6);
|
||||
if (jsonStr === "[DONE]") break;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
const text = data.content || data.reply || data.message || "";
|
||||
if (text) {
|
||||
assistantMsg.content += text;
|
||||
hasReceivedContent = true;
|
||||
}
|
||||
if (data.reasoning || data.reasoning_content) {
|
||||
assistantMsg.reasoning += data.reasoning || data.reasoning_content;
|
||||
hasReceivedContent = true;
|
||||
}
|
||||
await nextTick();
|
||||
options.scrollToLatest();
|
||||
} catch (e) {
|
||||
console.error("解析流数据失败", e, jsonStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasReceivedContent) {
|
||||
options.setError({ friendlyMessage: "后端已响应但未返回任何有效诊断内容。" }, "消息发送失败");
|
||||
messages.value.pop();
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === "AbortError") return;
|
||||
options.setError(e, "消息发送失败");
|
||||
messages.value.pop();
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function diagnose() {
|
||||
if (!options.selectedNode.value) {
|
||||
ElMessage.warning("请先在左侧选择一个节点进行诊断");
|
||||
return;
|
||||
}
|
||||
|
||||
const group = options.groups.find((g) => g.nodes.some((n) => n.name === options.selectedNode.value));
|
||||
if (!group) {
|
||||
ElMessage.error("无法确定节点所属集群");
|
||||
return;
|
||||
}
|
||||
|
||||
sending.value = true;
|
||||
options.clearError();
|
||||
|
||||
try {
|
||||
const res = await DiagnosisService.diagnoseRepair({
|
||||
cluster: group.uuid,
|
||||
model: options.model.value,
|
||||
auto: true,
|
||||
maxSteps: 3,
|
||||
});
|
||||
|
||||
messages.value.push({
|
||||
role: "assistant",
|
||||
content: `深度诊断已完成:\n${res.summary || res.message || "诊断完成,请查看报告。"}`,
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
options.scrollToLatest();
|
||||
} catch (e: any) {
|
||||
options.setError(e, "深度诊断请求失败");
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateReport() {
|
||||
inputMsg.value =
|
||||
inputMsg.value ||
|
||||
`请根据当前节点${options.selectedNode.value || "(未选定)"}最近关键日志生成一份状态报告(包含症状、影响范围、根因假设与建议)。`;
|
||||
await send();
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
visibleMessages,
|
||||
inputMsg,
|
||||
sending,
|
||||
stopGeneration,
|
||||
loadHistory,
|
||||
send,
|
||||
diagnose,
|
||||
generateReport,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
export function useIsMobile(breakpoint = 1024) {
|
||||
const isMobile = ref(window.innerWidth <= breakpoint);
|
||||
|
||||
function update() {
|
||||
isMobile.value = window.innerWidth <= breakpoint;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("resize", update);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", update);
|
||||
});
|
||||
|
||||
return { isMobile, update };
|
||||
}
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
export function useScrollBottomHint(options?: { threshold?: number }) {
|
||||
const threshold = options?.threshold ?? 200;
|
||||
const el = ref<HTMLElement | null>(null);
|
||||
const showScrollBottom = ref(false);
|
||||
|
||||
function handleScroll() {
|
||||
if (!el.value) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = el.value;
|
||||
showScrollBottom.value = scrollHeight - scrollTop - clientHeight > threshold;
|
||||
}
|
||||
|
||||
function setEl(v: Element | null) {
|
||||
el.value = (v as HTMLElement | null) || null;
|
||||
}
|
||||
|
||||
function scrollToBottom(smooth = true) {
|
||||
nextTick(() => {
|
||||
if (!el.value) return;
|
||||
el.value.scrollTo({
|
||||
top: el.value.scrollHeight,
|
||||
behavior: smooth ? "smooth" : "auto",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
watch(el, (next, prev) => {
|
||||
if (prev) prev.removeEventListener("scroll", handleScroll);
|
||||
if (next) next.addEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (el.value) el.value.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
return { el, setEl, showScrollBottom, scrollToBottom };
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
export function formatError(
|
||||
e: any,
|
||||
defaultMsg: string,
|
||||
options?: { mode?: "diagnosis" | "clusterList" }
|
||||
): string {
|
||||
const mode = options?.mode || "diagnosis";
|
||||
if (mode === "clusterList") {
|
||||
if (e?.response) {
|
||||
const s = e.response.status;
|
||||
const d = e.response.data;
|
||||
let detail = "";
|
||||
if (d?.detail) {
|
||||
if (typeof d.detail === "string") detail = d.detail;
|
||||
else if (Array.isArray(d.detail?.errors)) {
|
||||
detail = d.detail.errors
|
||||
.map((x: any) => {
|
||||
let msg = x?.message || "未知错误";
|
||||
if (x?.field) msg = `[${x.field}] ${msg}`;
|
||||
return msg;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
}
|
||||
return detail || `请求异常 (${s})`;
|
||||
}
|
||||
return e?.message || defaultMsg;
|
||||
}
|
||||
|
||||
if (e instanceof Error && !(e as any).response) {
|
||||
return e.message || "网络请求异常";
|
||||
}
|
||||
const r = e?.response;
|
||||
const s = r?.status;
|
||||
const d = r?.data;
|
||||
const detail = typeof d?.detail === "string" ? d.detail : "";
|
||||
const msgs: string[] = [];
|
||||
if (s) msgs.push(`HTTP ${s}`);
|
||||
if (detail) msgs.push(detail);
|
||||
if (!msgs.length) msgs.push(r ? defaultMsg : "网络连接异常");
|
||||
return msgs.join(" | ");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,240 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Diagnosis from "../Diagnosis.vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
elMessageWarning: vi.fn(),
|
||||
elMessageError: vi.fn(),
|
||||
mockClusterList: vi.fn(),
|
||||
mockNodesByCluster: vi.fn(),
|
||||
mockGetHistory: vi.fn(),
|
||||
mockDiagnoseRepair: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("element-plus", () => ({
|
||||
ElMessage: {
|
||||
warning: mocks.elMessageWarning,
|
||||
error: mocks.elMessageError,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../api/cluster.service", () => ({
|
||||
ClusterService: { list: () => mocks.mockClusterList() },
|
||||
}));
|
||||
|
||||
vi.mock("../../api/node.service", () => ({
|
||||
NodeService: { listByCluster: (uuid: string) => mocks.mockNodesByCluster(uuid) },
|
||||
}));
|
||||
|
||||
vi.mock("../../api/diagnosis.service", () => ({
|
||||
DiagnosisService: {
|
||||
getHistory: (sid: string) => mocks.mockGetHistory(sid),
|
||||
diagnoseRepair: (p: any) => mocks.mockDiagnoseRepair(p),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../stores/auth", () => ({
|
||||
useAuthStore: () => ({ token: "test-token" }),
|
||||
}));
|
||||
|
||||
function flush() {
|
||||
return new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
SplitPane: {
|
||||
template: "<div><slot name='first' /><slot name='second' /></div>",
|
||||
},
|
||||
LogViewer: {
|
||||
template: "<div></div>",
|
||||
},
|
||||
transition: { template: "<div><slot /></div>" },
|
||||
"el-icon": { template: "<i><slot /></i>" },
|
||||
"el-tag": { props: ["type", "size"], template: "<span><slot /></span>" },
|
||||
"el-scrollbar": { template: "<div><slot /></div>" },
|
||||
"el-card": {
|
||||
template:
|
||||
'<section class="el-card"><header class="el-card__header"><slot name="header"/></header><div class="el-card__body"><slot /></div></section>',
|
||||
},
|
||||
"el-select": {
|
||||
props: ["modelValue"],
|
||||
emits: ["update:modelValue"],
|
||||
template:
|
||||
'<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target && $event.target.value)"><slot /></select>',
|
||||
},
|
||||
"el-option": { template: "<option></option>" },
|
||||
"el-input": {
|
||||
props: ["modelValue", "type", "rows", "disabled", "placeholder"],
|
||||
emits: ["update:modelValue"],
|
||||
template:
|
||||
'<textarea :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target && $event.target.value)" />',
|
||||
},
|
||||
"el-button": {
|
||||
props: ["disabled", "type", "plain", "circle", "loading", "link"],
|
||||
emits: ["click"],
|
||||
template:
|
||||
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
"el-checkbox": {
|
||||
props: ["modelValue", "label", "disabled"],
|
||||
emits: ["update:modelValue"],
|
||||
template:
|
||||
'<label><input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'update:modelValue\', $event.target && $event.target.checked)" /><span><slot /></span></label>',
|
||||
},
|
||||
"el-collapse": { template: "<div><slot /></div>" },
|
||||
"el-collapse-item": { template: "<div><slot /></div>" },
|
||||
};
|
||||
|
||||
describe("Diagnosis关键链路", () => {
|
||||
beforeEach(() => {
|
||||
mocks.elMessageWarning.mockReset();
|
||||
mocks.elMessageError.mockReset();
|
||||
mocks.mockClusterList.mockReset();
|
||||
mocks.mockNodesByCluster.mockReset();
|
||||
mocks.mockGetHistory.mockReset();
|
||||
mocks.mockDiagnoseRepair.mockReset();
|
||||
});
|
||||
|
||||
it("页面能打开、选择节点会拉取历史", async () => {
|
||||
mocks.mockClusterList.mockResolvedValue([
|
||||
{ uuid: "c1", host: "cluster-1", count: 1 },
|
||||
]);
|
||||
mocks.mockNodesByCluster.mockResolvedValue([{ name: "node-1", status: "running" }]);
|
||||
mocks.mockGetHistory
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ role: "assistant", content: "global-history" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ role: "assistant", content: "node-history" }],
|
||||
});
|
||||
|
||||
const wrapper = mount(Diagnosis, {
|
||||
global: { stubs },
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(mocks.mockGetHistory).toHaveBeenCalledWith("diagnosis-global");
|
||||
|
||||
const groupHeader = wrapper.find(".group-header");
|
||||
expect(groupHeader.exists()).toBe(true);
|
||||
await groupHeader.trigger("click");
|
||||
await flush();
|
||||
|
||||
const nodeItem = wrapper.find(".node-item-v");
|
||||
expect(nodeItem.exists()).toBe(true);
|
||||
await nodeItem.trigger("click");
|
||||
await flush();
|
||||
|
||||
expect(mocks.mockGetHistory).toHaveBeenCalledWith("diagnosis-node-1");
|
||||
});
|
||||
|
||||
it("发送消息、停止、深度诊断按钮校验、滚动到底部提示", async () => {
|
||||
mocks.mockClusterList.mockResolvedValue([{ uuid: "c1", host: "cluster-1", count: 1 }]);
|
||||
mocks.mockNodesByCluster.mockResolvedValue([{ name: "node-1", status: "running" }]);
|
||||
mocks.mockGetHistory.mockResolvedValue({ messages: [{ role: "assistant", content: "history" }] });
|
||||
mocks.mockDiagnoseRepair.mockResolvedValue({ summary: "ok" });
|
||||
|
||||
const wrapper = mount(Diagnosis, {
|
||||
global: { stubs },
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
await wrapper.find(".group-header").trigger("click");
|
||||
await flush();
|
||||
await wrapper.find(".node-item-v").trigger("click");
|
||||
await flush();
|
||||
|
||||
mocks.elMessageWarning.mockReset();
|
||||
const deepBtn = wrapper.findAll("button").find((b) => b.text().includes("深度诊断"));
|
||||
expect(deepBtn).toBeTruthy();
|
||||
|
||||
const wrapperNoNode = mount(Diagnosis, { global: { stubs } });
|
||||
await flush();
|
||||
const deepBtn2 = wrapperNoNode.findAll("button").find((b) => b.text().includes("深度诊断"));
|
||||
expect(deepBtn2).toBeTruthy();
|
||||
await deepBtn2!.trigger("click");
|
||||
expect(mocks.elMessageWarning).toHaveBeenCalled();
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn((_: any, init: any) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const signal = init?.signal as AbortSignal | undefined;
|
||||
if (!signal) return reject({ name: "AbortError" });
|
||||
const onAbort = () => reject({ name: "AbortError" });
|
||||
if (signal.aborted) return reject({ name: "AbortError" });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}) as any;
|
||||
}) as any;
|
||||
|
||||
const textarea = wrapper.find("textarea");
|
||||
await textarea.setValue("hello");
|
||||
const sendBtn = wrapper.findAll("button").find((b) => b.text().trim() === "发送");
|
||||
expect(sendBtn).toBeTruthy();
|
||||
await sendBtn!.trigger("click");
|
||||
await flush();
|
||||
|
||||
const stopBtn = wrapper.findAll("button").find((b) => b.text().includes("停止"));
|
||||
expect(stopBtn).toBeTruthy();
|
||||
await stopBtn!.trigger("click");
|
||||
await flush();
|
||||
|
||||
globalThis.fetch = originalFetch as any;
|
||||
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
const text = [
|
||||
'data: {"content":"hi"}\n',
|
||||
'data: {"content":"!"}\n',
|
||||
"data: [DONE]\n",
|
||||
].join("");
|
||||
let used = false;
|
||||
return {
|
||||
ok: true,
|
||||
body: {
|
||||
getReader() {
|
||||
return {
|
||||
read: async () => {
|
||||
if (used) return { done: true, value: undefined };
|
||||
used = true;
|
||||
return { done: false, value: new TextEncoder().encode(text) };
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}) as any;
|
||||
|
||||
await textarea.setValue("ping");
|
||||
const sendBtnAgain = wrapper.findAll("button").find((b) => b.text().trim() === "发送");
|
||||
expect(sendBtnAgain).toBeTruthy();
|
||||
await sendBtnAgain!.trigger("click");
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(wrapper.text()).toContain("hi");
|
||||
expect(wrapper.text()).toContain("!");
|
||||
|
||||
globalThis.fetch = originalFetch as any;
|
||||
|
||||
const historyEl = wrapper.find(".chat-history").element as HTMLElement;
|
||||
const scrollToSpy = vi.fn();
|
||||
(historyEl as any).scrollTo = scrollToSpy;
|
||||
Object.defineProperty(historyEl, "scrollHeight", { value: 1000, configurable: true });
|
||||
Object.defineProperty(historyEl, "clientHeight", { value: 100, configurable: true });
|
||||
Object.defineProperty(historyEl, "scrollTop", { value: 0, configurable: true });
|
||||
historyEl.dispatchEvent(new Event("scroll"));
|
||||
await flush();
|
||||
|
||||
const scrollBottomBtn = wrapper.findAll("button").find((b) => b.element.classList.contains("scroll-bottom-btn"));
|
||||
expect(scrollBottomBtn).toBeTruthy();
|
||||
await scrollBottomBtn!.trigger("click");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: "happy-dom",
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Reference in new issue