提交 3b5554e8 authored 作者: 陈泽健's avatar 陈泽健

feat(service-manage): 服务管理功能升级 — 授权/目标/文档管理

- 新增服务授权模块:下载激活文件、上传授权文件、轮询任务状态
- 新增目标服务器管理:CRUD + 密码加密 + 连接测试
- 新增项目信息管理:保存/读取项目信息(销售/部署/密码/概述)
- 新增部署视图自检文档:模板下载 + 上传回传
- 前端 Authorization.vue 四步流程界面 + Targets.vue 目标管理页
- 后端 14 个 API 端点,管理员权限校验 + 审计日志
- 后端三层架构:routes/service_manage.py + services/service_manage.py + utils/paths.py
- 新增测试用例 test_service_manage.py
- 版本号分散配置(.env.example 新增 BACKEND/FRONTEND_VERSION)
- 服务监测增强:健康度趋势分析、通知服务、统计视图
- 问题排查优化:AI 服务支持自定义 model、离线模式 record_id
- 易用性优化:搜索面板组件、本地缓存、FAQ 面板优化
- PRD 文档:服务管理功能升级 + 易用性优化
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 b9201749
# 环境变量配置模板 # 环境变量配置模板
# 复制此文件为 .env 并填写实际值 # 复制此文件为 .env 并填写实际值
# ============================================
# 版本号(前后端分开)
# ============================================
BACKEND_VERSION=1.4.0
FRONTEND_VERSION=1.4.0
# ============================================ # ============================================
# SSH 连接配置(用于远程部署和测试) # SSH 连接配置(用于远程部署和测试)
# ============================================ # ============================================
...@@ -55,3 +61,10 @@ LOCAL_SSH_PASSWORD=your_host_ssh_password_here ...@@ -55,3 +61,10 @@ LOCAL_SSH_PASSWORD=your_host_ssh_password_here
# LOCAL_SSH_KEY=/app/keys/host_key # LOCAL_SSH_KEY=/app/keys/host_key
# Docker 宿主机 IP(不设则自动检测:ip route 网关 → host.docker.internal) # Docker 宿主机 IP(不设则自动检测:ip route 网关 → host.docker.internal)
# DOCKER_HOST_IP=172.17.0.1 # DOCKER_HOST_IP=172.17.0.1
# ============================================
# 中间件默认密码(用于巡检检测)
# MySQL / Redis 等中间件巡检时自动注入,无需手动输入
# ============================================
MYSQL_DEFAULT_PASSWORD=your_mysql_password_here
REDIS_DEFAULT_PASSWORD=your_redis_password_here
\ No newline at end of file
此差异已折叠。
此差异已折叠。
# -*- coding: utf-8 -*-
"""
deploy_service_manage.py — 部署服务管理模块到 5.60 Docker 容器
部署步骤:
1. 上传后端 Python 文件到 /data/third_party/monitor-platform/
2. docker cp 到 troubleshoot 容器
3. 上传前端 dist 到 /data/third_party/monitor-platform/frontend/dist/
4. 同步到 /opt/troubleshoot/dist/
5. 重启容器
6. 验证 health
"""
import paramiko
import os
import time
from pathlib import Path
HOST = '192.168.5.60'
USER = 'ubains'
PASSWORD = 'Ubains@123'
REPO = 'E:/GithubData/ubains-module-test/troubleshoot-ai-assistant'
REMOTE_BASE = '/data/third_party/monitor-platform'
CONTAINER = 'troubleshoot'
# 需要部署的后端文件(相对仓库根目录)
BACKEND_FILES = [
# 核心:服务管理模块(新功能)
'skill/code/web/services/service_manage.py',
'skill/code/web/routes/service_manage.py',
'skill/code/web/utils/paths.py',
# 其他有变更的文件
'skill/code/web/services/product_service.py',
'skill/code/web/routes/troubleshoot.py',
'skill/code/web/routes/version.py',
'skill/code/web/services/ai_service.py',
'skill/code/web/cache_manager.py',
'skill/code/web/users.json',
'skill/code/web/products.json',
]
def main():
s = paramiko.SSHClient()
s.set_missing_host_key_policy(paramiko.AutoAddPolicy())
s.connect(HOST, username=USER, password=PASSWORD)
sf = s.open_sftp()
print("=" * 60)
print(" Deploy Service Management Module")
print("=" * 60)
# ============================================================
# Step 1: 上传后端文件
# ============================================================
print("\n[1/4] Uploading backend files...")
for f in BACKEND_FILES:
local = os.path.join(REPO, f.replace('/', os.sep))
remote = f'{REMOTE_BASE}/{f}'
# 确保远程目录存在
rdir = os.path.dirname(remote)
s.exec_command(f'mkdir -p "{rdir}"')
# 上传
sf.put(local, remote)
print(f" [OK] {f}")
# ============================================================
# Step 2: docker cp 到容器(挂载卷文件除外)
# ============================================================
print("\n[2/4] Copying to container...")
# users.json 是挂载卷(/opt/troubleshoot/data/users.json -> /app/web/users.json)
# 不能 docker cp,需直接更新宿主机挂载文件
MOUNTED_FILES = {'skill/code/web/users.json'}
for f in BACKEND_FILES:
if f in MOUNTED_FILES:
local = os.path.join(REPO, f.replace('/', os.sep))
mount_target = '/opt/troubleshoot/data/users.json'
sf.put(local, mount_target)
print(f" [OK] {f} -> host mount {mount_target}")
continue
remote = f'{REMOTE_BASE}/{f}'
container_path = f'/app/web/{f.replace("skill/code/web/", "")}'
stdin, stdout, stderr = s.exec_command(
f'sudo docker cp "{remote}" "{CONTAINER}:{container_path}"'
)
err = stderr.read().decode().strip()
if err:
print(f" [FAIL] {f}: {err}")
else:
print(f" [OK] {container_path}")
# ============================================================
# Step 3: 上传前端 dist
# ============================================================
print("\n[3/4] Uploading frontend dist...")
local_dist = os.path.join(REPO, 'frontend', 'dist')
for root, dirs, files in os.walk(local_dist):
for fn in files:
lpath = os.path.join(root, fn)
rel = os.path.relpath(lpath, local_dist)
rpath = f'{REMOTE_BASE}/frontend/dist/{rel.replace(chr(92), "/")}'
rdir = os.path.dirname(rpath)
s.exec_command(f'mkdir -p "{rdir}"')
sf.put(lpath, rpath)
# 同步到宿主机挂载目录
s.exec_command('sudo rm -rf /opt/troubleshoot/dist/*')
s.exec_command('sudo cp -r /data/third_party/monitor-platform/frontend/dist/* /opt/troubleshoot/dist/')
print(" [OK] Frontend dist deployed to /opt/troubleshoot/dist/")
# ============================================================
# Step 4: 重启容器
# ============================================================
print("\n[4/4] Restarting container...")
stdin, stdout, stderr = s.exec_command('sudo docker restart troubleshoot')
out = stdout.read().decode().strip()
err = stderr.read().decode().strip()
print(f" {'[OK]' if 'troubleshoot' in out else '[FAIL]'} {out or err}")
# 等待启动
print("\nWaiting for service...")
time.sleep(8)
# 验证
print("\nVerifying...")
stdin, stdout, stderr = s.exec_command(
'curl -s http://localhost/api/health | python3 -m json.tool 2>/dev/null || curl -s http://localhost/api/health'
)
result = stdout.read().decode().strip()
if result:
print(f" Health check response:\n{result[:500]}")
if 'ok' in result.lower() or 'success' in result.lower():
print("\n[OK] Deployment verified successfully!")
else:
print("\n[WARN] Service may not be fully ready yet")
else:
err_text = stderr.read().decode().strip()
print(f" [FAIL] {err_text}")
# 验证容器内文件
print("\nVerifying container files...")
stdin, stdout, stderr = s.exec_command(
'sudo docker exec troubleshoot ls -la /app/web/services/service_manage.py /app/web/routes/service_manage.py 2>&1'
)
print(stdout.read().decode().strip())
sf.close()
s.close()
print("\n" + "=" * 60)
print(" Deployment Complete!")
print("=" * 60)
if __name__ == '__main__':
main()
\ No newline at end of file
...@@ -49,8 +49,8 @@ DEPLOY_FILES_TO_UPLOAD = [ ...@@ -49,8 +49,8 @@ DEPLOY_FILES_TO_UPLOAD = [
# P1-1 新增 utils/ 模块;P1-3 新增 routes/ services/ 目录;P1 收尾新增 templates # P1-1 新增 utils/ 模块;P1-3 新增 routes/ services/ 目录;P1 收尾新增 templates
DIRS_TO_UPLOAD = [ DIRS_TO_UPLOAD = [
('utils', 'web/utils'), # 含 modules.py / vector_builder.py 等 ('utils', 'web/utils'), # 含 modules.py / vector_builder.py 等
('routes', 'web/routes'), # 含 platform.py(平台首页) ('routes', 'web/routes'), # 含 platform.py(平台首页)+ service_manage.py(服务管理 API)
('services', 'web/services'), # P1-3:ai_service / record_service ('services', 'web/services'), # P1-3:ai_service / record_service(含 service_manage.py)
('templates', 'web/templates'), # 含 platform.html + service_monitor/ 子目录 ('templates', 'web/templates'), # 含 platform.html + service_monitor/ 子目录
] ]
......
/**
* 服务管理模块 API
*
* 包含服务授权与目标配置的接口调用。
*/
import http from '@/utils/http'
import type {
ManageTarget,
CreateManageTargetRequest,
UpdateManageTargetRequest,
TestManageTargetRequest,
TestManageTargetResponse,
LicenseTaskStatus,
UploadLicenseResponse,
ProjectInfo,
SaveProjectRequest,
UploadFileResult,
} from '@/types/service-manage'
// ============================================================
// 目标配置
// ============================================================
/** 目标列表 */
export async function getTargets(): Promise<{ success: boolean; targets: ManageTarget[] }> {
const res = await http.get<{ success: boolean; targets: ManageTarget[] }>('/api/service-manage/targets')
return res.data
}
/** 新增目标 */
export async function createTarget(data: CreateManageTargetRequest): Promise<{ success: boolean; target: ManageTarget }> {
const res = await http.post<{ success: boolean; target: ManageTarget }>('/api/service-manage/targets', data)
return res.data
}
/** 更新目标 */
export async function updateTarget(
targetId: string,
data: UpdateManageTargetRequest
): Promise<{ success: boolean; target: ManageTarget }> {
const res = await http.put<{ success: boolean; target: ManageTarget }>(
`/api/service-manage/targets/${targetId}`,
data
)
return res.data
}
/** 删除目标 */
export async function deleteTarget(targetId: string): Promise<{ success: boolean }> {
const res = await http.delete<{ success: boolean }>(`/api/service-manage/targets/${targetId}`)
return res.data
}
/** 测试连接 */
export async function testConnection(data: TestManageTargetRequest): Promise<TestManageTargetResponse> {
const res = await http.post<TestManageTargetResponse>('/api/service-manage/targets/test', data)
return res.data
}
// ============================================================
// 服务授权
// ============================================================
/** 下载激活文件(Blob) */
export async function downloadLicense(): Promise<Blob> {
const res = await http.get<Blob>('/api/service-manage/license/download', { responseType: 'blob' })
return res.data
}
/** 上传授权文件 */
export async function uploadLicense(file: File): Promise<UploadLicenseResponse> {
const formData = new FormData()
formData.append('file', file)
const res = await http.post<UploadLicenseResponse>('/api/service-manage/license/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return res.data
}
/** 查询授权任务状态 */
export async function getLicenseTaskStatus(taskId: string): Promise<LicenseTaskStatus> {
const res = await http.get<LicenseTaskStatus>('/api/service-manage/license/task-status', {
params: { taskId },
})
return res.data
}
/** 读取项目信息 */
export async function getProject(): Promise<{ success: boolean; project: ProjectInfo | null }> {
const res = await http.get<{ success: boolean; project: ProjectInfo | null }>('/api/service-manage/project')
return res.data
}
/** 保存项目信息 */
export async function saveProject(data: SaveProjectRequest): Promise<{ success: boolean; message: string }> {
const res = await http.post<{ success: boolean; message: string }>('/api/service-manage/project', data)
return res.data
}
/** 下载部署视图(Blob) */
export async function downloadDeployView(): Promise<Blob> {
const res = await http.get<Blob>('/api/service-manage/deploy-view/download', { responseType: 'blob' })
return res.data
}
/** 上传部署视图 */
export async function uploadDeployView(file: File): Promise<UploadFileResult> {
const formData = new FormData()
formData.append('file', file)
const res = await http.post<UploadFileResult>('/api/service-manage/deploy-view/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return res.data
}
/** 下载自检文档(Blob) */
export async function downloadSelfCheck(): Promise<Blob> {
const res = await http.get<Blob>('/api/service-manage/self-check/download', { responseType: 'blob' })
return res.data
}
/** 上传自检文档 */
export async function uploadSelfCheck(file: File): Promise<UploadFileResult> {
const formData = new FormData()
formData.append('file', file)
const res = await http.post<UploadFileResult>('/api/service-manage/self-check/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return res.data
}
// ============================================================
// 工具:Blob 下载
// ============================================================
/** 触发浏览器下载 Blob */
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
\ No newline at end of file
...@@ -44,3 +44,12 @@ export async function testWeCom(): Promise<{ success: boolean; message: string } ...@@ -44,3 +44,12 @@ export async function testWeCom(): Promise<{ success: boolean; message: string }
) )
return res.data return res.data
} }
/** 检查缺失报告的目标 */
export async function checkMissingReports(): Promise<{
success: boolean
data: { missing_targets: Array<{ target_name: string; target_id: string; last_report_at: string | null; missing_days: number }> }
}> {
const res = await http.get('/api/service-monitor/notification/check-missing-reports')
return res.data
}
\ No newline at end of file
...@@ -8,6 +8,7 @@ import type { ...@@ -8,6 +8,7 @@ import type {
ItemTrendData, ItemTrendData,
AbnormalItem, AbnormalItem,
ItemKeysModule, ItemKeysModule,
HealthTrendData,
} from '@/types/service-monitor' } from '@/types/service-monitor'
interface StatsParams { interface StatsParams {
...@@ -71,3 +72,14 @@ export async function getItemKeys( ...@@ -71,3 +72,14 @@ export async function getItemKeys(
) )
return res.data return res.data
} }
/** 获取健康度趋势 */
export async function getHealthTrend(
params?: StatsParams
): Promise<{ success: boolean; data: HealthTrendData }> {
const res = await http.get<{ success: boolean; data: HealthTrendData }>(
'/api/service-monitor/statistics/health-trend',
{ params }
)
return res.data
}
\ No newline at end of file
...@@ -133,6 +133,11 @@ export function downloadDocument(docId: string): string { ...@@ -133,6 +133,11 @@ export function downloadDocument(docId: string): string {
return `/api/troubleshoot/documents/${docId}/download` return `/api/troubleshoot/documents/${docId}/download`
} }
/** 在线预览文档(返回文本内容,或可直接打开的 URL) */
export function previewDocument(docId: string): string {
return `/api/troubleshoot/documents/${docId}/preview`
}
/** 获取产品常见问题 */ /** 获取产品常见问题 */
export async function getProductFAQs(productId: string, limit?: number): Promise<FAQsResponse> { export async function getProductFAQs(productId: string, limit?: number): Promise<FAQsResponse> {
const params = limit ? { limit } : {} const params = limit ? { limit } : {}
......
...@@ -13,8 +13,8 @@ declare module 'vue' { ...@@ -13,8 +13,8 @@ declare module 'vue' {
AppSidebar: typeof import('./components/layout/AppSidebar.vue')['default'] AppSidebar: typeof import('./components/layout/AppSidebar.vue')['default']
AppTopbar: typeof import('./components/layout/AppTopbar.vue')['default'] AppTopbar: typeof import('./components/layout/AppTopbar.vue')['default']
AppVersion: typeof import('./components/common/AppVersion.vue')['default'] AppVersion: typeof import('./components/common/AppVersion.vue')['default']
DocumentsPanel: typeof import('./components/troubleshoot/DocumentsPanel.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert'] ElAlert: typeof import('element-plus/es')['ElAlert']
ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
...@@ -31,6 +31,7 @@ declare module 'vue' { ...@@ -31,6 +31,7 @@ declare module 'vue' {
ElPagination: typeof import('element-plus/es')['ElPagination'] ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress'] ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
...@@ -40,8 +41,11 @@ declare module 'vue' { ...@@ -40,8 +41,11 @@ declare module 'vue' {
ElTabs: typeof import('element-plus/es')['ElTabs'] ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElUpload: typeof import('element-plus/es')['ElUpload'] ElUpload: typeof import('element-plus/es')['ElUpload']
FAQsPanel: typeof import('./components/troubleshoot/FAQsPanel.vue')['default']
ProductTree: typeof import('./components/troubleshoot/ProductTree.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SearchResultsPanel: typeof import('./components/troubleshoot/SearchResultsPanel.vue')['default']
} }
export interface ComponentCustomProperties { export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective'] vLoading: typeof import('element-plus/es')['ElLoadingDirective']
......
...@@ -2,14 +2,17 @@ ...@@ -2,14 +2,17 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import http from '@/utils/http' import http from '@/utils/http'
const version = ref('') // 前端版本号:构建期从 package.json 注入,无需接口
const frontendVersion = __FRONTEND_VERSION__ || '0.1.0'
// 后端版本号:从 API 获取
const backendVersion = ref('')
const buildTime = ref('') const buildTime = ref('')
onMounted(async () => { onMounted(async () => {
try { try {
const res = await http.get('/api/version') const res = await http.get('/api/version')
if (res.data?.success) { if (res.data?.success) {
version.value = res.data.data?.version || '' backendVersion.value = res.data.data?.backendVersion || ''
buildTime.value = res.data.data?.buildTime || '' buildTime.value = res.data.data?.buildTime || ''
} }
} catch { } catch {
...@@ -19,8 +22,13 @@ onMounted(async () => { ...@@ -19,8 +22,13 @@ onMounted(async () => {
</script> </script>
<template> <template>
<span v-if="version" class="app-version" :title="buildTime ? `构建时间: ${buildTime}` : ''"> <span
v{{ version }} class="app-version"
:title="buildTime ? `构建时间: ${buildTime}` : ''"
>
<span v-if="frontendVersion" class="ver-item">前端 v{{ frontendVersion }}</span>
<span v-if="backendVersion" class="ver-divider">/</span>
<span v-if="backendVersion" class="ver-item">后端 v{{ backendVersion }}</span>
</span> </span>
</template> </template>
...@@ -32,5 +40,12 @@ onMounted(async () => { ...@@ -32,5 +40,12 @@ onMounted(async () => {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
cursor: default; cursor: default;
display: inline-flex;
align-items: center;
gap: 4px;
.ver-divider {
opacity: 0.5;
}
} }
</style> </style>
\ No newline at end of file
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import AppVersion from '@/components/common/AppVersion.vue' import AppVersion from '@/components/common/AppVersion.vue'
withDefaults(defineProps<{ withDefaults(defineProps<{
showModule?: boolean showModule?: boolean
moduleLabel?: string moduleLabel?: string
homePath?: string
logsPath?: string logsPath?: string
}>(), { }>(), {
showModule: false, showModule: false,
moduleLabel: '', moduleLabel: '',
homePath: '',
logsPath: '', logsPath: '',
}) })
const route = useRoute()
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
...@@ -38,8 +41,23 @@ function handleHome() { ...@@ -38,8 +41,23 @@ function handleHome() {
运行维护平台 运行维护平台
</a> </a>
<span v-if="showModule" class="navbar-separator">/</span> <span v-if="showModule" class="navbar-separator">/</span>
<span v-if="showModule" class="navbar-module">{{ moduleLabel }}</span> <a
<a v-if="logsPath" :href="logsPath" class="navbar-logs" @click.prevent="router.push(logsPath)">📋 操作日志</a> v-if="showModule && homePath"
href="javascript:void(0)"
class="navbar-module navbar-module-link"
@click="router.push(homePath)"
>
{{ moduleLabel }}
</a>
<span v-else-if="showModule" class="navbar-module">{{ moduleLabel }}</span>
<a
v-if="logsPath && route.path !== logsPath"
:href="logsPath"
class="navbar-logs"
@click.prevent="router.push(logsPath)"
>
📋 操作日志
</a>
</div> </div>
<div class="navbar-right"> <div class="navbar-right">
<AppVersion /> <AppVersion />
...@@ -96,6 +114,15 @@ function handleHome() { ...@@ -96,6 +114,15 @@ function handleHome() {
font-size: 14px; font-size: 14px;
} }
.navbar-module-link {
cursor: pointer;
text-decoration: none;
&:hover {
color: #fff;
}
}
.navbar-logs { .navbar-logs {
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
font-size: 13px; font-size: 13px;
......
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { getProductDocuments, downloadDocument, getProduct, uploadDocument, deleteDocument } from '@/api/troubleshoot' import { getProductDocuments, downloadDocument, getProduct, uploadDocument, deleteDocument, previewDocument } from '@/api/troubleshoot'
import type { ProductDocument, Product } from '@/types/troubleshoot' import type { ProductDocument, Product } from '@/types/troubleshoot'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getLocalCache, setLocalCache } from '@/utils/localCache'
import { renderMarkdown } from '@/utils/markdown'
import http from '@/utils/http'
interface Props { interface Props {
productId: string productId: string
...@@ -19,12 +22,64 @@ const loading = ref(true) ...@@ -19,12 +22,64 @@ const loading = ref(true)
const uploading = ref(false) const uploading = ref(false)
const deletingId = ref<string | null>(null) const deletingId = ref<string | null>(null)
// 预览状态
const previewVisible = ref(false)
const previewLoading = ref(false)
const previewTitle = ref('')
const previewContent = ref('')
const previewType = ref<'text' | 'pdf' | 'image' | 'unsupported'>('text')
const previewUrl = ref('')
const previewingDoc = ref<ProductDocument | null>(null)
async function handlePreview(doc: ProductDocument) {
previewVisible.value = true
previewingDoc.value = doc
previewTitle.value = doc.title
previewContent.value = ''
previewType.value = 'text'
previewUrl.value = ''
previewLoading.value = true
try {
const ext = doc.type.toLowerCase()
// PDF 和图片类型:直接打开预览 URL
if (ext === 'pdf' || ['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext)) {
previewType.value = ext === 'pdf' ? 'pdf' : 'image'
previewUrl.value = previewDocument(doc.id)
previewLoading.value = false
return
}
// 文本类:调用预览 API 获取内容
if (['md', 'txt', 'log', 'text'].includes(ext)) {
const res = await http.get(previewDocument(doc.id))
if (res.data?.success && res.data.content) {
previewContent.value = res.data.content
} else {
previewContent.value = '无法加载文档内容'
}
} else {
previewType.value = 'unsupported'
previewContent.value = '该格式暂不支持在线预览,请下载后查看'
}
} catch {
previewType.value = 'unsupported'
previewContent.value = '预览加载失败'
} finally {
previewLoading.value = false
}
}
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
// 优先读取本地缓存
const cacheKey = `docs_${props.productId}`
const cached = getLocalCache<ProductDocument[]>(cacheKey)
const [productRes, docsRes] = await Promise.all([ const [productRes, docsRes] = await Promise.all([
getProduct(props.productId), getProduct(props.productId),
getProductDocuments(props.productId), // 缓存命中则跳过文档请求
cached ? Promise.resolve({ success: true, documents: cached }) : getProductDocuments(props.productId),
]) ])
if (productRes.success) { if (productRes.success) {
...@@ -33,6 +88,7 @@ async function loadData() { ...@@ -33,6 +88,7 @@ async function loadData() {
if (docsRes.success) { if (docsRes.success) {
documents.value = docsRes.documents documents.value = docsRes.documents
setLocalCache(cacheKey, docsRes.documents)
} }
} catch { } catch {
ElMessage.error('加载资料失败') ElMessage.error('加载资料失败')
...@@ -183,6 +239,10 @@ watch(() => props.productId, loadData, { immediate: true }) ...@@ -183,6 +239,10 @@ watch(() => props.productId, loadData, { immediate: true })
</div> </div>
</div> </div>
<div class="doc-actions"> <div class="doc-actions">
<el-button size="small" @click="handlePreview(doc)">
<span class="btn-icon">👁</span>
预览
</el-button>
<el-button type="primary" size="small" @click="handleDownload(doc)"> <el-button type="primary" size="small" @click="handleDownload(doc)">
<span class="btn-icon"></span> <span class="btn-icon"></span>
下载 下载
...@@ -199,6 +259,47 @@ watch(() => props.productId, loadData, { immediate: true }) ...@@ -199,6 +259,47 @@ watch(() => props.productId, loadData, { immediate: true })
</div> </div>
</div> </div>
</div> </div>
<!-- 文档预览弹窗 -->
<el-dialog
v-model="previewVisible"
:title="'📄 ' + previewTitle"
width="720px"
:destroy-on-close="true"
top="6vh"
>
<div v-loading="previewLoading" class="preview-body">
<!-- PDF / 图片:内联 iframe / img -->
<iframe
v-if="previewType === 'pdf' && !previewLoading"
:src="previewUrl"
class="preview-frame"
/>
<img
v-else-if="previewType === 'image' && !previewLoading"
:src="previewUrl"
class="preview-image"
alt="文档预览"
/>
<!-- 文本类:Markdown 渲染 -->
<div
v-else-if="previewType === 'text' && previewContent"
class="preview-markdown"
v-html="renderMarkdown(previewContent)"
/>
<!-- 不支持的格式提示 -->
<div v-else-if="previewType === 'unsupported'" class="preview-unsupported">
<div class="unsupported-icon">📄</div>
<p>{{ previewContent }}</p>
</div>
</div>
<template #footer>
<el-button @click="previewVisible = false">关闭</el-button>
<el-button v-if="previewingDoc" type="primary" @click="handleDownload(previewingDoc)">
📥 下载文档
</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
...@@ -352,4 +453,59 @@ watch(() => props.productId, loadData, { immediate: true }) ...@@ -352,4 +453,59 @@ watch(() => props.productId, loadData, { immediate: true })
.btn-icon { .btn-icon {
margin-right: 4px; margin-right: 4px;
} }
// ============================================================
// 文档预览弹窗
// ============================================================
.preview-body {
min-height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.preview-frame {
width: 100%;
height: 70vh;
border: none;
border-radius: 8px;
}
.preview-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 8px;
}
.preview-markdown {
width: 100%;
padding: 20px;
line-height: 1.8;
max-height: 70vh;
overflow-y: auto;
:deep(h1), :deep(h2), :deep(h3) {
color: #303133;
margin-top: 16px;
margin-bottom: 8px;
}
:deep(h1) { font-size: 18px; }
:deep(h2) { font-size: 16px; color: #2563eb; }
:deep(h3) { font-size: 14px; }
:deep(p), :deep(li) { color: #606266; font-size: 14px; }
:deep(ol), :deep(ul) { padding-left: 20px; margin: 8px 0; }
:deep(code) { background: #eef2ff; color: #1e40af; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
:deep(pre) { background: #1e293b; color: #e2e8f0; padding: 12px; border-radius: 8px; overflow-x: auto; }
:deep(blockquote) { border-left: 3px solid #2563eb; padding: 8px 16px; margin: 12px 0; background: #f8fafc; }
}
.preview-unsupported {
text-align: center;
color: #86909c;
padding: 40px;
.unsupported-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
p { font-size: 14px; margin: 0; }
}
</style> </style>
<script setup lang="ts">
import { ref } from 'vue'
import { search } from '@/api/troubleshoot'
import { renderMarkdown } from '@/utils/markdown'
import http from '@/utils/http'
import type { MatchedCase } from '@/types/troubleshoot'
import { ElMessage } from 'element-plus'
const keyword = ref('')
const searching = ref(false)
const searched = ref(false)
const results = ref<MatchedCase[]>([])
const matchedCount = ref(0)
const searchTime = ref(0)
// 记录详情弹窗
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailContent = ref('')
const detailTitle = ref('')
const detailRecordId = ref('')
async function doSearch() {
const q = keyword.value.trim()
if (!q) return
searching.value = true
try {
const res = await search({ query: q })
if (res.success) {
results.value = res.matched_cases
matchedCount.value = res.matched_count
searchTime.value = res.search_time
searched.value = true
} else {
ElMessage.error('搜索失败')
}
} catch {
ElMessage.error('搜索异常,请稍后重试')
} finally {
searching.value = false
}
}
const emit = defineEmits<{ exit: [] }>()
function clearSearch() {
keyword.value = ''
results.value = []
searched.value = false
matchedCount.value = 0
emit('exit')
}
async function showRecordDetail(c: MatchedCase) {
if (!c.record_id) return
detailRecordId.value = c.record_id
detailTitle.value = c.title
detailContent.value = ''
detailLoading.value = true
detailVisible.value = true
try {
const res = await http.get(`/api/troubleshoot/record/${c.record_id}`)
if (res.data?.success && res.data.record) {
detailContent.value = res.data.record.full_text || ''
detailTitle.value = res.data.record.title || c.title
} else {
detailContent.value = '记录加载失败'
}
} catch {
detailContent.value = '记录加载失败'
} finally {
detailLoading.value = false
}
}
function downloadRecord() {
if (!detailRecordId.value) return
window.open(`/api/troubleshoot/record/${detailRecordId.value}/download`, '_blank')
}
// 相似度百分比
function scorePercent(score: number): string {
return (score * 100).toFixed(0) + '%'
}
</script>
<template>
<div class="search-results-panel">
<!-- 搜索框 -->
<div class="search-bar">
<el-input
v-model="keyword"
placeholder="搜索历史问题案例..."
clearable
size="large"
:prefix-icon="'Search'"
@keyup.enter="doSearch"
@clear="clearSearch"
>
<template #append>
<el-button :loading="searching" @click="doSearch">搜索</el-button>
</template>
</el-input>
</div>
<!-- 搜索结果 -->
<div v-if="searched" class="result-area">
<div class="result-header">
<span class="result-count">共匹配 {{ matchedCount }} 个相关案例</span>
<span class="result-time" v-if="searchTime">耗时 {{ searchTime }}s</span>
<el-button link type="primary" size="small" @click="clearSearch">返回产品浏览</el-button>
</div>
<!-- 空结果 -->
<div v-if="results.length === 0" class="empty-state">
<div class="empty-icon">🔍</div>
<h3>未找到相关案例</h3>
<p>换个关键词试试,或从左侧产品列表浏览</p>
</div>
<!-- 结果列表 -->
<div v-else class="result-list">
<div
v-for="(c, i) in results"
:key="c.record_id"
class="result-item"
@click="showRecordDetail(c)"
>
<div class="result-top">
<span class="result-index">{{ i + 1 }}</span>
<span class="result-title">{{ c.title }}</span>
<span class="result-score">{{ scorePercent(c.score) }}</span>
</div>
<div class="result-meta">
<span v-if="c.project" class="result-project">🏷 {{ c.project }}</span>
<span v-if="c.phenomenon" class="result-phenomenon">{{ c.phenomenon }}</span>
</div>
</div>
</div>
</div>
<!-- 未搜索时的引导 -->
<div v-else class="idle-state">
<div class="idle-icon">💡</div>
<h3>全局搜索</h3>
<p>输入问题关键词,全文搜索历史案例</p>
<p class="idle-hint">例如:门口屏 MQTT 绑定失败、无纸化平板离线</p>
</div>
<!-- 记录详情弹窗 -->
<el-dialog
v-model="detailVisible"
:title="detailTitle"
width="700px"
destroy-on-close
>
<div v-loading="detailLoading" class="record-detail-content">
<div
v-if="detailContent"
class="record-detail-md"
v-html="renderMarkdown(detailContent)"
/>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
<el-button type="primary" @click="downloadRecord">
📥 下载文档
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.search-results-panel {
background: #fff;
border-radius: 12px;
padding: 24px;
min-height: 400px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.search-bar {
margin-bottom: 20px;
}
.result-area {
.result-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
margin-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
.result-count {
font-size: 14px;
font-weight: 500;
color: #1d2129;
}
.result-time {
font-size: 12px;
color: #86909c;
}
.el-button {
margin-left: auto;
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 260px;
color: #86909c;
.empty-icon { font-size: 56px; margin-bottom: 16px; opacity: 0.5; }
h3 { font-size: 16px; font-weight: 500; color: #4e5969; margin: 0 0 8px; }
p { font-size: 14px; margin: 0; }
}
.result-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.result-item {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 10px;
padding: 14px 18px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f7f9fc;
border-color: #e0e7ff;
box-shadow: 0 2px 8px rgba(22, 125, 255, 0.08);
}
}
.result-top {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
.result-index {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: #e8f3ff;
color: #165dff;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.result-title {
flex: 1;
font-size: 15px;
font-weight: 500;
color: #1d2129;
line-height: 1.5;
}
.result-score {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: #165dff;
background: #e8f3ff;
padding: 2px 8px;
border-radius: 12px;
}
}
.result-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: #86909c;
.result-project {
flex-shrink: 0;
color: #4e5969;
}
.result-phenomenon {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.idle-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 280px;
color: #86909c;
.idle-icon { font-size: 56px; margin-bottom: 16px; opacity: 0.6; }
h3 { font-size: 16px; font-weight: 500; color: #4e5969; margin: 0 0 8px; }
p { font-size: 14px; margin: 0 0 4px; }
.idle-hint { color: #c9cdd4; font-size: 13px; }
}
.record-detail-content {
min-height: 200px;
max-height: 60vh;
overflow-y: auto;
}
.record-detail-md { line-height: 1.8; }
</style>
\ No newline at end of file
...@@ -9,6 +9,7 @@ const appStore = useAppStore() ...@@ -9,6 +9,7 @@ const appStore = useAppStore()
// 服务管理侧边栏菜单 // 服务管理侧边栏菜单
const manageMenus = computed(() => [ const manageMenus = computed(() => [
{ key: 'authorization', icon: '🔑', label: '服务授权', path: '/service-manage/authorization' }, { key: 'authorization', icon: '🔑', label: '服务授权', path: '/service-manage/authorization' },
{ key: 'targets', icon: '🎯', label: '目标配置', path: '/service-manage/targets' },
{ key: 'upgrade', icon: '🚀', label: '服务升级', path: '/service-manage/upgrade' }, { key: 'upgrade', icon: '🚀', label: '服务升级', path: '/service-manage/upgrade' },
{ key: 'info', icon: 'ℹ️', label: '服务信息', path: '/service-manage/info' }, { key: 'info', icon: 'ℹ️', label: '服务信息', path: '/service-manage/info' },
{ key: 'logs', icon: '📝', label: '操作日志', path: '/service-manage/logs' }, { key: 'logs', icon: '📝', label: '操作日志', path: '/service-manage/logs' },
......
...@@ -114,6 +114,12 @@ const routes: RouteRecordRaw[] = [ ...@@ -114,6 +114,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/service-manage/Authorization.vue'), component: () => import('@/views/service-manage/Authorization.vue'),
meta: { activeMenu: 'authorization', title: '服务授权' } meta: { activeMenu: 'authorization', title: '服务授权' }
}, },
{
path: 'targets',
name: 'ManageTargets',
component: () => import('@/views/service-manage/Targets.vue'),
meta: { activeMenu: 'targets', title: '目标配置' }
},
{ {
path: 'upgrade', path: 'upgrade',
name: 'ManageUpgrade', name: 'ManageUpgrade',
......
/**
* 服务管理模块类型定义
*
* 包含服务授权与目标配置的类型。
*/
// ============================================================
// 目标配置
// ============================================================
/** 目标服务器配置 */
export interface ManageTarget {
id: string
name: string
host: string
port: number
protocol: 'http' | 'https'
auth_type: 'password' | 'token' | 'none'
username: string
has_password: boolean
connected: boolean
created_at: string
updated_at: string
}
/** 新建目标请求 */
export interface CreateManageTargetRequest {
name: string
host: string
port?: number
protocol?: 'http' | 'https'
auth_type?: 'password' | 'token' | 'none'
username?: string
password?: string
}
/** 更新目标请求 */
export interface UpdateManageTargetRequest {
name?: string
host?: string
port?: number
protocol?: 'http' | 'https'
auth_type?: 'password' | 'token' | 'none'
username?: string
password?: string
}
/** 测试连接请求 */
export interface TestManageTargetRequest {
host: string
port?: number
protocol?: 'http' | 'https'
username?: string
password?: string
}
/** 测试连接响应 */
export interface TestManageTargetResponse {
success: boolean
message: string
}
// ============================================================
// 服务授权
// ============================================================
/** 授权任务状态 */
export interface LicenseTaskStatus {
taskId: string
status: 'pending' | 'processing' | 'completed' | 'failed'
message: string
}
/** 上传授权文件响应 */
export interface UploadLicenseResponse {
success: boolean
taskId: string
status: string
message: string
}
/** 项目信息 */
export interface ProjectInfo {
salesPeople: string
deployer: string
orderTime: string
serverPassword?: string
has_password?: boolean
projectOverview: string
updated_at?: string
}
/** 保存项目信息请求 */
export interface SaveProjectRequest {
salesPeople: string
deployer: string
orderTime: string
serverPassword: string
projectOverview: string
}
/** 文件上传结果 */
export interface UploadFileResult {
success: boolean
message: string
file?: {
filename: string
path: string
size: number
}
}
\ No newline at end of file
...@@ -278,12 +278,19 @@ export interface AlertOnConsecutiveConfig { ...@@ -278,12 +278,19 @@ export interface AlertOnConsecutiveConfig {
channels: string[] channels: string[]
} }
export interface ReportMissingConfig {
enabled: boolean
missing_days: number
channels: string[]
}
export interface NotificationConfig { export interface NotificationConfig {
email: EmailConfig email: EmailConfig
dingtalk: DingTalkConfig dingtalk: DingTalkConfig
wecom: WeComConfig wecom: WeComConfig
trigger: NotificationTrigger trigger: NotificationTrigger
alert_on_consecutive?: AlertOnConsecutiveConfig alert_on_consecutive?: AlertOnConsecutiveConfig
report_missing?: ReportMissingConfig
} }
export interface NotificationConfigResponse { export interface NotificationConfigResponse {
...@@ -359,6 +366,24 @@ export interface AbnormalItem { ...@@ -359,6 +366,24 @@ export interface AbnormalItem {
critical_count: number critical_count: number
} }
export interface HealthTrendPoint {
date: string
score: number
normal_count: number
warning_count: number
critical_count: number
total_items: number
}
export interface HealthTrendData {
data_points: HealthTrendPoint[]
overall_score: number
date_range: {
start: string
end: string
}
}
export interface ItemKeyInfo { export interface ItemKeyInfo {
key: string key: string
name: string name: string
......
...@@ -52,6 +52,7 @@ export interface AnalyzeRequest { ...@@ -52,6 +52,7 @@ export interface AnalyzeRequest {
apk_product?: string apk_product?: string
query: string query: string
matched_cases?: MatchedCase[] matched_cases?: MatchedCase[]
model?: string
} }
/** AI 分析响应 */ /** AI 分析响应 */
......
/**
* useLocalCache.ts — 离线模式本地缓存工具(P3-1)
*
* 5 分钟过期,localStorage 存储。
* 首次加载后写入缓存,过期自动刷新,不过期不请求 API。
*/
const CACHE_PREFIX = 'offline_cache_'
const DEFAULT_TTL = 5 * 60 * 1000 // 5 分钟
interface CacheEntry<T> {
data: T
timestamp: number
ttl: number
}
export function getLocalCache<T>(key: string): T | null {
try {
const raw = localStorage.getItem(CACHE_PREFIX + key)
if (!raw) return null
const entry: CacheEntry<T> = JSON.parse(raw)
if (Date.now() - entry.timestamp > entry.ttl) {
localStorage.removeItem(CACHE_PREFIX + key)
return null
}
return entry.data
} catch {
return null
}
}
export function setLocalCache<T>(key: string, data: T, ttl: number = DEFAULT_TTL): void {
try {
const entry: CacheEntry<T> = { data, timestamp: Date.now(), ttl }
localStorage.setItem(CACHE_PREFIX + key, JSON.stringify(entry))
} catch {
// localStorage 满或不可用时静默失败,不影响功能
}
}
\ No newline at end of file
...@@ -8,6 +8,12 @@ ...@@ -8,6 +8,12 @@
* 事件类型:progress / module_done / finished / error / cancelled * 事件类型:progress / module_done / finished / error / cancelled
* *
* 后端响应头:Cache-Control: no-cache, X-Accel-Buffering: no, Connection: keep-alive * 后端响应头:Cache-Control: no-cache, X-Accel-Buffering: no, Connection: keep-alive
*
* 断线重连(P3-2):
* - 连接断开后自动重连,递增退避 1s→2s→4s→8s,最多 3 次
* - 重连成功后新内容追加到已有内容,不重置
* - 用户手动 close() 后不自动重连
* - 3 次重试失败判定最终失败,回调 onError
*/ */
export interface SSEOptions { export interface SSEOptions {
...@@ -17,12 +23,14 @@ export interface SSEOptions { ...@@ -17,12 +23,14 @@ export interface SSEOptions {
params?: Record<string, string> params?: Record<string, string>
/** 事件处理器映射(key 为事件 type) */ /** 事件处理器映射(key 为事件 type) */
handlers: Record<string, (data: any) => void> handlers: Record<string, (data: any) => void>
/** 连接错误回调 */ /** 连接错误回调(所有重连尝试均失败后触发) */
onError?: (error: Event) => void onError?: (error: Event) => void
/** 连接打开回调 */ /** 连接打开回调 */
onOpen?: () => void onOpen?: () => void
/** 是否携带 Cookie(默认 true,Flask Session 需要) */ /** 是否携带 Cookie(默认 true,Flask Session 需要) */
withCredentials?: boolean withCredentials?: boolean
/** onerror 最大连续触发次数,超过后才回调 onError(默认 3,避免短暂波动误判) */
maxErrorCount?: number
} }
export interface SSEConnection { export interface SSEConnection {
...@@ -33,7 +41,14 @@ export interface SSEConnection { ...@@ -33,7 +41,14 @@ export interface SSEConnection {
} }
/** /**
* 创建通用 SSE 连接 * 创建通用 SSE 连接(支持断线自动重连)
*
* 断线重连策略:
* - 递增退避:1s → 2s → 4s → 8s
* - 最多重试 3 次
* - 重连成功后续内容追加到已有内容(不重置)
* - 手动 close() 后不自动重连
* - 3 次重试失败后回调 onError
* *
* 用法示例(阶段二): * 用法示例(阶段二):
* const conn = createSSE({ * const conn = createSSE({
...@@ -63,19 +78,32 @@ export function createSSE(options: SSEOptions): SSEConnection { ...@@ -63,19 +78,32 @@ export function createSSE(options: SSEOptions): SSEConnection {
const queryString = new URLSearchParams(params).toString() const queryString = new URLSearchParams(params).toString()
const fullUrl = queryString ? `${url}?${queryString}` : url const fullUrl = queryString ? `${url}?${queryString}` : url
// 创建 EventSource let source: EventSource | null = null
const source = new EventSource(fullUrl, { withCredentials }) let closed = false
let reconnectAttempts = 0
const maxReconnectAttempts = 3
function connect() {
if (closed) return
source = new EventSource(fullUrl, { withCredentials })
// 连接打开 // 连接打开(首次连接 + 重连成功均触发)
source.onopen = () => {
reconnectAttempts = 0 // 重连成功,重置重试计数
console.log(`SSE 连接已建立` + (reconnectAttempts > 0 ? '(重连成功)' : ''))
if (onOpen) { if (onOpen) {
source.onopen = onOpen onOpen()
}
} }
// 监听消息 // 监听消息
source.onmessage = (event) => { source.onmessage = (event) => {
reconnectAttempts = 0 // 收到正常消息,重置重试计数
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
const handler = handlers[data.type] const eventType = data.event || data.type
const handler = handlers[eventType]
if (handler) { if (handler) {
handler(data) handler(data)
} }
...@@ -84,17 +112,41 @@ export function createSSE(options: SSEOptions): SSEConnection { ...@@ -84,17 +112,41 @@ export function createSSE(options: SSEOptions): SSEConnection {
} }
} }
// 监听错误 // 监听错误(触发断线重连)
source.onerror = (event) => { source.onerror = (_event: Event) => {
if (closed) return
source?.close()
reconnectAttempts++
if (reconnectAttempts <= maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 8000)
console.warn(
`SSE 连接断开,${delay / 1000}s 后第 ${reconnectAttempts} 次重连...`
)
setTimeout(() => connect(), delay)
} else {
// 3 次重连均失败,判定最终失败
console.error(`SSE 连接失败,已重试 ${maxReconnectAttempts} 次`)
closed = true
if (onError) { if (onError) {
onError(event) onError(_event)
}
}
} }
// 自动关闭连接(避免无限重连)
source.close()
} }
// 建立首次连接
connect()
return { return {
close: () => source.close(), close: () => {
closed = true
if (source) {
source.close()
source = null
}
},
getSource: () => source, getSource: () => source,
} }
} }
......
...@@ -47,7 +47,7 @@ async function handleLogin() { ...@@ -47,7 +47,7 @@ async function handleLogin() {
<div class="login-page"> <div class="login-page">
<div class="login-card"> <div class="login-card">
<div class="login-header"> <div class="login-header">
<h1>问题排查助手</h1> <h1>运行维护平台</h1>
<p>请登录以继续访问系统</p> <p>请登录以继续访问系统</p>
</div> </div>
<div class="login-body"> <div class="login-body">
......
...@@ -10,7 +10,7 @@ const modules = computed(() => { ...@@ -10,7 +10,7 @@ const modules = computed(() => {
const all = [ const all = [
{ {
id: 'troubleshoot', id: 'troubleshoot',
name: '运行维护平台', name: '问题排查',
icon: '🔍', icon: '🔍',
description: '基于历史知识库的 AI 问题排查,357 条记录', description: '基于历史知识库的 AI 问题排查,357 条记录',
url: '/troubleshoot', url: '/troubleshoot',
......
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import {
getTargets,
createTarget,
updateTarget,
deleteTarget,
testConnection,
} from '@/api/service-manage'
import type {
ManageTarget,
CreateManageTargetRequest,
TestManageTargetRequest,
} from '@/types/service-manage'
// ============================================================
// 列表
// ============================================================
const loading = ref(false)
const targets = ref<ManageTarget[]>([])
async function loadTargets() {
loading.value = true
try {
const res = await getTargets()
if (res.success) {
targets.value = res.targets
}
} finally {
loading.value = false
}
}
// ============================================================
// 新增/编辑对话框
// ============================================================
const dialogVisible = ref(false)
const dialogTitle = ref('新增目标')
const editingId = ref('')
const emptyForm = (): CreateManageTargetRequest => ({
name: '',
host: '',
port: 443,
protocol: 'https',
auth_type: 'password',
username: '',
password: '',
})
const form = ref<CreateManageTargetRequest>(emptyForm())
const formRef = ref<FormInstance>()
const rules: FormRules = {
name: [{ required: true, message: '请输入目标名称', trigger: 'blur' }],
host: [{ required: true, message: '请输入主机地址', trigger: 'blur' }],
port: [{ required: true, message: '请输入端口', trigger: 'blur' }],
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
function openCreate() {
dialogTitle.value = '新增目标'
editingId.value = ''
form.value = emptyForm()
dialogVisible.value = true
}
function openEdit(target: ManageTarget) {
dialogTitle.value = '编辑目标'
editingId.value = target.id
form.value = {
name: target.name,
host: target.host,
port: target.port,
protocol: target.protocol,
auth_type: target.auth_type,
username: target.username,
password: '',
}
dialogVisible.value = true
}
// ============================================================
// 测试连接
// ============================================================
const testing = ref(false)
async function handleTestConnection() {
const payload: TestManageTargetRequest = {
host: form.value.host,
port: form.value.port,
protocol: form.value.protocol,
username: form.value.username,
password: form.value.password,
}
if (!payload.host) {
ElMessage.warning('请先填写主机地址')
return
}
testing.value = true
try {
const res = await testConnection(payload)
if (res.success) {
ElMessage.success(res.message || '连接成功')
} else {
ElMessage.error(res.message || '连接失败')
}
} finally {
testing.value = false
}
}
// ============================================================
// 保存
// ============================================================
async function handleSave() {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
const loadingIns = ElLoading.service({ text: '正在保存...' })
try {
if (editingId.value) {
const res = await updateTarget(editingId.value, form.value)
if (res.success) ElMessage.success('目标更新成功')
} else {
const res = await createTarget(form.value)
if (res.success) ElMessage.success('目标新增成功')
}
dialogVisible.value = false
await loadTargets()
} finally {
loadingIns.close()
}
}
// ============================================================
// 删除
// ============================================================
async function handleDelete(target: ManageTarget) {
try {
await ElMessageBox.confirm(
`确定要删除目标「${target.name}」吗?`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch {
return // 用户取消
}
const loadingIns = ElLoading.service({ text: '正在删除...' })
try {
const res = await deleteTarget(target.id)
if (res.success) {
ElMessage.success('目标已删除')
await loadTargets()
}
} finally {
loadingIns.close()
}
}
// ============================================================
// 初始化
// ============================================================
onMounted(loadTargets)
</script>
<template>
<div class="page-target-config">
<div class="section-head">
<h1 class="section-title">🎯 目标配置</h1>
<el-button type="primary" @click="openCreate">+ 新增目标</el-button>
</div>
<el-card class="table-card" shadow="never">
<el-table v-loading="loading" :data="targets" stripe>
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column label="地址" min-width="160">
<template #default="{ row }">
<span>{{ row.protocol }}://{{ row.host }}:{{ row.port }}</span>
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column label="密码" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.has_password ? 'success' : 'info'">
{{ row.has_password ? '已设置' : '未设置' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row as ManageTarget)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row as ManageTarget)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无目标配置,点击右上角「新增目标」添加" />
</template>
</el-table>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入目标名称(如:5.44 统一管理平台)" />
</el-form-item>
<el-form-item label="地址" prop="host">
<el-input v-model="form.host" placeholder="请输入目标服务器 IP 或域名" />
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input-number v-model="form.port" :min="1" :max="65535" style="width: 180px" />
</el-form-item>
<el-form-item label="协议" prop="protocol">
<el-radio-group v-model="form.protocol">
<el-radio value="https">HTTPS</el-radio>
<el-radio value="http">HTTP</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="认证方式" prop="auth_type">
<el-radio-group v-model="form.auth_type">
<el-radio value="password">密码</el-radio>
<el-radio value="token">Token</el-radio>
<el-radio value="none"></el-radio>
</el-radio-group>
</el-form-item>
<template v-if="form.auth_type === 'password'">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入登录用户名" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="请输入登录密码" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button :loading="testing" @click="handleTestConnection">测试连接</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.page-target-config {
padding: 0 0 40px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.section-title {
font-size: 20px;
font-weight: 700;
margin: 0;
color: $gray-900;
}
.table-card {
border-radius: 12px;
:deep(.el-card__body) {
padding: 0;
}
}
</style>
\ No newline at end of file
...@@ -38,6 +38,11 @@ const config = ref<NotificationConfig>({ ...@@ -38,6 +38,11 @@ const config = ref<NotificationConfig>({
alert_level: 'critical', alert_level: 'critical',
channels: [], channels: [],
}, },
report_missing: {
enabled: false,
missing_days: 2,
channels: [],
},
}) })
const loading = ref(false) const loading = ref(false)
...@@ -68,6 +73,7 @@ async function loadConfig() { ...@@ -68,6 +73,7 @@ async function loadConfig() {
wecom: res.config.wecom || config.value.wecom, wecom: res.config.wecom || config.value.wecom,
trigger: res.config.trigger || config.value.trigger, trigger: res.config.trigger || config.value.trigger,
alert_on_consecutive: res.config.alert_on_consecutive || config.value.alert_on_consecutive, alert_on_consecutive: res.config.alert_on_consecutive || config.value.alert_on_consecutive,
report_missing: res.config.report_missing || config.value.report_missing,
} }
emailRecipients.value = (config.value.email.recipients || []).join(', ') emailRecipients.value = (config.value.email.recipients || []).join(', ')
dingtalkAtMobiles.value = (config.value.dingtalk.at_mobiles || []).join(', ') dingtalkAtMobiles.value = (config.value.dingtalk.at_mobiles || []).join(', ')
...@@ -161,6 +167,21 @@ function toggleAlertChannel(channel: string) { ...@@ -161,6 +167,21 @@ function toggleAlertChannel(channel: string) {
else channels.push(channel) else channels.push(channel)
} }
// Missing report channels toggle
function isMissingChannel(channel: string) {
return (config.value.report_missing?.channels || []).includes(channel)
}
function toggleMissingChannel(channel: string) {
if (!config.value.report_missing) {
config.value.report_missing = { enabled: false, missing_days: 2, channels: [] }
}
const channels = config.value.report_missing.channels || []
const idx = channels.indexOf(channel)
if (idx >= 0) channels.splice(idx, 1)
else channels.push(channel)
}
onMounted(loadConfig) onMounted(loadConfig)
</script> </script>
...@@ -325,6 +346,32 @@ onMounted(loadConfig) ...@@ -325,6 +346,32 @@ onMounted(loadConfig)
</div> </div>
</el-card> </el-card>
<!-- Report Missing Section -->
<el-card class="config-card">
<template #header>
<div class="card-header">
<span class="card-title">📭 报告缺失告警</span>
<el-switch v-model="config.report_missing!.enabled" />
</div>
</template>
<div v-if="config.report_missing?.enabled" class="config-body">
<div class="form-row">
<el-form-item label="缺失天数阈值">
<el-input-number v-model="config.report_missing!.missing_days" :min="1" :max="14" />
<div class="form-hint">目标超过 N 天未生成报告时发送告警(每天 09:30 检测)</div>
</el-form-item>
</div>
<el-form-item label="通知渠道">
<div class="channel-checks">
<el-checkbox :model-value="isMissingChannel('email')" @change="toggleMissingChannel('email')">邮件</el-checkbox>
<el-checkbox :model-value="isMissingChannel('dingtalk')" @change="toggleMissingChannel('dingtalk')">钉钉</el-checkbox>
<el-checkbox :model-value="isMissingChannel('wecom')" @change="toggleMissingChannel('wecom')">企微</el-checkbox>
</div>
<div class="form-hint">复用上方已配置的通知渠道</div>
</el-form-item>
</div>
</el-card>
<!-- Save Button --> <!-- Save Button -->
<div class="actions-bar"> <div class="actions-bar">
<el-button type="primary" :loading="saving" @click="saveConfig()">保存配置</el-button> <el-button type="primary" :loading="saving" @click="saveConfig()">保存配置</el-button>
......
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { statisticsApi, targetApi } from '@/api/service-monitor' import { statisticsApi, targetApi, notificationApi } from '@/api/service-monitor'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import type { Target, OverviewStats, ItemKeysModule } from '@/types/service-monitor' import type { Target, OverviewStats, ItemKeysModule, HealthTrendData } from '@/types/service-monitor'
// Filters // Filters
const targets = ref<Target[]>([]) const targets = ref<Target[]>([])
...@@ -11,8 +11,12 @@ const filterStartDate = ref('') ...@@ -11,8 +11,12 @@ const filterStartDate = ref('')
const filterEndDate = ref('') const filterEndDate = ref('')
const loading = ref(false) const loading = ref(false)
// Missing report alert
const missingTargets = ref<Array<{ target_name: string; target_id: string; last_report_at: string | null; missing_days: number }>>([])
// Overview data // Overview data
const overview = ref<OverviewStats | null>(null) const overview = ref<OverviewStats | null>(null)
const healthTrendData = ref<HealthTrendData | null>(null)
// Item selector // Item selector
const cachedModulesData = ref<ItemKeysModule[]>([]) const cachedModulesData = ref<ItemKeysModule[]>([])
...@@ -22,6 +26,7 @@ const selectedItemKey = ref('') ...@@ -22,6 +26,7 @@ const selectedItemKey = ref('')
// Charts // Charts
let chartModules: echarts.ECharts | null = null let chartModules: echarts.ECharts | null = null
let chartHealthTrend: echarts.ECharts | null = null
let chartTrendLine: echarts.ECharts | null = null let chartTrendLine: echarts.ECharts | null = null
let chartTrendPie: echarts.ECharts | null = null let chartTrendPie: echarts.ECharts | null = null
let chartAbnormal: echarts.ECharts | null = null let chartAbnormal: echarts.ECharts | null = null
...@@ -46,11 +51,13 @@ function initDates() { ...@@ -46,11 +51,13 @@ function initDates() {
// Init charts // Init charts
function initCharts() { function initCharts() {
const chartModulesEl = document.getElementById('chartModules') const chartModulesEl = document.getElementById('chartModules')
const chartHealthTrendEl = document.getElementById('chartHealthTrend')
const chartTrendLineEl = document.getElementById('chartTrendLine') const chartTrendLineEl = document.getElementById('chartTrendLine')
const chartTrendPieEl = document.getElementById('chartTrendPie') const chartTrendPieEl = document.getElementById('chartTrendPie')
const chartAbnormalEl = document.getElementById('chartAbnormal') const chartAbnormalEl = document.getElementById('chartAbnormal')
if (chartModulesEl) chartModules = echarts.init(chartModulesEl) if (chartModulesEl) chartModules = echarts.init(chartModulesEl)
if (chartHealthTrendEl) chartHealthTrend = echarts.init(chartHealthTrendEl)
if (chartTrendLineEl) chartTrendLine = echarts.init(chartTrendLineEl) if (chartTrendLineEl) chartTrendLine = echarts.init(chartTrendLineEl)
if (chartTrendPieEl) chartTrendPie = echarts.init(chartTrendPieEl) if (chartTrendPieEl) chartTrendPie = echarts.init(chartTrendPieEl)
if (chartAbnormalEl) chartAbnormal = echarts.init(chartAbnormalEl) if (chartAbnormalEl) chartAbnormal = echarts.init(chartAbnormalEl)
...@@ -60,6 +67,7 @@ function initCharts() { ...@@ -60,6 +67,7 @@ function initCharts() {
function handleResize() { function handleResize() {
chartModules?.resize() chartModules?.resize()
chartHealthTrend?.resize()
chartTrendLine?.resize() chartTrendLine?.resize()
chartTrendPie?.resize() chartTrendPie?.resize()
chartAbnormal?.resize() chartAbnormal?.resize()
...@@ -128,6 +136,76 @@ async function fetchModules() { ...@@ -128,6 +136,76 @@ async function fetchModules() {
} }
} }
async function fetchHealthTrend() {
if (!chartHealthTrend) return
try {
const res = await statisticsApi.getHealthTrend(getParams())
if (res.success && res.data) {
const data = res.data
healthTrendData.value = data
const points = data.data_points || []
if (points.length === 0) {
chartHealthTrend.setOption({
title: { text: '暂无数据', left: 'center', top: 'center', textStyle: { color: '#9ca3af', fontSize: 14 } }
})
return
}
const dates = points.map(p => p.date)
const scores = points.map(p => p.score)
chartHealthTrend.setOption({
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const idx = params[0].dataIndex
const p = points[idx]
if (!p) return ''
let s = p.date + '<br/>'
s += '健康度: ' + p.score + ' 分<br/>'
s += '检测项: ' + p.total_items + ' 项<br/>'
s += '🟢 正常: ' + p.normal_count + '<br/>'
s += '🟡 警告: ' + p.warning_count + '<br/>'
s += '🔴 严重: ' + p.critical_count
return s
},
axisPointer: { type: 'cross' }
},
legend: { data: ['健康度'], bottom: 0 },
grid: { left: 10, right: 20, bottom: 40, top: 30, containLabel: true },
xAxis: { type: 'category', data: dates, axisLabel: { rotate: 30, fontSize: 10 } },
yAxis: { type: 'value', min: 0, max: 100, name: '健康度' },
series: [{
name: '健康度',
type: 'line',
data: scores,
smooth: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: { color: COLORS.primary, width: 2 },
itemStyle: { color: COLORS.primary },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(37, 99, 235, 0.25)' },
{ offset: 1, color: 'rgba(37, 99, 235, 0.02)' }
])
},
markLine: {
silent: true,
symbol: 'none',
data: [
{ yAxis: 80, lineStyle: { color: '#16a34a', type: 'dashed', width: 1 }, label: { formatter: '优良线 80', fontSize: 10 } },
{ yAxis: 60, lineStyle: { color: '#d97706', type: 'dashed', width: 1 }, label: { formatter: '及格线 60', fontSize: 10 } }
]
}
}],
})
}
} catch {
// silent
}
}
async function fetchItemKeys() { async function fetchItemKeys() {
try { try {
const res = await statisticsApi.getItemKeys(getParams()) const res = await statisticsApi.getItemKeys(getParams())
...@@ -186,6 +264,17 @@ async function fetchAbnormalItems() { ...@@ -186,6 +264,17 @@ async function fetchAbnormalItems() {
} }
} }
async function fetchMissingReports() {
try {
const res = await notificationApi.checkMissingReports()
if (res.success && res.data) {
missingTargets.value = res.data.missing_targets || []
}
} catch {
// silent
}
}
async function fetchItemTrend() { async function fetchItemTrend() {
if (!chartTrendLine || !chartTrendPie) return if (!chartTrendLine || !chartTrendPie) return
if (!selectedItemKey.value) { if (!selectedItemKey.value) {
...@@ -344,8 +433,10 @@ async function fetchAllData() { ...@@ -344,8 +433,10 @@ async function fetchAllData() {
await Promise.all([ await Promise.all([
fetchOverview(), fetchOverview(),
fetchModules(), fetchModules(),
fetchHealthTrend(),
fetchItemKeys(), fetchItemKeys(),
fetchAbnormalItems(), fetchAbnormalItems(),
fetchMissingReports(),
]) ])
// If item selector has value, refresh trend // If item selector has value, refresh trend
if (selectedItemKey.value) { if (selectedItemKey.value) {
...@@ -382,6 +473,7 @@ onMounted(async () => { ...@@ -382,6 +473,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
chartModules?.dispose() chartModules?.dispose()
chartHealthTrend?.dispose()
chartTrendLine?.dispose() chartTrendLine?.dispose()
chartTrendPie?.dispose() chartTrendPie?.dispose()
chartAbnormal?.dispose() chartAbnormal?.dispose()
...@@ -432,6 +524,22 @@ onUnmounted(() => { ...@@ -432,6 +524,22 @@ onUnmounted(() => {
</el-card> </el-card>
</div> </div>
<!-- Missing report alert banner -->
<el-card v-if="missingTargets.length > 0" class="missing-alert">
<div class="missing-alert-head">
<span class="missing-alert-title">📭 报告缺失告警</span>
<span class="missing-alert-count">{{ missingTargets.length }} 个目标超过阈值未生成报告</span>
</div>
<div class="missing-alert-list">
<div v-for="mt in missingTargets" :key="mt.target_id" class="missing-target">
<span class="target-name">{{ mt.target_name }}</span>
<span class="target-detail">已缺 {{ mt.missing_days }}</span>
<span class="target-detail" v-if="mt.last_report_at">上次报告: {{ mt.last_report_at.replace('T', ' ').slice(0, 16) }}</span>
<span class="target-detail" v-else>从未生成报告</span>
</div>
</div>
</el-card>
<!-- Module health --> <!-- Module health -->
<el-card class="chart-card"> <el-card class="chart-card">
<template #header> <template #header>
...@@ -440,6 +548,22 @@ onUnmounted(() => { ...@@ -440,6 +548,22 @@ onUnmounted(() => {
<div id="chartModules" class="chart-container"></div> <div id="chartModules" class="chart-container"></div>
</el-card> </el-card>
<!-- Health trend -->
<el-card class="chart-card">
<template #header>
<span class="chart-title">📈 健康度趋势</span>
</template>
<div class="trend-summary" v-if="healthTrendData">
<span class="trend-summary-item">
整体评分: <strong>{{ healthTrendData.overall_score }}</strong>
</span>
<span class="trend-summary-item">
报告数: <strong>{{ healthTrendData.data_points.length }}</strong>
</span>
</div>
<div id="chartHealthTrend" class="chart-container"></div>
</el-card>
<!-- Item trend --> <!-- Item trend -->
<el-card class="chart-card"> <el-card class="chart-card">
<template #header> <template #header>
...@@ -565,6 +689,58 @@ onUnmounted(() => { ...@@ -565,6 +689,58 @@ onUnmounted(() => {
} }
} }
.missing-alert {
border-radius: 12px;
border: 1px solid #fecaca;
background: #fef2f2;
margin-bottom: 20px;
:deep(.el-card__body) {
padding: 16px 20px;
}
}
.missing-alert-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.missing-alert-title {
font-size: 15px;
font-weight: 700;
color: #b91c1c;
}
.missing-alert-count {
font-size: 13px;
color: #dc2626;
}
.missing-alert-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.missing-target {
display: flex;
align-items: center;
gap: 16px;
font-size: 14px;
color: #7f1d1d;
}
.missing-target .target-name {
font-weight: 600;
}
.missing-target .target-detail {
color: #b91c1c;
font-size: 13px;
}
.chart-header { .chart-header {
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -590,6 +766,22 @@ onUnmounted(() => { ...@@ -590,6 +766,22 @@ onUnmounted(() => {
min-height: 350px; min-height: 350px;
} }
.trend-summary {
display: flex;
gap: 24px;
margin-bottom: 16px;
padding: 8px 12px;
background: #f8fafc;
border-radius: 8px;
font-size: 14px;
color: #475569;
}
.trend-summary-item strong {
color: #2563eb;
font-size: 16px;
}
.trend-layout { .trend-layout {
display: flex; display: flex;
gap: 20px; gap: 20px;
......
...@@ -150,7 +150,7 @@ onMounted(() => { ...@@ -150,7 +150,7 @@ onMounted(() => {
<template> <template>
<div class="logs-wrapper"> <div class="logs-wrapper">
<AppNavbar show-module module-label="问题排查助手" logs-path="/troubleshoot/logs" /> <AppNavbar show-module module-label="运行维护平台" home-path="/troubleshoot" logs-path="/troubleshoot/logs" />
<div class="logs-page"> <div class="logs-page">
<div class="logs-header"> <div class="logs-header">
<h2>操作日志</h2> <h2>操作日志</h2>
......
...@@ -4,9 +4,11 @@ import AppNavbar from '@/components/layout/AppNavbar.vue' ...@@ -4,9 +4,11 @@ import AppNavbar from '@/components/layout/AppNavbar.vue'
import ProductTree from '@/components/troubleshoot/ProductTree.vue' import ProductTree from '@/components/troubleshoot/ProductTree.vue'
import DocumentsPanel from '@/components/troubleshoot/DocumentsPanel.vue' import DocumentsPanel from '@/components/troubleshoot/DocumentsPanel.vue'
import FAQsPanel from '@/components/troubleshoot/FAQsPanel.vue' import FAQsPanel from '@/components/troubleshoot/FAQsPanel.vue'
import SearchResultsPanel from '@/components/troubleshoot/SearchResultsPanel.vue'
import { getProducts } from '@/api/troubleshoot' import { getProducts } from '@/api/troubleshoot'
import type { Product } from '@/types/troubleshoot' import type { Product } from '@/types/troubleshoot'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getLocalCache, setLocalCache } from '@/utils/localCache'
// 产品列表 // 产品列表
const products = ref<Product[]>([]) const products = ref<Product[]>([])
...@@ -17,6 +19,9 @@ const selectedProductId = ref<string | null>(null) ...@@ -17,6 +19,9 @@ const selectedProductId = ref<string | null>(null)
const selectedProductName = ref('') const selectedProductName = ref('')
const selectedNodeType = ref<'documents' | 'faqs' | null>(null) const selectedNodeType = ref<'documents' | 'faqs' | null>(null)
// 全局搜索模式
const globalSearching = ref(false)
// 搜索关键词 // 搜索关键词
const searchKeyword = ref('') const searchKeyword = ref('')
...@@ -33,9 +38,18 @@ const filteredProducts = computed(() => { ...@@ -33,9 +38,18 @@ const filteredProducts = computed(() => {
// 加载产品列表 // 加载产品列表
async function loadProducts() { async function loadProducts() {
try { try {
// 优先读取本地缓存
const cached = getLocalCache<Product[]>('products')
if (cached) {
products.value = cached
loading.value = false
return
}
const res = await getProducts() const res = await getProducts()
if (res.success) { if (res.success) {
products.value = res.products products.value = res.products
setLocalCache('products', res.products)
} }
} catch (e) { } catch (e) {
ElMessage.error('加载产品列表失败') ElMessage.error('加载产品列表失败')
...@@ -49,6 +63,19 @@ function handleNodeSelect(productId: string, productName: string, nodeType: 'doc ...@@ -49,6 +63,19 @@ function handleNodeSelect(productId: string, productName: string, nodeType: 'doc
selectedProductId.value = productId selectedProductId.value = productId
selectedProductName.value = productName selectedProductName.value = productName
selectedNodeType.value = nodeType selectedNodeType.value = nodeType
globalSearching.value = false
}
// 进入全局搜索模式
function handleEnterSearch() {
globalSearching.value = true
selectedProductId.value = null
selectedNodeType.value = null
}
// 退出搜索回到产品树
function handleExitSearch() {
globalSearching.value = false
} }
onMounted(() => { onMounted(() => {
...@@ -71,6 +98,15 @@ onMounted(() => { ...@@ -71,6 +98,15 @@ onMounted(() => {
<div class="panel-title"> <div class="panel-title">
<span class="title-icon">📦</span> <span class="title-icon">📦</span>
<span>产品列表</span> <span>产品列表</span>
<el-button
class="global-search-btn"
text
size="small"
type="primary"
@click="handleEnterSearch"
>
🔍 全局搜索
</el-button>
</div> </div>
<div class="search-box"> <div class="search-box">
...@@ -93,7 +129,13 @@ onMounted(() => { ...@@ -93,7 +129,13 @@ onMounted(() => {
<!-- 右侧面板:内容区 --> <!-- 右侧面板:内容区 -->
<main class="right-panel"> <main class="right-panel">
<div v-if="!selectedProductId" class="empty-state"> <!-- 全局搜索模式 -->
<SearchResultsPanel
v-if="globalSearching"
@exit="handleExitSearch"
/>
<div v-else-if="!selectedProductId" class="empty-state">
<div class="empty-icon">📋</div> <div class="empty-icon">📋</div>
<h3>请从左侧选择产品</h3> <h3>请从左侧选择产品</h3>
<p>点击产品下的「产品资料」或「常见问题」查看详细内容</p> <p>点击产品下的「产品资料」或「常见问题」查看详细内容</p>
......
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
clearCache, clearCache,
exportReport, exportReport,
submitRecord, submitRecord,
health,
} from '@/api/troubleshoot' } from '@/api/troubleshoot'
import type { MatchedCase } from '@/types/troubleshoot' import type { MatchedCase } from '@/types/troubleshoot'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
...@@ -32,6 +33,8 @@ const form = ref({ ...@@ -32,6 +33,8 @@ const form = ref({
const projectList = ref<string[]>([]) const projectList = ref<string[]>([])
const loading = ref(false) const loading = ref(false)
const loadingText = ref('正在连接服务...') const loadingText = ref('正在连接服务...')
// 动态知识库记录数(T8:从 /api/health 读取,不再硬编码)
const knowledgeBaseCount = ref(357)
// ============================================================ // ============================================================
// 结果数据 // 结果数据
...@@ -224,6 +227,7 @@ async function fallbackToNormalRequest() { ...@@ -224,6 +227,7 @@ async function fallbackToNormalRequest() {
apk_product: form.value.apkProduct || undefined, apk_product: form.value.apkProduct || undefined,
query: form.value.query, query: form.value.query,
matched_cases: searchRes.matched_cases || [], matched_cases: searchRes.matched_cases || [],
model: form.value.model,
}) })
if (analyzeRes.success) { if (analyzeRes.success) {
...@@ -479,7 +483,20 @@ const modelOptions = [ ...@@ -479,7 +483,20 @@ const modelOptions = [
onMounted(() => { onMounted(() => {
restoreModelSelection() restoreModelSelection()
loadProjects() loadProjects()
loadKnowledgeBaseCount()
}) })
// 动态获取知识库记录总数(T8:替换硬编码"357 条记录")
async function loadKnowledgeBaseCount() {
try {
const res = await health()
if (res.status === 'ok' && res.knowledge_base?.total_records != null) {
knowledgeBaseCount.value = res.knowledge_base.total_records
}
} catch {
// 获取失败保持默认值,不影响页面
}
}
</script> </script>
<template> <template>
...@@ -492,7 +509,7 @@ onMounted(() => { ...@@ -492,7 +509,7 @@ onMounted(() => {
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<h1 class="card-title">🔍 运行维护平台</h1> <h1 class="card-title">🔍 运行维护平台</h1>
<p class="card-subtitle">基于历史知识库的 AI 问题排查,357 条记录 · 安全过滤</p> <p class="card-subtitle">基于历史知识库的 AI 问题排查,{{ knowledgeBaseCount }} 条记录 · 安全过滤</p>
</div> </div>
</template> </template>
......
/// <reference types="vite/client" /> /// <reference types="vite/client" />
// 构建期注入的全局常量
declare const __FRONTEND_VERSION__: string
...@@ -5,6 +5,7 @@ import AutoImport from 'unplugin-auto-import/vite' ...@@ -5,6 +5,7 @@ import AutoImport from 'unplugin-auto-import/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import pkg from './package.json' with { type: 'json' }
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
...@@ -24,6 +25,11 @@ export default defineConfig({ ...@@ -24,6 +25,11 @@ export default defineConfig({
}), }),
], ],
// 构建期注入全局常量(前端版本号来自 package.json)
define: {
__FRONTEND_VERSION__: JSON.stringify(pkg.version),
},
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, 'src'), '@': path.resolve(__dirname, 'src'),
......
...@@ -74,7 +74,7 @@ def app(tmp_path, monkeypatch): ...@@ -74,7 +74,7 @@ def app(tmp_path, monkeypatch):
# mock AI 调用 # mock AI 调用
import services.ai_service as ai_service import services.ai_service as ai_service
monkeypatch.setattr(ai_service, "call_claude_api", lambda prompt: MOCK_AI_RESPONSE) monkeypatch.setattr(ai_service, "call_claude_api", lambda prompt, model=None: MOCK_AI_RESPONSE)
monkeypatch.setattr(ai_service, "call_claude_api_stream", lambda prompt, model=None: [MOCK_AI_RESPONSE]) monkeypatch.setattr(ai_service, "call_claude_api_stream", lambda prompt, model=None: [MOCK_AI_RESPONSE])
# 隔离 submit 的文件写入与索引重建 # 隔离 submit 的文件写入与索引重建
......
# -*- coding: utf-8 -*-
"""
test_service_manage.py — 服务管理模块业务层单元测试
被测模块:skill/code/web/services/service_manage.py
覆盖:
1. 目标配置 CRUD(list / create / update / delete / get_view)
2. 校验规则(名称/主机/协议/认证方式/端口/密码)
3. 密码脱敏(has_password 布尔,不返回密文)
4. test_connection 连通性测试(mock urllib)
5. 项目信息保存/读取(密码脱敏)
6. 文件占位生成与上传保存
所有测试用 tmp_path 隔离数据目录,不触碰真实 service_manage_data。
"""
# conftest 已把 skill/code/web 加入 sys.path
import json
from pathlib import Path
import pytest
import services.service_manage as sm
from utils import paths
@pytest.fixture
def manage(tmp_path, monkeypatch):
"""把 service_manage 模块级数据路径指向 tmp_path 隔离目录。"""
data_dir = tmp_path / "service_manage_data"
monkeypatch.setattr(sm, "SERVICE_MANAGE_DATA_DIR", data_dir)
monkeypatch.setattr(sm, "MANAGE_TARGETS_FILE", data_dir / "targets.json")
monkeypatch.setattr(sm, "MANAGE_PROJECTS_FILE", data_dir / "projects.json")
monkeypatch.setattr(sm, "MANAGE_UPLOADS_DIR", data_dir / "uploads")
return data_dir
# ============================================================
# 目标配置 CRUD
# ============================================================
class TestTargetCrud:
def test_create_and_list(self, manage):
"""新增目标后列表能读到,且密码已加密脱敏。"""
t = sm.create_target({
"name": "5.44平台",
"host": "192.168.5.44",
"port": 443,
"protocol": "https",
"auth_type": "password",
"username": "admin",
"password": "secret123",
})
assert t["id"]
assert t["name"] == "5.44平台"
assert t["host"] == "192.168.5.44"
assert t["has_password"] is True
# 对外视图不含密码密文
assert "password" not in t
assert "password_enc" not in t
targets = sm.list_targets()
assert len(targets) == 1
assert targets[0]["has_password"] is True
# 存储的是密文而非明文
stored = json.loads((manage / "targets.json").read_text(encoding="utf-8"))
assert stored[0]["password_enc"] != "secret123"
def test_create_requires_name(self, manage):
"""名称为空时报错。"""
with pytest.raises(ValueError):
sm.create_target({"name": "", "host": "1.2.3.4"})
def test_create_rejects_bad_host(self, manage):
"""非法主机地址报错。"""
with pytest.raises(ValueError):
sm.create_target({"name": "x", "host": "http://bad host!"})
def test_create_rejects_bad_protocol(self, manage):
"""协议非法报错。"""
with pytest.raises(ValueError):
sm.create_target({"name": "x", "host": "1.2.3.4", "protocol": "ftp"})
def test_create_requires_password_for_password_auth(self, manage):
"""密码认证方式必须提供用户名和密码。"""
with pytest.raises(ValueError):
sm.create_target({"name": "x", "host": "1.2.3.4", "auth_type": "password"})
with pytest.raises(ValueError):
sm.create_target({
"name": "x", "host": "1.2.3.4", "auth_type": "password",
"username": "u",
})
def test_create_rejects_bad_port(self, manage):
"""端口越界报错。"""
with pytest.raises(ValueError):
sm.create_target({"name": "x", "host": "1.2.3.4", "port": 70000})
def test_create_token_auth_no_password(self, manage):
"""Token 认证方式无需密码,username 可为空。"""
t = sm.create_target({
"name": "x", "host": "1.2.3.4", "auth_type": "token",
})
assert t["username"] == ""
assert t["has_password"] is False
def test_update_target(self, manage):
"""更新名称、主机、密码。"""
t = sm.create_target({
"name": "旧名", "host": "1.1.1.1", "port": 443,
"auth_type": "token",
})
updated = sm.update_target(t["id"], {
"name": "新名", "host": "2.2.2.2", "password": "newpass",
})
assert updated["name"] == "新名"
assert updated["host"] == "2.2.2.2"
assert updated["has_password"] is True
def test_update_missing_target(self, manage):
"""更新不存在目标报错。"""
with pytest.raises(ValueError):
sm.update_target("nope", {"name": "x"})
def test_delete_target(self, manage):
"""删除目标。"""
t = sm.create_target({"name": "x", "host": "1.2.3.4", "auth_type": "token"})
assert sm.delete_target(t["id"]) is True
assert sm.delete_target(t["id"]) is False # 已删除
assert sm.list_targets() == []
def test_get_target_view(self, manage):
"""按 id 获取脱敏视图。"""
t = sm.create_target({"name": "x", "host": "1.2.3.4", "auth_type": "token"})
view = sm.get_target_view(t["id"])
assert view["host"] == "1.2.3.4"
assert "password_enc" not in view
assert sm.get_target_view("nope") is None
# ============================================================
# 连通性测试
# ============================================================
class TestConnection:
def _fake_urlopen_success(self, req, timeout=10):
class _Resp:
def __init__(self):
self._code = 200
def getcode(self):
return self._code
def __enter__(self):
return self
def __exit__(self, *a):
return False
return _Resp()
def test_connection_success(self, manage, monkeypatch):
"""HTTP 200 返回成功。"""
monkeypatch.setattr("urllib.request.urlopen", self._fake_urlopen_success)
res = sm.test_connection({"host": "192.168.5.44", "port": 443, "protocol": "https"})
assert res["success"] is True
def test_connection_bad_host(self, manage):
"""非法主机返回失败而非抛异常。"""
res = sm.test_connection({"host": "bad host!"})
assert res["success"] is False
def test_connection_exception(self, manage, monkeypatch):
"""连接异常返回失败信息。"""
def _boom(req, timeout=10):
raise ConnectionError("timeout")
monkeypatch.setattr("urllib.request.urlopen", _boom)
res = sm.test_connection({"host": "10.0.0.1", "port": 9999})
assert res["success"] is False
assert "timeout" in res["message"]
# ============================================================
# 项目信息
# ============================================================
class TestProject:
def test_save_and_get(self, manage):
"""保存项目信息后能读取,密码脱敏。"""
res = sm.save_project({
"salesPeople": "张三",
"deployer": "李四",
"orderTime": "2026-08-01 10:00:00",
"serverPassword": "pwd123",
"projectOverview": "某项目",
})
assert res["success"] is True
p = sm.get_project()
assert p["salesPeople"] == "张三"
assert p["deployer"] == "李四"
assert p["has_password"] is True
assert "serverPassword" not in p
def test_get_no_project(self, manage):
"""无项目信息返回 None。"""
assert sm.get_project() is None
# ============================================================
# 文件操作
# ============================================================
class TestFiles:
def test_activation_file(self, manage):
"""激活文件生成在 uploads 目录。"""
p = sm.get_activation_file()
assert p.exists()
assert p.name == "licences.zip"
def test_deploy_view_file(self, manage):
"""部署视图文件带时间戳。"""
p = sm.get_deploy_view_file()
assert p.exists()
assert p.name.startswith("deploymentView")
assert p.name.endswith(".xlsx")
def test_self_check_file(self, manage):
"""自检文档文件带时间戳。"""
p = sm.get_self_check_file()
assert p.exists()
assert p.name.startswith("checkList")
assert p.name.endswith(".xlsx")
def test_save_uploaded_file(self, manage):
"""上传文件保存到子目录。"""
class _File:
def __init__(self, name, content):
self.filename = name
self._content = content
def save(self, path):
Path(path).write_bytes(self._content)
f = _File("auth.zip", b"binary-data")
res = sm.save_uploaded_file(f, subdir="license")
assert res["success"] is True
assert res["filename"] == "auth.zip"
assert res["size"] == len(b"binary-data")
saved = manage / "uploads" / "license" / "auth.zip"
assert saved.exists()
\ No newline at end of file
...@@ -52,19 +52,27 @@ class CacheManager: ...@@ -52,19 +52,27 @@ class CacheManager:
self.expire_seconds = expire_hours * 3600 self.expire_seconds = expire_hours * 3600
self.max_size_bytes = max_size_mb * 1024 * 1024 self.max_size_bytes = max_size_mb * 1024 * 1024
def _get_cache_key(self, project_name, system_type, apk_product, query): def _get_cache_key(self, project_name, system_type, apk_product, query, model=None):
"""生成缓存键(MD5 哈希)""" """生成缓存键(MD5 哈希)
content = f"{project_name}|{system_type}|{apk_product}|{query}"
缓存键含 model 维度(P3-3):切换模型后返回各自缓存。
model 为 None 时保持旧格式 key(兼容未传 model 的调用方)。
"""
model_part = f"|{model}" if model else ""
content = f"{project_name}|{system_type}|{apk_product}|{query}{model_part}"
return hashlib.md5(content.encode('utf-8')).hexdigest() return hashlib.md5(content.encode('utf-8')).hexdigest()
def get(self, project_name, system_type, apk_product, query): def get(self, project_name, system_type, apk_product, query, model=None):
""" """
获取缓存结果。 获取缓存结果。
参数:
model: 模型 ID(可选,加入缓存键维度)
返回: 返回:
缓存数据字典,或 None(缓存不存在或已过期) 缓存数据字典,或 None(缓存不存在或已过期)
""" """
key = self._get_cache_key(project_name, system_type, apk_product, query) key = self._get_cache_key(project_name, system_type, apk_product, query, model)
cache_file = self.cache_dir / f"{key}.json" cache_file = self.cache_dir / f"{key}.json"
if not cache_file.exists(): if not cache_file.exists():
...@@ -90,7 +98,7 @@ class CacheManager: ...@@ -90,7 +98,7 @@ class CacheManager:
logger.error(f"读取缓存文件失败: {cache_file} - {e}") logger.error(f"读取缓存文件失败: {cache_file} - {e}")
return None return None
def set(self, project_name, system_type, apk_product, query, response, matched_cases): def set(self, project_name, system_type, apk_product, query, response, matched_cases, model=None):
""" """
设置缓存。 设置缓存。
...@@ -101,8 +109,9 @@ class CacheManager: ...@@ -101,8 +109,9 @@ class CacheManager:
query: 问题描述 query: 问题描述
response: AI 响应内容 response: AI 响应内容
matched_cases: 匹配案例列表 matched_cases: 匹配案例列表
model: 模型 ID(可选,加入缓存键维度)
""" """
key = self._get_cache_key(project_name, system_type, apk_product, query) key = self._get_cache_key(project_name, system_type, apk_product, query, model)
cache_file = self.cache_dir / f"{key}.json" cache_file = self.cache_dir / f"{key}.json"
data = { data = {
......
...@@ -11,7 +11,12 @@ ...@@ -11,7 +11,12 @@
{ "id": "ds-003", "title": "门口屏5.0维护手册", "filename": "门口屏/03-门口屏5.0维护手册.docx", "type": "docx" }, { "id": "ds-003", "title": "门口屏5.0维护手册", "filename": "门口屏/03-门口屏5.0维护手册.docx", "type": "docx" },
{ "id": "ds-004", "title": "门口屏5.0第三方设备部署文档", "filename": "门口屏/04-门口屏5.0第三方设备部署文档.docx", "type": "docx" } { "id": "ds-004", "title": "门口屏5.0第三方设备部署文档", "filename": "门口屏/04-门口屏5.0第三方设备部署文档.docx", "type": "docx" }
], ],
"tags": ["门口屏", "会议显示", "MQTT"] "tags": ["门口屏", "会议显示", "MQTT"],
"faqs": [
{ "id": "faq-ds-001", "title": "门口屏绑定会议室失败", "phenomenon": "门口屏在绑定会议室时报错,无法完成绑定操作", "solution": "1. 检查门口屏绑定会议室的地址是否正确为:https://服务器IP/exapi\n2. 检查门口屏授权码是否已启用,且绑定了会议室\n3. 检查门口屏与服务器之间的网络通信状态" },
{ "id": "faq-ds-002", "title": "门口屏绑定MQTT失败,提示未连接到服务器", "phenomenon": "门口屏绑定时提示 MQTT 连接错误,无法连接到服务器", "solution": "1. 检查门口屏MQTT地址是否正确为:服务器IP:1883\n2. 检查门口屏MQTT账号密码是否正确为:mqtt@cmdb mqtt@webpassw0RD\n3. 检查门口屏与服务器之间的网络通信状态" },
{ "id": "faq-ds-003", "title": "门口屏未显示会议信息", "phenomenon": "会议开始后门口屏不显示会议主题、参会人等会议信息", "solution": "1. 检查门口屏是否已绑定会议室\n2. 访问服务器查看对外服务是否启动,指令:ps -ef | grep ubains-meeting-api-1.0-SNAPSHOT.jar\n3. 检查门口屏与服务器之间的网络通信状态" }
]
}, },
{ {
"id": "paperless", "id": "paperless",
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
version.py — 版本信息接口 version.py — 版本信息接口
前后端版本号分开管理:
- 后端版本:从环境变量 BACKEND_VERSION 读取,默认 '0.1.0'
- 前端版本:从环境变量 FRONTEND_VERSION 读取,默认 '0.1.0'
""" """
import os import os
...@@ -11,12 +15,12 @@ bp = Blueprint('version', __name__, url_prefix='/api') ...@@ -11,12 +15,12 @@ bp = Blueprint('version', __name__, url_prefix='/api')
@bp.route('/version', methods=['GET']) @bp.route('/version', methods=['GET'])
def get_version(): def get_version():
"""获取平台版本号(无需登录)""" """获取前后端版本号(无需登录)"""
return jsonify({ return jsonify({
'success': True, 'success': True,
'data': { 'data': {
'version': os.environ.get('APP_VERSION', '0.1.0'), 'backendVersion': os.environ.get('BACKEND_VERSION', '0.1.0'),
'frontendVersion': os.environ.get('FRONTEND_VERSION', '0.1.0'),
'buildTime': os.environ.get('BUILD_TIME', ''), 'buildTime': os.environ.get('BUILD_TIME', ''),
'name': 'troubleshoot-frontend'
} }
}) })
...@@ -604,6 +604,23 @@ def api_test_wecom(): ...@@ -604,6 +604,23 @@ def api_test_wecom():
return jsonify(result) return jsonify(result)
@bp.route('/api/service-monitor/notification/check-missing-reports', methods=['GET'])
def api_check_missing_reports():
"""检查并返回缺失报告的目标列表。"""
guard = _require_admin_json()
if guard:
return guard
try:
from .services import report_service
config = notification_service.get_config()
rm_cfg = config.get("report_missing", {})
missing_days = int(rm_cfg.get("missing_days", 2) or 2)
missing = report_service.check_missing_reports(missing_days=missing_days)
return jsonify({"success": True, "data": {"missing_targets": missing}})
except Exception as e:
return jsonify({"success": False, "error": {"code": 500, "message": str(e)}}), 500
# ============================================================ # ============================================================
# API:监测统计(登录即可查看) # API:监测统计(登录即可查看)
# ============================================================ # ============================================================
...@@ -650,6 +667,19 @@ def api_statistics_item_trend(): ...@@ -650,6 +667,19 @@ def api_statistics_item_trend():
return jsonify({"success": True, "data": data}) return jsonify({"success": True, "data": data})
@bp.route('/api/service-monitor/statistics/health-trend', methods=['GET'])
def api_statistics_health_trend():
"""健康度趋势。"""
guard = _require_login_json()
if guard:
return guard
target_id = request.args.get('target_id') or None
start_date = request.args.get('start_date') or None
end_date = request.args.get('end_date') or None
data = statistics_service.get_health_trend(target_id, start_date, end_date)
return jsonify({"success": True, "data": data})
@bp.route('/api/service-monitor/statistics/abnormal-items', methods=['GET']) @bp.route('/api/service-monitor/statistics/abnormal-items', methods=['GET'])
def api_statistics_abnormal_items(): def api_statistics_abnormal_items():
"""高频异常检测项。""" """高频异常检测项。"""
......
...@@ -104,6 +104,7 @@ def list_targets(role: str) -> list: ...@@ -104,6 +104,7 @@ def list_targets(role: str) -> list:
"username": t.get("username", ""), "username": t.get("username", ""),
"has_password": bool(t.get("password_enc")), "has_password": bool(t.get("password_enc")),
"use_sudo": t.get("use_sudo", False), # 是否需要 sudo 提权 "use_sudo": t.get("use_sudo", False), # 是否需要 sudo 提权
"workdir_base": t.get("workdir_base"), # 自定义工作目录
"built_in": t.get("built_in", False), "built_in": t.get("built_in", False),
"container_overrides": t.get("container_overrides", {}), "container_overrides": t.get("container_overrides", {}),
"thresholds": t.get("thresholds", {}), "thresholds": t.get("thresholds", {}),
...@@ -150,6 +151,7 @@ def _build_view(t: dict) -> dict: ...@@ -150,6 +151,7 @@ def _build_view(t: dict) -> dict:
"username": t.get("username", ""), "username": t.get("username", ""),
"has_password": bool(t.get("password_enc")), "has_password": bool(t.get("password_enc")),
"use_sudo": t.get("use_sudo", False), # 是否需要 sudo 提权 "use_sudo": t.get("use_sudo", False), # 是否需要 sudo 提权
"workdir_base": t.get("workdir_base"), # 自定义工作目录
"built_in": t.get("built_in", False), "built_in": t.get("built_in", False),
"container_overrides": t.get("container_overrides", {}), "container_overrides": t.get("container_overrides", {}),
"thresholds": t.get("thresholds", {}), "thresholds": t.get("thresholds", {}),
...@@ -204,6 +206,7 @@ def create_target(data: dict) -> dict: ...@@ -204,6 +206,7 @@ def create_target(data: dict) -> dict:
"username": username, "username": username,
"password_enc": encrypt_password(password), "password_enc": encrypt_password(password),
"use_sudo": bool(data.get("use_sudo", False)), # 是否需要 sudo 提权 "use_sudo": bool(data.get("use_sudo", False)), # 是否需要 sudo 提权
"workdir_base": data.get("workdir_base") or None, # 自定义工作目录(如 /home/openkylin/check_modules)
"container_overrides": _sanitize_overrides(data.get("container_overrides")), "container_overrides": _sanitize_overrides(data.get("container_overrides")),
"credential_overrides": _encrypt_creds(data.get("credential_overrides")), "credential_overrides": _encrypt_creds(data.get("credential_overrides")),
"thresholds": _sanitize_thresholds(data.get("thresholds")), "thresholds": _sanitize_thresholds(data.get("thresholds")),
...@@ -251,6 +254,9 @@ def update_target(target_id: str, data: dict) -> dict: ...@@ -251,6 +254,9 @@ def update_target(target_id: str, data: dict) -> dict:
# sudo 提权选项 # sudo 提权选项
if "use_sudo" in data: if "use_sudo" in data:
target["use_sudo"] = bool(data["use_sudo"]) target["use_sudo"] = bool(data["use_sudo"])
# 自定义工作目录
if "workdir_base" in data:
target["workdir_base"] = data["workdir_base"] or None
# 通用可改字段 # 通用可改字段
if "container_overrides" in data: if "container_overrides" in data:
...@@ -363,6 +369,9 @@ def make_executor(target: dict, run_id: str): ...@@ -363,6 +369,9 @@ def make_executor(target: dict, run_id: str):
# 是否需要 sudo 提权(用户非 root 时) # 是否需要 sudo 提权(用户非 root 时)
use_sudo = target.get("use_sudo", False) use_sudo = target.get("use_sudo", False)
# 自定义工作目录(如 9.89 麒麟系统只能上传到 /home/openkylin/)
workdir_base = target.get("workdir_base") or None
# 合并目标级凭据覆盖(如 mysql/redis 密码) # 合并目标级凭据覆盖(如 mysql/redis 密码)
exe = SSHExecutor( exe = SSHExecutor(
run_id=run_id, run_id=run_id,
...@@ -371,6 +380,7 @@ def make_executor(target: dict, run_id: str): ...@@ -371,6 +380,7 @@ def make_executor(target: dict, run_id: str):
username=target["username"], username=target["username"],
password=password, password=password,
use_sudo=use_sudo, use_sudo=use_sudo,
workdir_base=workdir_base,
) )
return exe return exe
......
...@@ -29,11 +29,17 @@ def tmp_data(tmp_path, monkeypatch): ...@@ -29,11 +29,17 @@ def tmp_data(tmp_path, monkeypatch):
reports_dir = data_dir / "reports" reports_dir = data_dir / "reports"
reports_dir.mkdir(parents=True, exist_ok=True) reports_dir.mkdir(parents=True, exist_ok=True)
targets_file = data_dir / "targets.json" targets_file = data_dir / "targets.json"
report_index_file = data_dir / "report_index.json"
cooldown_file = data_dir / "alert_cooldown.json"
monkeypatch.setattr(sm_paths, "DATA_DIR", data_dir) monkeypatch.setattr(sm_paths, "DATA_DIR", data_dir)
monkeypatch.setattr(sm_paths, "REPORTS_DIR", reports_dir) monkeypatch.setattr(sm_paths, "REPORTS_DIR", reports_dir)
monkeypatch.setattr(sm_paths, "TARGETS_FILE", targets_file) monkeypatch.setattr(sm_paths, "TARGETS_FILE", targets_file)
monkeypatch.setattr(sm_paths, "REPORT_INDEX_FILE", report_index_file)
monkeypatch.setattr(sm_paths, "COOLDOWN_FILE", cooldown_file)
# service 模块在导入时已绑定常量引用,需同步 patch # service 模块在导入时已绑定常量引用,需同步 patch
monkeypatch.setattr(target_service, "TARGETS_FILE", targets_file) monkeypatch.setattr(target_service, "TARGETS_FILE", targets_file)
monkeypatch.setattr(report_service, "REPORTS_DIR", reports_dir) monkeypatch.setattr(report_service, "REPORTS_DIR", reports_dir)
monkeypatch.setattr(report_service, "REPORT_INDEX_FILE", report_index_file)
monkeypatch.setattr(report_service, "COOLDOWN_FILE", cooldown_file)
return {"data": data_dir, "reports": reports_dir, "targets": targets_file} return {"data": data_dir, "reports": reports_dir, "targets": targets_file}
此差异已折叠。
此差异已折叠。
此差异已折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论