feat: 新增补丁管理和异常检测插件及相关功能
feat(protocol): 添加补丁管理和行为指标协议类型 feat(client): 实现补丁管理插件采集功能 feat(server): 添加补丁管理和异常检测API feat(database): 新增补丁状态和异常检测相关表 feat(web): 添加补丁管理和异常检测前端页面 fix(security): 增强输入验证和防注入保护 refactor(auth): 重构认证检查逻辑 perf(service): 优化Windows服务恢复策略 style: 统一健康评分显示样式 docs: 更新知识库文档
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Shared API client with authentication and error handling
|
||||
* Shared API client with cookie-based authentication.
|
||||
* Tokens are managed via HttpOnly cookies set by the server —
|
||||
* the frontend never reads or stores JWT tokens.
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
||||
@@ -21,43 +23,37 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token || token.trim() === '') return null
|
||||
return token
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null
|
||||
|
||||
/** Cached user info from /api/auth/me */
|
||||
let cachedUser: { id: number; username: string; role: string } | null = null
|
||||
|
||||
export function getCachedUser() {
|
||||
return cachedUser
|
||||
}
|
||||
|
||||
export function clearCachedUser() {
|
||||
cachedUser = null
|
||||
}
|
||||
|
||||
async function tryRefresh(): Promise<boolean> {
|
||||
// Coalesce concurrent refresh attempts
|
||||
if (refreshPromise) return refreshPromise
|
||||
|
||||
refreshPromise = (async () => {
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
if (!refreshToken || refreshToken.trim() === '') return false
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (!response.ok) return false
|
||||
|
||||
const result = await response.json()
|
||||
if (!result.success || !result.data?.access_token) return false
|
||||
if (!result.success) return false
|
||||
|
||||
localStorage.setItem('token', result.data.access_token)
|
||||
if (result.data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', result.data.refresh_token)
|
||||
// Update cached user from refresh response
|
||||
if (result.data?.user) {
|
||||
cachedUser = result.data.user
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
@@ -74,13 +70,8 @@ async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const token = getToken()
|
||||
const headers = new Headers(options.headers || {})
|
||||
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
|
||||
if (options.body && typeof options.body === 'string') {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
@@ -88,18 +79,21 @@ async function request<T>(
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
// Handle 401 - try refresh before giving up
|
||||
if (response.status === 401) {
|
||||
const refreshed = await tryRefresh()
|
||||
if (refreshed) {
|
||||
// Retry the original request with new token
|
||||
const newToken = getToken()
|
||||
headers.set('Authorization', `Bearer ${newToken}`)
|
||||
const retryResponse = await fetch(`${API_BASE}${path}`, { ...options, headers })
|
||||
const retryResponse = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (retryResponse.status === 401) {
|
||||
clearAuth()
|
||||
clearCachedUser()
|
||||
window.location.href = '/login'
|
||||
throw new ApiError(401, 'UNAUTHORIZED', 'Session expired')
|
||||
}
|
||||
const retryContentType = retryResponse.headers.get('content-type')
|
||||
@@ -112,7 +106,8 @@ async function request<T>(
|
||||
}
|
||||
return retryResult.data as T
|
||||
}
|
||||
clearAuth()
|
||||
clearCachedUser()
|
||||
window.location.href = '/login'
|
||||
throw new ApiError(401, 'UNAUTHORIZED', 'Session expired')
|
||||
}
|
||||
|
||||
@@ -159,11 +154,12 @@ export const api = {
|
||||
return request<T>(path, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
/** Login doesn't use the auth header */
|
||||
async login(username: string, password: string): Promise<{ access_token: string; refresh_token: string; user: { id: number; username: string; role: string } }> {
|
||||
/** Login — server sets HttpOnly cookies, we only get user info back */
|
||||
async login(username: string, password: string): Promise<{ user: { id: number; username: string; role: string } }> {
|
||||
const response = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
@@ -172,12 +168,27 @@ export const api = {
|
||||
throw new ApiError(response.status, 'LOGIN_FAILED', result.error || 'Login failed')
|
||||
}
|
||||
|
||||
localStorage.setItem('token', result.data.access_token)
|
||||
localStorage.setItem('refresh_token', result.data.refresh_token)
|
||||
cachedUser = result.data.user
|
||||
return result.data
|
||||
},
|
||||
|
||||
logout() {
|
||||
clearAuth()
|
||||
/** Logout — server clears cookies */
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
} catch {
|
||||
// Ignore errors during logout
|
||||
}
|
||||
clearCachedUser()
|
||||
},
|
||||
|
||||
/** Check current auth status via /api/auth/me */
|
||||
async me(): Promise<{ user: { id: number; username: string; role: string }; expires_at: string }> {
|
||||
const result = await request<{ user: { id: number; username: string; role: string }; expires_at: string }>('/api/auth/me')
|
||||
cachedUser = (result as { user: { id: number; username: string; role: string } }).user
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@@ -27,40 +27,43 @@ const router = createRouter({
|
||||
{ path: 'plugins/print-audit', name: 'PrintAudit', component: () => import('../views/plugins/PrintAudit.vue') },
|
||||
{ path: 'plugins/clipboard-control', name: 'ClipboardControl', component: () => import('../views/plugins/ClipboardControl.vue') },
|
||||
{ path: 'plugins/plugin-control', name: 'PluginControl', component: () => import('../views/plugins/PluginControl.vue') },
|
||||
{ path: 'plugins/patch', name: 'PatchManagement', component: () => import('../views/plugins/PatchManagement.vue') },
|
||||
{ path: 'plugins/anomaly', name: 'AnomalyDetection', component: () => import('../views/plugins/AnomalyDetection.vue') },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
/** Check if a JWT token is structurally valid and not expired */
|
||||
function isTokenValid(token: string): boolean {
|
||||
if (!token || token.trim() === '') return false
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return false
|
||||
const payload = JSON.parse(atob(parts[1]))
|
||||
if (!payload.exp) return false
|
||||
// Reject if token expires within 30 seconds
|
||||
return payload.exp * 1000 > Date.now() + 30_000
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
/** Track whether we've already validated auth this session */
|
||||
let authChecked = false
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
if (to.path === '/login') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token || !isTokenValid(token)) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
next('/login')
|
||||
} else {
|
||||
// If we've already verified auth this session, allow navigation
|
||||
// (cookies are sent automatically, no need to check on every route change)
|
||||
if (authChecked) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// Check auth status via /api/auth/me (reads access_token cookie)
|
||||
try {
|
||||
const { me } = await import('../lib/api')
|
||||
await me()
|
||||
authChecked = true
|
||||
next()
|
||||
} catch {
|
||||
next('/login')
|
||||
}
|
||||
})
|
||||
|
||||
/** Reset auth check flag (called after logout) */
|
||||
export function resetAuthCheck() {
|
||||
authChecked = false
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
@@ -41,6 +41,32 @@
|
||||
<div class="stat-label">USB事件(24h)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" @click="showHealthDetail = true" style="cursor:pointer">
|
||||
<div class="stat-icon" :class="healthIconClass">
|
||||
<span style="font-size:20px;font-weight:800;line-height:26px">{{ healthAvg }}</span>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" :class="healthTextClass">{{ healthAvg }}</div>
|
||||
<div class="stat-label">健康评分</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health overview bar -->
|
||||
<div v-if="healthSummary.total > 0" class="health-bar">
|
||||
<div class="health-bar-segment healthy" :style="{ flex: healthSummary.healthy }" :title="`${healthSummary.healthy} 健康`">
|
||||
<span v-if="healthSummary.healthy > 0">{{ healthSummary.healthy }} 健康</span>
|
||||
</div>
|
||||
<div class="health-bar-segment warning" :style="{ flex: healthSummary.warning }" :title="`${healthSummary.warning} 告警`">
|
||||
<span v-if="healthSummary.warning > 0">{{ healthSummary.warning }} 告警</span>
|
||||
</div>
|
||||
<div class="health-bar-segment critical" :style="{ flex: healthSummary.critical }" :title="`${healthSummary.critical} 严重`">
|
||||
<span v-if="healthSummary.critical > 0">{{ healthSummary.critical }} 严重</span>
|
||||
</div>
|
||||
<div class="health-bar-segment unknown" :style="{ flex: healthSummary.unknown || 0 }" :title="`${healthSummary.unknown || 0} 未知`">
|
||||
<span v-if="(healthSummary.unknown || 0) > 0">{{ healthSummary.unknown }} 未知</span>
|
||||
</div>
|
||||
<div class="health-bar-label">策略冲突: {{ conflictCount }} 项</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts row -->
|
||||
@@ -138,7 +164,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Monitor, Platform, Bell, Connection, Top } from '@element-plus/icons-vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { api } from '@/lib/api'
|
||||
@@ -148,6 +174,24 @@ const recentAlerts = ref<Array<{ id: number; severity: string; detail: string; t
|
||||
const recentUsbEvents = ref<Array<{ device_name: string; event_type: string; device_uid: string; event_time: string }>>([])
|
||||
const topDevices = ref<Array<{ hostname: string; cpu_usage: number; memory_usage: number; status: string }>>([])
|
||||
|
||||
const healthSummary = ref<{ total: number; healthy: number; warning: number; critical: number; unknown: number; avg_score: number }>({ total: 0, healthy: 0, warning: 0, critical: 0, unknown: 0, avg_score: 0 })
|
||||
const conflictCount = ref(0)
|
||||
const showHealthDetail = ref(false)
|
||||
|
||||
const healthAvg = computed(() => Math.round(healthSummary.value.avg_score))
|
||||
const healthIconClass = computed(() => {
|
||||
const s = healthAvg.value
|
||||
if (s >= 80) return 'health-good'
|
||||
if (s >= 50) return 'health-warn'
|
||||
return 'health-bad'
|
||||
})
|
||||
const healthTextClass = computed(() => {
|
||||
const s = healthAvg.value
|
||||
if (s >= 80) return 'text-good'
|
||||
if (s >= 50) return 'text-warn'
|
||||
return 'text-bad'
|
||||
})
|
||||
|
||||
const cpuChartRef = ref<HTMLElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -155,10 +199,12 @@ let resizeHandler: (() => void) | null = null
|
||||
|
||||
async function fetchDashboard() {
|
||||
try {
|
||||
const [devicesData, alertsData, usbData] = await Promise.all([
|
||||
const [devicesData, alertsData, usbData, healthData, conflictData] = await Promise.all([
|
||||
api.get<any>('/api/devices'),
|
||||
api.get<any>('/api/alerts/records?handled=0&page_size=10'),
|
||||
api.get<any>('/api/usb/events?page_size=10'),
|
||||
api.get<any>('/api/dashboard/health-overview').catch(() => null),
|
||||
api.get<any>('/api/policies/conflicts').catch(() => null),
|
||||
])
|
||||
|
||||
const devices = devicesData.devices || []
|
||||
@@ -179,6 +225,16 @@ async function fetchDashboard() {
|
||||
const events = usbData.events || []
|
||||
stats.value.usbEvents = events.length
|
||||
recentUsbEvents.value = events.slice(0, 8)
|
||||
|
||||
// Health overview
|
||||
if (healthData?.summary) {
|
||||
healthSummary.value = healthData.summary
|
||||
}
|
||||
|
||||
// Conflict count
|
||||
if (conflictData?.total !== undefined) {
|
||||
conflictCount.value = conflictData.total
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch dashboard data', e)
|
||||
}
|
||||
@@ -374,4 +430,51 @@ onUnmounted(() => {
|
||||
color: var(--csm-text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Health bar */
|
||||
.health-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-top: 16px;
|
||||
background: #f1f5f9;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.health-bar-segment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px;
|
||||
transition: flex 0.3s ease;
|
||||
}
|
||||
|
||||
.health-bar-segment.healthy { background: #16a34a; }
|
||||
.health-bar-segment.warning { background: #d97706; }
|
||||
.health-bar-segment.critical { background: #dc2626; }
|
||||
.health-bar-segment.unknown { background: #94a3b8; }
|
||||
|
||||
.health-bar-label {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Health score colors */
|
||||
.stat-icon.health-good { background: #f0fdf4; color: #16a34a; }
|
||||
.stat-icon.health-warn { background: #fffbeb; color: #d97706; }
|
||||
.stat-icon.health-bad { background: #fef2f2; color: #dc2626; }
|
||||
|
||||
.text-good { color: #16a34a !important; }
|
||||
.text-warn { color: #d97706 !important; }
|
||||
.text-bad { color: #dc2626 !important; }
|
||||
</style>
|
||||
|
||||
@@ -143,6 +143,15 @@
|
||||
<el-tag size="small" effect="plain" round>{{ row.group_name || '默认' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="健康" width="90" sortable :sort-method="(a: any, b: any) => (a.health_score ?? 0) - (b.health_score ?? 0)">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.health_score != null" class="health-cell">
|
||||
<span class="health-dot" :class="row.health_level"></span>
|
||||
<span class="health-value" :class="'text-' + healthClass(row.health_score)">{{ row.health_score }}</span>
|
||||
</div>
|
||||
<span v-else class="health-cell">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="CPU" width="100">
|
||||
<template #default="{ row }">
|
||||
<div class="usage-cell">
|
||||
@@ -380,6 +389,13 @@ function getProgressColor(value?: number): string {
|
||||
return '#16a34a'
|
||||
}
|
||||
|
||||
function healthClass(score?: number): string {
|
||||
if (score == null) return 'unknown'
|
||||
if (score >= 80) return 'good'
|
||||
if (score >= 50) return 'warn'
|
||||
return 'bad'
|
||||
}
|
||||
|
||||
function formatTime(t: string | null): string {
|
||||
if (!t) return '-'
|
||||
const d = new Date(t)
|
||||
@@ -795,6 +811,31 @@ async function handleMoveSubmit() {
|
||||
color: var(--csm-text-primary);
|
||||
}
|
||||
|
||||
/* Health cell */
|
||||
.health-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.health-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.health-dot.healthy { background: #16a34a; box-shadow: 0 0 4px rgba(22,163,74,0.4); }
|
||||
.health-dot.warning { background: #d97706; }
|
||||
.health-dot.critical { background: #dc2626; box-shadow: 0 0 4px rgba(220,38,38,0.3); }
|
||||
.health-dot.unknown { background: #94a3b8; }
|
||||
|
||||
.health-value { font-size: 13px; font-weight: 600; }
|
||||
.text-good { color: #16a34a; }
|
||||
.text-warn { color: #d97706; }
|
||||
.text-bad { color: #dc2626; }
|
||||
.text-unknown { color: #94a3b8; }
|
||||
|
||||
/* Pagination */
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
|
||||
@@ -76,6 +76,12 @@
|
||||
<el-menu-item index="/plugins/plugin-control">
|
||||
<template #title><span>插件控制</span></template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/plugins/patch">
|
||||
<template #title><span>补丁管理</span></template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/plugins/anomaly">
|
||||
<template #title><span>异常检测</span></template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item index="/settings">
|
||||
@@ -143,7 +149,8 @@ import {
|
||||
Monitor, Platform, Connection, Bell, Setting,
|
||||
ArrowDown, Grid, Expand, Fold, SwitchButton
|
||||
} from '@element-plus/icons-vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { api, getCachedUser } from '@/lib/api'
|
||||
import { resetAuthCheck } from '@/router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -153,15 +160,8 @@ const currentRoute = computed(() => route.path)
|
||||
const unreadAlerts = ref(0)
|
||||
const username = ref('')
|
||||
|
||||
function decodeUsername(): string {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) return ''
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
return payload.username || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
function getCachedUsername(): string {
|
||||
return getCachedUser()?.username || ''
|
||||
}
|
||||
|
||||
async function fetchUnreadAlerts() {
|
||||
@@ -189,18 +189,20 @@ const pageTitles: Record<string, string> = {
|
||||
'/plugins/print-audit': '打印审计',
|
||||
'/plugins/clipboard-control': '剪贴板管控',
|
||||
'/plugins/plugin-control': '插件控制',
|
||||
'/plugins/patch': '补丁管理',
|
||||
'/plugins/anomaly': '异常检测',
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => pageTitles[route.path] || '仪表盘')
|
||||
|
||||
onMounted(() => {
|
||||
username.value = decodeUsername()
|
||||
username.value = getCachedUsername()
|
||||
fetchUnreadAlerts()
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
async function handleLogout() {
|
||||
await api.logout()
|
||||
resetAuthCheck()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '@/lib/api'
|
||||
import { api, getCachedUser } from '@/lib/api'
|
||||
|
||||
const version = ref('0.1.0')
|
||||
const dbInfo = ref('SQLite (WAL mode)')
|
||||
@@ -96,14 +96,11 @@ const pwdForm = reactive({ oldPassword: '', newPassword: '', confirmPassword: ''
|
||||
const pwdLoading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
user.username = payload.username || 'admin'
|
||||
user.role = payload.role || 'admin'
|
||||
}
|
||||
} catch (e) { console.error('Failed to decode token for username', e) }
|
||||
const cached = getCachedUser()
|
||||
if (cached) {
|
||||
user.username = cached.username
|
||||
user.role = cached.role
|
||||
}
|
||||
|
||||
api.get<any>('/health')
|
||||
.then((data: any) => {
|
||||
|
||||
90
web/src/views/plugins/AnomalyDetection.vue
Normal file
90
web/src/views/plugins/AnomalyDetection.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="csm-card">
|
||||
<div class="csm-card-header">
|
||||
<span>异常行为检测</span>
|
||||
<el-tag v-if="unhandled > 0" type="danger" effect="light" size="small">{{ unhandled }} 未处理</el-tag>
|
||||
<el-tag v-else type="success" effect="light" size="small">无异常</el-tag>
|
||||
</div>
|
||||
<div class="csm-card-body">
|
||||
<el-table :data="alerts" v-loading="loading" size="small" max-height="520">
|
||||
<el-table-column prop="hostname" label="终端" width="140" />
|
||||
<el-table-column label="异常类型" width="180">
|
||||
<template #default="{ row }">
|
||||
<span>{{ anomalyLabel(row.anomaly_type) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="严重性" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="severityType(row.severity)" size="small" effect="light">{{ row.severity }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="detail" label="详情" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="triggered_at" label="检测时间" width="160" />
|
||||
</el-table>
|
||||
<div v-if="alerts.length === 0 && !loading" style="padding:40px 0;text-align:center;color:#94a3b8">
|
||||
暂无异常行为告警,系统运行正常
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const alerts = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const unhandled = ref(0)
|
||||
|
||||
const anomalyLabels: Record<string, string> = {
|
||||
night_clipboard_spike: '非工作时间剪贴板异常',
|
||||
usb_file_exfiltration: 'USB文件大量拷贝',
|
||||
high_print_volume: '打印量异常',
|
||||
process_spawn_spike: '进程启动频率异常',
|
||||
}
|
||||
|
||||
function anomalyLabel(type: string): string {
|
||||
return anomalyLabels[type] || type
|
||||
}
|
||||
|
||||
function severityType(s: string): string {
|
||||
if (s === 'critical') return 'danger'
|
||||
if (s === 'high') return 'warning'
|
||||
if (s === 'medium') return ''
|
||||
return 'info'
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<any>('/api/plugins/anomaly/alerts?page_size=50')
|
||||
alerts.value = data.alerts || []
|
||||
unhandled.value = data.unhandled_count || 0
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch anomaly alerts', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.csm-card-header {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--csm-text-primary);
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.csm-card-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
</style>
|
||||
92
web/src/views/plugins/PatchManagement.vue
Normal file
92
web/src/views/plugins/PatchManagement.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="csm-card">
|
||||
<div class="csm-card-header">
|
||||
<span>补丁管理</span>
|
||||
<el-tag type="info" effect="plain" size="small">{{ summary.total_installed }} 已安装 / {{ summary.total_missing }} 缺失</el-tag>
|
||||
</div>
|
||||
<div class="csm-card-body">
|
||||
<el-table :data="patches" v-loading="loading" size="small" max-height="520">
|
||||
<el-table-column prop="hostname" label="终端" width="140" />
|
||||
<el-table-column prop="kb_id" label="补丁编号" width="120" />
|
||||
<el-table-column prop="title" label="描述" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="severity" label="严重性" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.severity" :type="severityType(row.severity)" size="small" effect="light">{{ row.severity }}</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_installed ? 'success' : 'danger'" size="small" effect="light">
|
||||
{{ row.is_installed ? '已安装' : '缺失' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="installed_at" label="安装时间" width="120">
|
||||
<template #default="{ row }">{{ row.installed_at || '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="display:flex;justify-content:flex-end;padding-top:12px">
|
||||
<el-pagination :total="total" :page-size="pageSize" layout="total, prev, pager, next" @current-change="handlePage" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const patches = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const summary = ref({ total_installed: 0, total_missing: 0 })
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<any>(`/api/plugins/patch/status?page=${page.value}&page_size=${pageSize}`)
|
||||
patches.value = data.patches || []
|
||||
total.value = data.total || 0
|
||||
if (data.summary) summary.value = data.summary
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch patches', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePage(p: number) {
|
||||
page.value = p
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function severityType(s: string): string {
|
||||
if (s === 'Critical') return 'danger'
|
||||
if (s === 'Important') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.csm-card-header {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--csm-text-primary);
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--csm-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.csm-card-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.text-muted { color: #94a3b8; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user