feat: 监控告警体系统一重构 + 自动化任务调度引擎 + 监控大屏拆分
告警中心 - 新增统一告警(alert_unified)、监控告警规则(monitor_alert_rule)、指标目录(alert_metric_catalog) - 通知策略与消息分发(alert_notify_policy/dispatch/message)、静默规则 - 告警引擎按阈值/SNMP 拆分(alert_engine_monitor/snmp) 监控采集 - SNMP 常量/模板与历史指标(snmp templates/constants、snmp_metrics_history、snmp_collect) - 中间件监控 Redis 采集与种子(middleware_monitor_redis/seed) - 站点探测 pkg(siteprobe) + 站点视图(site_view) 自动化任务 - cron 调度器(cronscheduler)、脚本运行器(scriptrunner)、系统任务(systask)及运行记录(cron_job_run、system_task) 审计与凭证 - 审计设置(audit_setting) + 历史清理(auditcleaner) 前端 - 监控页面(host/middleware/site/snmp)拆分为子模块并大屏化 - 告警中心多 Tab(规则/指标目录/通知策略/静默)、任务管理组件重构、Agent CLI Markdown 渲染 - 移除旧脚本卡片/详情/日志组件,统一为 ScriptTable/ScriptCodeEditor/TerminalOutput Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
092d0e4b9e
commit
471353e3d7
190 files changed
+16640
-3484
No files matched your search
@@ -5,7 +5,21 @@ import { http } from '@/utils/request';
|
||||
|
||||
export const agentApi = {
|
||||
getLatest: () => http.get('/agent/latest'),
|
||||
getHistory: (hostname: string, hours = 1) => http.get(`/agent/history/${hostname}?hours=${hours}`),
|
||||
getHistory: (
|
||||
hostname: string,
|
||||
query: { minutes?: number; hours?: number; start?: string; end?: string } = { hours: 1 },
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (query.start && query.end) {
|
||||
params.set('start', query.start);
|
||||
params.set('end', query.end);
|
||||
} else if (query.minutes != null) {
|
||||
params.set('minutes', String(query.minutes));
|
||||
} else {
|
||||
params.set('hours', String(query.hours ?? 1));
|
||||
}
|
||||
return http.get(`/agent/history/${hostname}?${params.toString()}`);
|
||||
},
|
||||
createTask: (data: { hostname: string; type: string; command: string; timeout?: number }) => http.post('/agent/tasks', data),
|
||||
listTasks: (params?: Record<string, string>) => http.get('/agent/tasks', params),
|
||||
// 直连 SSH 执行命令(即时返回)
|
||||
|
||||
@@ -47,8 +47,9 @@ export const aiApi = {
|
||||
getConfig: () => http.get<LLMConfigData>('/aiops/llm-config'),
|
||||
// 保存 LLM 配置(api_key 若为脱敏值则后端保留原值)
|
||||
saveConfig: (data: LLMConfigData) => http.put<LLMConfigData>('/aiops/llm-config', data),
|
||||
// 测试 LLM 连接(询问模型身份,返回模型回答) // Logan.Li
|
||||
testConnection: () => http.post<LLMTestResult>('/aiops/llm-config/test'),
|
||||
// 测试 LLM 连接(可传草稿配置,保存前即可验证) // Logan.Li
|
||||
testConnection: (data?: Partial<LLMConfigData>) =>
|
||||
http.post<LLMTestResult>('/aiops/llm-config/test', data ?? {}),
|
||||
// AI 对话(messages 含历史 + 当前用户消息,session_id 区分多轮对话)
|
||||
chat: (messages: { role: 'user' | 'assistant'; content: string }[], sessionId?: string) =>
|
||||
http.post<ChatResult>('/aiops/chat', { messages, session_id: sessionId || '' }),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// 监控指标目录 & 统一告警 API // Logan.Li
|
||||
|
||||
import { http } from '@/utils/request';
|
||||
|
||||
export interface MetricCatalogItem {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
type: string;
|
||||
default_op: string;
|
||||
default_warn: number;
|
||||
default_crit: number;
|
||||
min: number;
|
||||
max: number;
|
||||
collect_source: string;
|
||||
description: string;
|
||||
operators: string[];
|
||||
}
|
||||
|
||||
export interface MetricCatalogCategory {
|
||||
id: 'host' | 'middleware' | 'snmp' | 'site';
|
||||
label: string;
|
||||
description: string;
|
||||
metrics: MetricCatalogItem[];
|
||||
}
|
||||
|
||||
export interface UnifiedAlertRule {
|
||||
id: number;
|
||||
category: 'host' | 'middleware' | 'snmp' | 'site';
|
||||
name: string;
|
||||
target?: string;
|
||||
target_id?: number;
|
||||
metric: string;
|
||||
operator: string;
|
||||
threshold: number;
|
||||
enabled: boolean;
|
||||
severity: string;
|
||||
description?: string;
|
||||
rule_type: 'agent' | 'monitor';
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export const alertMetricsApi = {
|
||||
getCatalog: () =>
|
||||
http.get<{ categories: MetricCatalogCategory[] }>('/alerts/metric-catalog'),
|
||||
|
||||
getUnifiedAlerts: (params?: {
|
||||
page?: number; page_size?: number;
|
||||
status?: string; severity?: string; source_type?: string;
|
||||
}) => http.get<{ list: unknown[]; total: number; stats: Record<string, number> }>('/alerts/unified', params),
|
||||
|
||||
getUnifiedRules: (category?: string) =>
|
||||
http.get<{ list: UnifiedAlertRule[]; total: number }>('/alerts/unified-rules', { category: category || 'all' }),
|
||||
|
||||
createMonitorRule: (data: Record<string, unknown>) =>
|
||||
http.post('/alerts/monitor-rules', data),
|
||||
|
||||
updateMonitorRule: (id: number, data: Record<string, unknown>) =>
|
||||
http.put(`/alerts/monitor-rules/${id}`, data),
|
||||
|
||||
deleteMonitorRule: (id: number) =>
|
||||
http.delete(`/alerts/monitor-rules/${id}`),
|
||||
|
||||
monitorQuickSetup: (category?: string) =>
|
||||
http.post('/alerts/monitor-quick-setup', { category: category || 'all' }),
|
||||
|
||||
resolveUnified: (source: string, id: number) =>
|
||||
http.post(`/alerts/unified/${source}/${id}/resolve`),
|
||||
};
|
||||
@@ -3,6 +3,12 @@
|
||||
import { http, type ListResponse } from '@/utils/request';
|
||||
import type { AlertRuleInput } from '@/types/monitor';
|
||||
|
||||
export interface NotifyMessageSettings {
|
||||
title_template: string;
|
||||
footer_template: string;
|
||||
platform_name: string;
|
||||
}
|
||||
|
||||
export const alertApi = {
|
||||
getAlerts: async (params?: { page?: number; page_size?: number; status?: string; severity?: string }) => {
|
||||
return http.get<ListResponse>('/alerts', params);
|
||||
@@ -70,4 +76,21 @@ export const alertApi = {
|
||||
deleteContactGroup: async (id: number | string) => {
|
||||
return http.delete(`/alerts/contact-groups/${id}`);
|
||||
},
|
||||
// 告警集合通知策略 // Logan.Li
|
||||
getNotifyPolicies: async () => {
|
||||
return http.get('/alerts/notify-policies');
|
||||
},
|
||||
upsertNotifyPolicy: async (category: string, data: Record<string, unknown>) => {
|
||||
return http.put(`/alerts/notify-policies/${category}`, data);
|
||||
},
|
||||
testNotifyPolicy: async (category: string) => {
|
||||
return http.post(`/alerts/notify-policies/${category}/test`);
|
||||
},
|
||||
// 告警消息模版(标题 / 底部) // Logan.Li
|
||||
getNotifyMessageSettings: async () => {
|
||||
return http.get('/alerts/notify-message-settings');
|
||||
},
|
||||
saveNotifyMessageSettings: async (data: NotifyMessageSettings) => {
|
||||
return http.put('/alerts/notify-message-settings', data);
|
||||
},
|
||||
};
|
||||
@@ -3,6 +3,8 @@
|
||||
export { alertApi } from './alert';
|
||||
export { alertCenterApi } from './alert-center';
|
||||
export { siteApi } from './site';
|
||||
export { alertMetricsApi } from './alert-metrics';
|
||||
export type { MetricCatalogItem, MetricCatalogCategory, UnifiedAlertRule } from './alert-metrics';
|
||||
export { traceApi } from './trace';
|
||||
export { traceDatasourceApi, type TraceDataSource } from './trace-datasource';
|
||||
export { dashboardApi } from './grafana';
|
||||
|
||||
@@ -20,6 +20,14 @@ export const snmpApi = {
|
||||
getDevices: () => http.get('/snmp/devices'),
|
||||
createDevice: (data: Record<string, unknown>) => http.post('/snmp/devices', data),
|
||||
deleteDevice: (id: number | string) => http.delete(`/snmp/devices/${id}`),
|
||||
getTemplates: () => http.get('/snmp/templates'),
|
||||
collectDevice: (id: number | string) => http.post(`/snmp/devices/${id}/collect`),
|
||||
getDeviceMetrics: (id: number | string) => http.get(`/snmp/devices/${id}/metrics`),
|
||||
getDeviceMetricSeries: (id: number | string) => http.get(`/snmp/devices/${id}/metrics/series`),
|
||||
getDeviceMetricHistory: (id: number | string, params: {
|
||||
metric_name: string; index?: string; hours?: number; limit?: number;
|
||||
}) => http.get(`/snmp/devices/${id}/metrics/history`, params),
|
||||
getOverviewMetrics: () => http.get('/snmp/overview/metrics'),
|
||||
};
|
||||
|
||||
// 中间件监控 — Redis/MySQL 后端直连采集 (M15)
|
||||
@@ -32,8 +40,10 @@ export const middlewareMonitorApi = {
|
||||
delete: (id: number | string) => http.delete(`/middleware-monitors/${id}`),
|
||||
test: (id: number | string) => http.post(`/middleware-monitors/${id}/test`),
|
||||
collectNow: () => http.post('/middleware-monitors/collect'),
|
||||
getMetrics: (id: number | string, params?: Record<string, string | number | undefined>) =>
|
||||
http.get(`/middleware-monitors/${id}/metrics`, params),
|
||||
seedDefaults: () => http.post('/middleware-monitors/seed-defaults'),
|
||||
getMetrics: (id: number | string, params?: {
|
||||
metric?: string; hours?: number; start?: number; end?: number;
|
||||
}) => http.get(`/middleware-monitors/${id}/metrics`, params),
|
||||
};
|
||||
|
||||
export const prometheusProxyApi = {
|
||||
|
||||
@@ -3,19 +3,28 @@
|
||||
import { http } from '@/utils/request';
|
||||
|
||||
export const siteApi = {
|
||||
getStats: async () => {
|
||||
return http.get('/site/stats');
|
||||
},
|
||||
getTasks: async () => {
|
||||
return http.get<{ list: unknown[]; total: number }>('/site/tasks');
|
||||
},
|
||||
createTask: async (data: { name: string; url: string; type: string; frequency_sec?: number; timeout_sec?: number; expected_status_code?: number }) => {
|
||||
return http.post('/site/tasks', data);
|
||||
},
|
||||
deleteTask: async (id: number | string) => {
|
||||
return http.delete(`/site/tasks/${id}`);
|
||||
},
|
||||
toggleTask: async (id: number | string, enabled: boolean) => {
|
||||
return http.put(`/site/tasks/${id}`, { enabled });
|
||||
},
|
||||
getStats: async () => http.get('/site/stats'),
|
||||
getTasks: async () => http.get<{ list: unknown[]; total: number }>('/site/tasks'),
|
||||
createTask: async (data: {
|
||||
name: string; url: string; type: string;
|
||||
frequency_sec?: number; timeout_sec?: number; expected_status_code?: number;
|
||||
enabled?: boolean;
|
||||
}) => http.post('/site/tasks', data),
|
||||
updateTask: async (id: number | string, data: {
|
||||
name?: string; url?: string; type?: string;
|
||||
frequency_sec?: number; timeout_sec?: number; expected_status_code?: number;
|
||||
enabled?: boolean;
|
||||
}) => http.put(`/site/tasks/${id}`, data),
|
||||
deleteTask: async (id: number | string) => http.delete(`/site/tasks/${id}`),
|
||||
toggleTask: async (id: number | string, enabled: boolean) =>
|
||||
http.put(`/site/tasks/${id}`, { enabled }),
|
||||
probeNow: async (id: number | string) => http.post(`/site/tasks/${id}/probe`),
|
||||
getResults: async (
|
||||
taskId: number | string,
|
||||
opts?: { limit?: number; hours?: number },
|
||||
) => http.get<{ list: unknown[]; total: number }>('/site/results', {
|
||||
task_id: taskId,
|
||||
limit: opts?.limit ?? 500,
|
||||
...(opts?.hours ? { hours: opts.hours } : {}),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
// 审计策略配置 API // Logan.Li
|
||||
|
||||
import { http } from '@/utils/request';
|
||||
|
||||
export interface AuditCleanupStats {
|
||||
retention_days: number;
|
||||
cutoff_time: string;
|
||||
audit_logs: number;
|
||||
login_logs: number;
|
||||
terminal_sessions: number;
|
||||
session_commands: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AuditSettings {
|
||||
retention_days: number;
|
||||
record_all: boolean;
|
||||
last_cleanup_at?: string | null;
|
||||
last_cleanup_summary?: string;
|
||||
allowed_retention_days?: number[];
|
||||
}
|
||||
|
||||
export const auditSettingsApi = {
|
||||
get: async () => http.get<AuditSettings>('/admin/audit-settings'),
|
||||
apply: async (data: { retention_days: number; record_all: boolean }) =>
|
||||
http.put<AuditSettings & { cleanup?: AuditCleanupStats }>('/admin/audit-settings', data),
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// 系统管理 API 聚合出口 // Logan.Li
|
||||
|
||||
export { userApi, ldapApi } from './user';
|
||||
export { auditSettingsApi } from './audit-settings';
|
||||
export type { AuditSettings, AuditCleanupStats } from './audit-settings';
|
||||
@@ -21,6 +21,9 @@ export const userApi = {
|
||||
deleteUser: async (id: number | string) => {
|
||||
return http.delete(`/admin/users/${id}`);
|
||||
},
|
||||
batchDelete: async (ids: number[]) => {
|
||||
return http.post('/admin/users/batch-delete', { ids });
|
||||
},
|
||||
batchImport: async (data: { users: Record<string, unknown>[]; skip_existing?: boolean }) => {
|
||||
return http.post('/admin/users/batch-import', data);
|
||||
},
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
export * from './cron-job';
|
||||
export * from './script';
|
||||
export * from './remote-exec';
|
||||
export * from './system-task';
|
||||
@@ -2,7 +2,22 @@
|
||||
|
||||
import { http, type ListResponse } from '@/utils/request';
|
||||
|
||||
export interface ScriptRuntimeInfo {
|
||||
shell: boolean;
|
||||
shell_path?: string;
|
||||
python: boolean;
|
||||
python_path?: string;
|
||||
powershell: boolean;
|
||||
powershell_path?: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
embed_mode: boolean;
|
||||
}
|
||||
|
||||
export const scriptApi = {
|
||||
getRuntime: async () => {
|
||||
return http.get<ScriptRuntimeInfo>('/scripts/runtime');
|
||||
},
|
||||
getScripts: async (params?: { page?: number; page_size?: number }) => {
|
||||
return http.get<ListResponse>('/scripts', params);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// 内置系统任务 API // Logan.Li
|
||||
|
||||
import { http, type ListResponse } from '@/utils/request';
|
||||
|
||||
export interface SystemTaskItem {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
schedule: string;
|
||||
category: 'monitor' | 'cleanup' | string;
|
||||
runnable: boolean;
|
||||
enabled: boolean;
|
||||
last_run_at?: string | null;
|
||||
last_status?: string;
|
||||
last_message?: string;
|
||||
run_count?: number;
|
||||
last_duration_ms?: number;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const systemTaskApi = {
|
||||
list: async () => http.get<ListResponse<SystemTaskItem>>('/system-tasks'),
|
||||
run: async (code: string) => http.post<SystemTaskItem>(`/system-tasks/${code}/run`),
|
||||
};
|
||||
@@ -49,7 +49,7 @@ export function HostDetailDialog({
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
agentApi
|
||||
.getHistory(host.hostname, hours)
|
||||
.getHistory(host.hostname, { hours })
|
||||
.then((data) => {
|
||||
if (active) setHistory(asList(data));
|
||||
})
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
// Agent 告警常量与辅助函数(从 AgentAlerts.tsx 提取) // Logan.Li
|
||||
// Agent 告警常量与辅助函数 // Logan.Li
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AlertTriangle, Cpu, MemoryStick, HardDrive, WifiOff, Network } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle, Cpu, MemoryStick, HardDrive, WifiOff, Network,
|
||||
Activity, Gauge, Timer,
|
||||
} from 'lucide-react';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
// 监控指标选项(含图标,告警事件/规则/编辑弹窗共用) // Logan.Li
|
||||
// 主机监控指标(与后端 alert_engine 对齐)// Logan.Li
|
||||
export const METRIC_OPTIONS = [
|
||||
{ value: 'cpu', label: 'CPU', icon: Cpu },
|
||||
{ value: 'mem', label: '内存', icon: MemoryStick },
|
||||
{ value: 'disk', label: '磁盘', icon: HardDrive },
|
||||
{ value: 'cpu', label: 'CPU 使用率', icon: Cpu },
|
||||
{ value: 'cpu_iowait', label: 'CPU IO 等待', icon: Cpu },
|
||||
{ value: 'mem', label: '内存使用率', icon: MemoryStick },
|
||||
{ value: 'mem_available', label: '可用内存(GB)', icon: MemoryStick },
|
||||
{ value: 'swap', label: 'Swap 使用率', icon: MemoryStick },
|
||||
{ value: 'load1', label: '1分钟负载', icon: Gauge },
|
||||
{ value: 'disk', label: '磁盘使用率', icon: HardDrive },
|
||||
{ value: 'disk_inode', label: 'Inode 使用率', icon: HardDrive },
|
||||
{ value: 'disk_io_util', label: '磁盘 IO 利用率', icon: HardDrive },
|
||||
{ value: 'tcp_established', label: 'TCP 已建立', icon: Network },
|
||||
{ value: 'tcp_time_wait', label: 'TCP TIME_WAIT', icon: Network },
|
||||
{ value: 'tcp_close_wait', label: 'TCP CLOSE_WAIT', icon: Network },
|
||||
{ value: 'net_rx_bps', label: '入网带宽', icon: Network },
|
||||
{ value: 'net_tx_bps', label: '出网带宽', icon: Network },
|
||||
{ value: 'net_rx_errors', label: '入网错误', icon: Network },
|
||||
{ value: 'fd', label: '文件描述符', icon: Activity },
|
||||
{ value: 'offline', label: '主机离线', icon: WifiOff },
|
||||
{ value: 'port_down', label: '端口不通', icon: Network },
|
||||
{ value: 'health_probe', label: '健康探针', icon: Timer },
|
||||
];
|
||||
|
||||
// 比较运算符选项 // Logan.Li
|
||||
export const OPERATOR_OPTIONS = [
|
||||
{ value: '>', label: '> 大于' },
|
||||
{ value: '<', label: '< 小于' },
|
||||
@@ -21,19 +37,16 @@ export const OPERATOR_OPTIONS = [
|
||||
{ value: '==', label: '= 等于' },
|
||||
];
|
||||
|
||||
// 严重程度选项(含徽章配色) // Logan.Li
|
||||
export const SEVERITY_OPTIONS = [
|
||||
{ value: 'critical', label: '严重', color: 'bg-red-500/15 dg-text-red border-red-500/30' },
|
||||
{ value: 'warning', label: '警告', color: 'bg-amber-500/15 dg-text-yellow border-amber-500/30' },
|
||||
{ value: 'info', label: '提示', color: 'bg-blue-500/15 dg-text-blue border-blue-500/30' },
|
||||
];
|
||||
|
||||
// 获取指标中文标签 // Logan.Li
|
||||
export function getMetricLabel(metric: string) {
|
||||
return METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric;
|
||||
}
|
||||
|
||||
// 兼容多种列表响应格式(数组 / {list} / {items} / {data}) // Logan.Li
|
||||
export function asList(data: JsonData): unknown[] {
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data)) return data;
|
||||
@@ -43,7 +56,6 @@ export function asList(data: JsonData): unknown[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 严重程度徽章(告警事件 + 规则列表共用) // Logan.Li
|
||||
export function severityBadge(sev: string) {
|
||||
const opt = SEVERITY_OPTIONS.find((s) => s.value === sev) || SEVERITY_OPTIONS[1];
|
||||
return (
|
||||
@@ -52,4 +64,4 @@ export function severityBadge(sev: string) {
|
||||
{opt.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,22 @@
|
||||
// 关联主机后,用自然语言描述任务,AI 通过 SSH 工具自主排障/部署,循环重试直到解决。
|
||||
// 通过 WebSocket 实时展示思考、执行的命令、输出与结果;高危命令弹框确认。
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { serverApi, buildAgentCliWsUrl } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Send, Square, Terminal, ChevronDown, ChevronRight,
|
||||
Shield, Brain, Cpu, Database, Loader2, Bot, Lightbulb, Server,
|
||||
Send, Square, Terminal, Shield, Brain, Cpu, Database, Loader2, Bot, Lightbulb, Server,
|
||||
PanelLeftClose, History, Rocket,
|
||||
} from 'lucide-react';
|
||||
import AgentCLIHistory from './AgentCLIHistory';
|
||||
import { MessageView, type ChatItem } from './AgentCLIMessages';
|
||||
import { AgentChatTimeline, type ChatItem } from './AgentCLIMessages';
|
||||
import type { AgentCliSession } from '@/api/aiops/agent-cli';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
|
||||
@@ -39,24 +40,49 @@ interface WsEvent {
|
||||
command?: string;
|
||||
}
|
||||
|
||||
// 四大核心模块说明(页面 tips) // Logan.Li
|
||||
// 四大核心模块说明(灯泡 Popover) // Logan.Li
|
||||
const TIPS = [
|
||||
{ icon: Cpu, title: 'LLM + 工具层(llm / tools)', color: 'text-sky-400',
|
||||
desc: 'OpenAI 兼容 LLM 客户端(复用「大模型设置」中的配置);工具系统注册 ssh_execute(远程执行命令,复用 pkg/remoteexec)与 analyze_output(超长输出分析)。AI 借助工具真正动手操作服务器。' },
|
||||
{ icon: Shield, title: '安全 + Prompt(safety / prompt)', color: 'dg-text-yellow',
|
||||
desc: '五级风险评估 + 三级安全模式:危险命令(rm -rf /、mkfs)硬性拒绝,高危命令(reboot、restart)弹框确认;五层 System Prompt 定义角色、安全规则、工具与 ReAct 工作方式。' },
|
||||
{ icon: Brain, title: 'ReAct 核心引擎(core)', color: 'dg-text-purple',
|
||||
desc: '「思考→行动→观察」循环(最多 16 轮):每轮调用工具、读取输出,失败则分析原因换法重试;自适应检查点 + 循环检测(同参超过 3 次拒绝),直到真正解决问题而非假装解决。' },
|
||||
{ icon: Database, title: '自进化引擎(evolver)', color: 'dg-text-green',
|
||||
desc: '四层记忆(经验 / 环境 / 事实 / 程序):任务完成后异步学习经验,下次同类任务自动注入历史经验,越用越聪明;学习失败优雅降级,绝不影响主流程。' },
|
||||
{ icon: Cpu, title: 'LLM + 工具层', color: 'text-sky-400',
|
||||
desc: 'OpenAI 兼容 LLM;工具含 ssh_execute(远程执行)与 analyze_output(输出分析)。' },
|
||||
{ icon: Shield, title: '安全 + Prompt', color: 'dg-text-yellow',
|
||||
desc: '五级风险评估 + 三级安全模式;危险命令硬性拒绝,高危命令弹框确认。' },
|
||||
{ icon: Brain, title: 'ReAct 核心引擎', color: 'dg-text-purple',
|
||||
desc: '思考→行动→观察循环(最多 16 轮),失败换法重试,循环检测防死循环。' },
|
||||
{ icon: Database, title: '自进化引擎', color: 'dg-text-green',
|
||||
desc: '四层记忆异步学习,同类任务自动注入历史经验,越用越聪明。' },
|
||||
];
|
||||
|
||||
// 安全模式说明(灯泡 Popover 展示) // Logan.Li
|
||||
const SAFETY_MODES = [
|
||||
{ value: 'strict', label: '严格', desc: '中风险及以上需确认' },
|
||||
{ value: 'balanced', label: '均衡', desc: '高风险及以上需确认' },
|
||||
{ value: 'permissive', label: '宽松', desc: '仅危险命令拦截' },
|
||||
{
|
||||
value: 'strict',
|
||||
label: '严格',
|
||||
short: '中风险+确认',
|
||||
desc: 'chmod、文件修改、服务重启等中风险及以上命令均需弹框确认。适合生产环境或不熟悉的主机。',
|
||||
examples: 'chmod、mv、systemctl restart',
|
||||
},
|
||||
{
|
||||
value: 'balanced',
|
||||
label: '均衡',
|
||||
short: '高风险+确认',
|
||||
desc: '日常排障命令(查看日志、ps、df)自动执行;删除文件、重启服务等高风险操作才需确认。推荐日常使用。',
|
||||
examples: 'cat/grep/df 自动 · rm/reboot 确认',
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
value: 'permissive',
|
||||
label: '宽松',
|
||||
short: '仅拦截危险',
|
||||
desc: '除 rm -rf /、mkfs 等危险命令硬性拦截外,其余全部自动执行。仅适合隔离测试环境。',
|
||||
examples: 'rm -rf / 拦截 · 其余自动',
|
||||
},
|
||||
] as const;
|
||||
|
||||
// formatServerLabel 主机下拉展示文案 // Logan.Li
|
||||
function formatServerLabel(s: { hostname: string; ip: string }) {
|
||||
return `${s.hostname}(${s.ip})`;
|
||||
}
|
||||
|
||||
export default function AgentCLI() {
|
||||
const confirm = useConfirm();
|
||||
|
||||
@@ -68,11 +94,14 @@ export default function AgentCLI() {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [round, setRound] = useState(0);
|
||||
const [tokens, setTokens] = useState(0);
|
||||
const [tipsOpen, setTipsOpen] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [replayMode, setReplayMode] = useState(false);
|
||||
const [viewMode, setViewMode] = useState(false);
|
||||
const [viewSession, setViewSession] = useState<AgentCliSession | null>(null);
|
||||
const [canContinue, setCanContinue] = useState(false);
|
||||
const [historyRefresh, setHistoryRefresh] = useState(0);
|
||||
const [historyOpen, setHistoryOpen] = useState(true);
|
||||
const [scrollAtTop, setScrollAtTop] = useState(true);
|
||||
const [scrollAtBottom, setScrollAtBottom] = useState(true);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const idSeq = useRef(0);
|
||||
@@ -83,6 +112,8 @@ export default function AgentCLI() {
|
||||
setItems((prev) => [...prev, { ...it, id: nextId() }]);
|
||||
}, []);
|
||||
|
||||
const currentSafety = SAFETY_MODES.find((m) => m.value === safetyMode) ?? SAFETY_MODES[1];
|
||||
|
||||
// 加载主机列表 // Logan.Li
|
||||
useEffect(() => {
|
||||
serverApi.getServers({ page: 1, page_size: 500 }).then((res) => {
|
||||
@@ -96,7 +127,8 @@ export default function AgentCLI() {
|
||||
setItems([]);
|
||||
setRound(0); setTokens(0); setRunning(false);
|
||||
setConnected(false);
|
||||
setReplayMode(false);
|
||||
setViewMode(false);
|
||||
setViewSession(null);
|
||||
setCanContinue(false);
|
||||
const ws = new WebSocket(buildAgentCliWsUrl(serverId));
|
||||
wsRef.current = ws;
|
||||
@@ -118,7 +150,7 @@ export default function AgentCLI() {
|
||||
setRunning(false);
|
||||
if (!everConnected) {
|
||||
push({ kind: 'error', content: '主机连接被拒绝:常见为该主机未关联可用的 SSH 凭据(密码或密钥),或大模型未启用。请到「服务器管理」确认该主机凭据、到「大模型设置」确认已启用模型后重试。' });
|
||||
} else if (!replayMode) {
|
||||
} else if (!viewMode) {
|
||||
push({ kind: 'info', content: '连接已关闭' });
|
||||
}
|
||||
};
|
||||
@@ -135,10 +167,30 @@ export default function AgentCLI() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serverId]);
|
||||
|
||||
// 自动滚到底部 // Logan.Li
|
||||
const updateScrollState = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const threshold = 48;
|
||||
setScrollAtTop(el.scrollTop <= threshold);
|
||||
setScrollAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight <= threshold);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
const scrollToBottom = () => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// 自动滚到底部(仅当用户已在底部附近时) // Logan.Li
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}, [items]);
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||
if (nearBottom || items.length <= 2) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
updateScrollState();
|
||||
}, [items, updateScrollState]);
|
||||
|
||||
// 处理后端事件 // Logan.Li
|
||||
const handleEvent = (ev: WsEvent) => {
|
||||
@@ -146,6 +198,11 @@ export default function AgentCLI() {
|
||||
switch (type) {
|
||||
case 'session':
|
||||
push({ kind: 'info', content: ev.message || '' });
|
||||
// 仅「恢复历史会话」时进入可追问(新任务开始的 session 事件文案不同) // Logan.Li
|
||||
if (ev.message?.includes('已恢复历史会话')) {
|
||||
setViewMode(false);
|
||||
setCanContinue(true);
|
||||
}
|
||||
break;
|
||||
case 'progress':
|
||||
if (ev.round) setRound(ev.round);
|
||||
@@ -201,12 +258,34 @@ export default function AgentCLI() {
|
||||
case 'confirm':
|
||||
handleConfirm(ev);
|
||||
break;
|
||||
case 'done':
|
||||
push({ kind: 'done', content: ev.answer || '', round: ev.round });
|
||||
case 'done': {
|
||||
const text = (ev.answer || ev.message || '').trim();
|
||||
setItems((prev) => {
|
||||
const norm = (s?: string) => (s || '').trim().replace(/\s+/g, ' ');
|
||||
// 移除与 done 同文的 thought,避免终局总结显示两遍 // Logan.Li
|
||||
let next = prev.filter((it) => {
|
||||
if (it.kind !== 'thought' || !text) return true;
|
||||
return norm(it.content) !== norm(text);
|
||||
});
|
||||
const last = next[next.length - 1];
|
||||
if (last?.kind === 'done') {
|
||||
if (text) {
|
||||
next[next.length - 1] = { ...last, content: text, round: ev.round ?? last.round };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
return [...next, {
|
||||
id: nextId(),
|
||||
kind: 'done',
|
||||
content: text || '任务已完成,请查看上方工具执行结果。',
|
||||
round: ev.round,
|
||||
}];
|
||||
});
|
||||
setRunning(false);
|
||||
setCanContinue(true);
|
||||
setHistoryRefresh((k) => k + 1);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
case 'aborted':
|
||||
push({ kind: type === 'aborted' ? 'info' : 'error', content: ev.message || (type === 'aborted' ? '任务已中断' : '发生错误') });
|
||||
@@ -240,7 +319,8 @@ export default function AgentCLI() {
|
||||
return;
|
||||
}
|
||||
if (running) return;
|
||||
setReplayMode(false);
|
||||
setViewMode(false);
|
||||
setViewSession(null);
|
||||
const msgType = canContinue ? 'continue' : 'start';
|
||||
push({ kind: 'user', content: q });
|
||||
wsRef.current.send(JSON.stringify({ type: msgType, query: q, safety_mode: safetyMode }));
|
||||
@@ -251,67 +331,43 @@ export default function AgentCLI() {
|
||||
|
||||
const stop = () => wsRef.current?.send(JSON.stringify({ type: 'abort' }));
|
||||
|
||||
const handleReplay = (replayItems: ChatItem[], sess: AgentCliSession) => {
|
||||
setReplayMode(true);
|
||||
// handleViewHistory 查看历史记录(只读,不执行命令) // Logan.Li
|
||||
const handleViewHistory = (replayItems: ChatItem[], sess: AgentCliSession) => {
|
||||
setViewMode(true);
|
||||
setViewSession(sess);
|
||||
setCanContinue(false);
|
||||
setItems([
|
||||
{ id: nextId(), kind: 'info', content: `回放历史任务:${sess.title}(${sess.status})` },
|
||||
{
|
||||
id: nextId(), kind: 'info',
|
||||
content: `查看历史记录:${sess.title}(${sess.status === 'done' ? '已完成' : sess.status})— 仅浏览,点击「继续追问」可在此基础上接着执行`,
|
||||
},
|
||||
...replayItems,
|
||||
]);
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
// resumeHistorySession 恢复历史会话上下文,进入可追问状态 // Logan.Li
|
||||
const resumeHistorySession = () => {
|
||||
if (!viewSession) return;
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
toast.error('连接未就绪,请稍后重试');
|
||||
return;
|
||||
}
|
||||
wsRef.current.send(JSON.stringify({ type: 'resume', session_id: viewSession.id }));
|
||||
push({ kind: 'info', content: `正在恢复会话「${viewSession.title}」…` });
|
||||
};
|
||||
|
||||
const handleNewTask = () => {
|
||||
setReplayMode(false);
|
||||
setViewMode(false);
|
||||
setViewSession(null);
|
||||
setCanContinue(false);
|
||||
setItems([]);
|
||||
setRound(0);
|
||||
setTokens(0);
|
||||
};
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Server className="w-4 h-4 dg-text-secondary" />
|
||||
<Select value={serverId} onValueChange={setServerId}>
|
||||
<SelectTrigger className="w-56 dg-input">
|
||||
<SelectValue placeholder="选择目标主机" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input max-h-72">
|
||||
{servers.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.hostname}({s.ip})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Shield className="w-4 h-4 dg-text-secondary" />
|
||||
<Select value={safetyMode} onValueChange={setSafetyMode}>
|
||||
<SelectTrigger className="w-28 dg-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
{SAFETY_MODES.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{connected && (
|
||||
<Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5" />已连接
|
||||
</Badge>
|
||||
)}
|
||||
{replayMode && (
|
||||
<Badge className="bg-amber-500/15 dg-text-yellow border-amber-500/30">回放模式</Badge>
|
||||
)}
|
||||
{canContinue && !running && !replayMode && (
|
||||
<Badge className="bg-violet-500/15 dg-text-purple border-violet-500/30">可追问</Badge>
|
||||
)}
|
||||
{running && (
|
||||
<Badge className="bg-blue-500/15 dg-text-blue border-blue-500/30">
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />第 {round} 轮 · {tokens} tokens
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const selectedServer = servers.find((s) => String(s.id) === serverId);
|
||||
const canResumeView = viewSession && ['done', 'aborted', 'error'].includes(viewSession.status);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
@@ -319,45 +375,159 @@ export default function AgentCLI() {
|
||||
title="Agent CLI"
|
||||
subtitle="自然语言驱动 SSH 自主排障与部署"
|
||||
icon={<Bot className="h-5 w-5" />}
|
||||
toolbar={toolbar}
|
||||
className="h-full"
|
||||
contentClassName="h-full flex gap-3"
|
||||
contentClassName="h-full flex flex-col min-h-0 gap-2"
|
||||
>
|
||||
<AgentCLIHistory
|
||||
serverId={serverId}
|
||||
onReplay={handleReplay}
|
||||
onNewTask={handleNewTask}
|
||||
refreshKey={historyRefresh}
|
||||
/>
|
||||
{/* 工作区控制栏:主机 + 安全模式 + 状态,不挤占对话区宽度 // Logan.Li */}
|
||||
<div className="flex items-center gap-2 flex-wrap shrink-0 px-0.5">
|
||||
<Select value={serverId} onValueChange={setServerId}>
|
||||
<SelectTrigger className="w-80 max-w-[22rem] h-8 text-xs dg-input min-w-0">
|
||||
<Server className="w-3.5 h-3.5 mr-1.5 dg-text-secondary shrink-0" />
|
||||
<SelectValue placeholder="选择目标主机" className="truncate">
|
||||
{selectedServer ? formatServerLabel(selectedServer) : undefined}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input max-h-72 min-w-[var(--radix-select-trigger-width)]">
|
||||
{servers.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)} title={formatServerLabel(s)}>
|
||||
<span className="truncate block max-w-[20rem]">{formatServerLabel(s)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-3">
|
||||
{/* tips 面板 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="py-2 px-3 cursor-pointer select-none" onClick={() => setTipsOpen((v) => !v)}>
|
||||
<div className="flex items-center gap-2 text-[var(--dg-text-secondary)] text-sm">
|
||||
{tipsOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
<Lightbulb className="w-4 h-4 dg-text-yellow" />
|
||||
<span className="font-medium">核心能力与设计思路</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{tipsOpen && (
|
||||
<CardContent className="pt-2 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{TIPS.map((t) => (
|
||||
<div key={t.title} className="flex gap-2.5 p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)]/40 border border-[var(--dg-border)]">
|
||||
<t.icon className={`w-5 h-5 mt-0.5 shrink-0 ${t.color}`} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{t.title}</div>
|
||||
<div className="text-xs dg-text-secondary leading-relaxed mt-0.5">{t.desc}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Select value={safetyMode} onValueChange={setSafetyMode}>
|
||||
<SelectTrigger className="w-24 h-8 text-xs dg-input">
|
||||
<Shield className="w-3.5 h-3.5 mr-1 dg-text-secondary shrink-0" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
{SAFETY_MODES.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-yellow hover:bg-[var(--dg-bg-hover)]" title="安全模式说明">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 dg-input p-3 space-y-2.5">
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">安全模式说明</div>
|
||||
<p className="text-[11px] dg-text-muted leading-relaxed">
|
||||
控制 AI 执行远程命令前是否需要你确认。危险命令(如 rm -rf /)任何模式均硬性拦截。
|
||||
</p>
|
||||
{SAFETY_MODES.map((m) => (
|
||||
<div
|
||||
key={m.value}
|
||||
className={`rounded-md p-2.5 border text-xs ${
|
||||
m.value === safetyMode
|
||||
? 'border-blue-500/40 bg-blue-500/10'
|
||||
: 'border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{m.label}</span>
|
||||
<span className="text-[10px] dg-text-muted">{m.short}</span>
|
||||
{'recommended' in m && m.recommended && (
|
||||
<Badge className="text-[9px] px-1 py-0 bg-violet-500/15 dg-text-purple border-violet-500/30">推荐</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="dg-text-secondary leading-relaxed mt-1">{m.desc}</p>
|
||||
<p className="text-[10px] dg-text-muted mt-1">例:{m.examples}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 对话流 */}
|
||||
<Card className="flex-1 min-h-0 dg-card flex flex-col">
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs dg-text-secondary"
|
||||
onClick={() => setHistoryOpen((v) => !v)}
|
||||
title={historyOpen ? '收起历史任务' : '展开历史任务'}
|
||||
>
|
||||
{historyOpen ? <PanelLeftClose className="w-3.5 h-3.5 mr-1" /> : <History className="w-3.5 h-3.5 mr-1" />}
|
||||
历史
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 min-w-[1rem]" />
|
||||
|
||||
{connected && (
|
||||
<Badge className="h-6 text-[10px] bg-emerald-500/15 dg-text-green border-emerald-500/30">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1" />已连接
|
||||
</Badge>
|
||||
)}
|
||||
{viewMode && (
|
||||
<Badge className="h-6 text-[10px] bg-amber-500/15 dg-text-yellow border-amber-500/30">查看记录</Badge>
|
||||
)}
|
||||
{canContinue && !running && !viewMode && (
|
||||
<Badge className="h-6 text-[10px] bg-violet-500/15 dg-text-purple border-violet-500/30">可追问</Badge>
|
||||
)}
|
||||
{running && (
|
||||
<Badge className="h-6 text-[10px] bg-blue-500/15 dg-text-blue border-blue-500/30">
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />第 {round} 轮 · {tokens}t
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-muted hover:dg-text-secondary" title="核心能力说明">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 dg-input p-3 space-y-2">
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">核心能力</div>
|
||||
{TIPS.map((t) => (
|
||||
<div key={t.title} className="flex gap-2 p-2 rounded-md bg-[var(--dg-bg-tertiary)]/40 border border-[var(--dg-border)]">
|
||||
<t.icon className={`w-4 h-4 mt-0.5 shrink-0 ${t.color}`} />
|
||||
<div>
|
||||
<div className="text-xs font-medium text-[var(--dg-text-primary)]">{t.title}</div>
|
||||
<div className="text-[10px] dg-text-secondary leading-relaxed mt-0.5">{t.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 主工作区:可折叠历史 + 对话区全宽 // Logan.Li */}
|
||||
<div className="flex flex-1 min-h-0 gap-2">
|
||||
{historyOpen && (
|
||||
<AgentCLIHistory
|
||||
serverId={serverId}
|
||||
onView={handleViewHistory}
|
||||
onNewTask={handleNewTask}
|
||||
refreshKey={historyRefresh}
|
||||
onCollapse={() => setHistoryOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card className="flex-1 min-w-0 min-h-0 dg-card flex flex-col">
|
||||
{viewMode && viewSession && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)] bg-amber-500/5 shrink-0 flex-wrap">
|
||||
<span className="text-xs dg-text-secondary flex-1 min-w-[12rem]">
|
||||
历史记录(只读)· {viewSession.title}
|
||||
</span>
|
||||
{canResumeView && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={resumeHistorySession}>
|
||||
继续追问
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={handleNewTask}>
|
||||
新建任务
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={updateScrollState}
|
||||
className="flex-1 min-h-0 overflow-y-auto p-4 space-y-3"
|
||||
>
|
||||
{items.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center dg-text-muted gap-3">
|
||||
<Terminal className="w-10 h-10 opacity-40" />
|
||||
@@ -374,37 +544,72 @@ export default function AgentCLI() {
|
||||
<div className="text-sm">正在连接主机...</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm">描述你要执行的任务,例如「检查磁盘使用率并清理大文件」</div>
|
||||
<div className="text-sm text-center max-w-md">
|
||||
描述你要执行的任务,例如「检查磁盘使用率并清理大文件」
|
||||
<div className="text-xs dg-text-muted mt-2">
|
||||
当前安全模式:{currentSafety.label}({currentSafety.short})
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{items.map((it) => (
|
||||
<MessageView key={it.id} item={it} />
|
||||
))}
|
||||
<AgentChatTimeline items={items} activeRound={round} />
|
||||
</div>
|
||||
<div className="border-t border-[var(--dg-border)] p-3 flex items-end gap-2">
|
||||
{items.length > 0 && (
|
||||
<div className="absolute right-3 bottom-3 z-10 flex flex-col gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full shadow-md border border-[var(--dg-border)] bg-[var(--surface-raised)]/95 opacity-90 hover:opacity-100"
|
||||
onClick={scrollToTop}
|
||||
disabled={scrollAtTop}
|
||||
title="去顶部"
|
||||
>
|
||||
<Rocket className="h-3.5 w-3.5 -rotate-45" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full shadow-md border border-[var(--dg-border)] bg-[var(--surface-raised)]/95 opacity-90 hover:opacity-100"
|
||||
onClick={scrollToBottom}
|
||||
disabled={scrollAtBottom}
|
||||
title="去底部"
|
||||
>
|
||||
<Rocket className="h-3.5 w-3.5 rotate-[135deg]" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 加宽输入区:多行 + 独立操作栏 // Logan.Li */}
|
||||
<div className="border-t border-[var(--dg-border)] p-3 space-y-2 shrink-0">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
|
||||
placeholder={
|
||||
replayMode ? '回放模式,点击「新建」开始新任务'
|
||||
viewMode ? '查看模式中,点击「继续追问」或「新建任务」'
|
||||
: canContinue ? '继续追问,回车发送(Shift+回车换行)'
|
||||
: serverId ? '输入任务描述,回车发送(Shift+回车换行)' : '请先选择目标主机'
|
||||
}
|
||||
rows={1}
|
||||
disabled={replayMode}
|
||||
className="flex-1 resize-none dg-input max-h-32 disabled:opacity-50"
|
||||
rows={3}
|
||||
disabled={viewMode}
|
||||
className="w-full resize-none dg-input min-h-[5rem] max-h-40 text-sm leading-relaxed disabled:opacity-50"
|
||||
/>
|
||||
{running ? (
|
||||
<Button variant="destructive" onClick={stop} className="bg-red-600 hover:bg-red-700">
|
||||
<Square className="w-4 h-4 mr-1" /> 停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={send} disabled={!input.trim() || replayMode} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="w-4 h-4 mr-1" /> 执行
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] dg-text-muted">Shift+回车换行 · 回车发送</span>
|
||||
{running ? (
|
||||
<Button variant="destructive" size="sm" onClick={stop} className="bg-red-600 hover:bg-red-700">
|
||||
<Square className="w-3.5 h-3.5 mr-1" /> 停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={send} disabled={!input.trim() || viewMode} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="w-3.5 h-3.5 mr-1" /> 执行
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AgentCLIHistory.tsx - Agent CLI 会话历史侧栏(列表/回放/删除) // Logan.Li
|
||||
// AgentCLIHistory.tsx - Agent CLI 会话历史侧栏(列表/回放/删除/折叠) // Logan.Li
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -7,13 +7,14 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { agentCliApi, type AgentCliSession } from '@/api/aiops/agent-cli';
|
||||
import { messagesToChatItems, type ChatItem } from './AgentCLIMessages';
|
||||
import { toast } from 'sonner';
|
||||
import { History, Trash2, Plus, Loader2 } from 'lucide-react';
|
||||
import { History, Trash2, Plus, Loader2, PanelLeftClose } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
onReplay: (items: ChatItem[], session: AgentCliSession) => void;
|
||||
onView: (items: ChatItem[], session: AgentCliSession) => void;
|
||||
onNewTask: () => void;
|
||||
refreshKey?: number;
|
||||
onCollapse?: () => void;
|
||||
}
|
||||
|
||||
// statusBadge 会话状态标签样式 // Logan.Li
|
||||
@@ -26,7 +27,7 @@ function statusBadge(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentCLIHistory({ serverId, onReplay, onNewTask, refreshKey }: Props) {
|
||||
export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKey, onCollapse }: Props) {
|
||||
const confirm = useConfirm();
|
||||
const [sessions, setSessions] = useState<AgentCliSession[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -54,12 +55,12 @@ export default function AgentCLIHistory({ serverId, onReplay, onNewTask, refresh
|
||||
|
||||
useEffect(() => { loadSessions(); }, [loadSessions, refreshKey]);
|
||||
|
||||
const handleReplay = async (sess: AgentCliSession) => {
|
||||
const handleView = async (sess: AgentCliSession) => {
|
||||
try {
|
||||
const detail = await agentCliApi.getSession(sess.id);
|
||||
const items = messagesToChatItems(detail?.messages || []);
|
||||
setActiveId(sess.id);
|
||||
onReplay(items, sess);
|
||||
onView(items, sess);
|
||||
} catch {
|
||||
toast.error('加载会话详情失败');
|
||||
}
|
||||
@@ -89,40 +90,46 @@ export default function AgentCLIHistory({ serverId, onReplay, onNewTask, refresh
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-72 shrink-0 flex flex-col bg-[var(--dg-bg-secondary)]/60 border border-[var(--dg-border)] rounded-lg overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-[var(--dg-border)]">
|
||||
<History className="w-4 h-4 dg-text-secondary" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)] flex-1">历史任务</span>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => { setActiveId(null); onNewTask(); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />新建
|
||||
<div className="w-56 shrink-0 flex flex-col bg-[var(--dg-bg-secondary)]/60 border border-[var(--dg-border)] rounded-lg overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2 border-b border-[var(--dg-border)]">
|
||||
<History className="w-3.5 h-3.5 dg-text-secondary shrink-0" />
|
||||
<span className="text-xs font-medium text-[var(--dg-text-primary)] flex-1 truncate">历史</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => { setActiveId(null); onNewTask(); }}>
|
||||
<Plus className="w-3 h-3 mr-0.5" />新建
|
||||
</Button>
|
||||
{onCollapse && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 dg-text-muted hover:dg-text-secondary" onClick={onCollapse} title="收起历史">
|
||||
<PanelLeftClose className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{!serverId ? (
|
||||
<div className="p-4 text-xs dg-text-muted text-center">选择主机后查看历史</div>
|
||||
<div className="p-3 text-[10px] dg-text-muted text-center">选择主机后查看</div>
|
||||
) : loading ? (
|
||||
<div className="p-4 flex justify-center"><Loader2 className="w-5 h-5 animate-spin dg-text-muted" /></div>
|
||||
<div className="p-3 flex justify-center"><Loader2 className="w-4 h-4 animate-spin dg-text-muted" /></div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="p-4 text-xs dg-text-muted text-center">暂无历史任务</div>
|
||||
<div className="p-3 text-[10px] dg-text-muted text-center">暂无历史</div>
|
||||
) : (
|
||||
<div className="p-1.5 space-y-1">
|
||||
<div className="p-1 space-y-0.5">
|
||||
{sessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => handleReplay(s)}
|
||||
className={`w-full text-left rounded-md px-2.5 py-2 transition-colors group ${
|
||||
onClick={() => handleView(s)}
|
||||
title="查看历史记录"
|
||||
className={`w-full text-left rounded-md px-2 py-1.5 transition-colors group ${
|
||||
activeId === s.id ? 'bg-blue-500/15 border border-blue-500/30' : 'hover:bg-[var(--dg-bg-hover)]/60 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<div className="flex items-start gap-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-[var(--dg-text-primary)] truncate">{s.title}</div>
|
||||
<div className="text-[11px] text-[var(--dg-text-primary)] truncate leading-tight">{s.title}</div>
|
||||
<div className="text-[10px] dg-text-muted mt-0.5">
|
||||
{s.rounds} 轮 · {s.total_tokens || 0} tokens
|
||||
{s.rounds}轮 · {s.total_tokens || 0}t
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={`text-[10px] border shrink-0 ${statusBadge(s.status)}`}>
|
||||
<Badge className={`text-[9px] px-1 py-0 border shrink-0 ${statusBadge(s.status)}`}>
|
||||
{s.status === 'done' ? '完成' : s.status === 'running' ? '进行' : s.status === 'aborted' ? '中断' : '失败'}
|
||||
</Badge>
|
||||
<button
|
||||
@@ -130,7 +137,7 @@ export default function AgentCLIHistory({ serverId, onReplay, onNewTask, refresh
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 dg-text-muted hover:dg-text-red shrink-0"
|
||||
onClick={(e) => handleDelete(e, s)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// AgentCLIMessages.tsx - Agent CLI 对话流消息渲染子组件 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Terminal, ChevronDown, ChevronRight, Brain,
|
||||
AlertTriangle, CheckCircle2, Loader2,
|
||||
AlertTriangle, CheckCircle2, Loader2, Layers,
|
||||
} from 'lucide-react';
|
||||
import type { AgentCliMessage } from '@/api/aiops/agent-cli';
|
||||
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown';
|
||||
|
||||
// 对话流条目模型 // Logan.Li
|
||||
export interface ChatItem {
|
||||
@@ -89,8 +90,126 @@ export function messagesToChatItems(msgs: AgentCliMessage[]): ChatItem[] {
|
||||
return items;
|
||||
}
|
||||
|
||||
// normalizeChatText 归一化文本用于去重比较 // Logan.Li
|
||||
function normalizeChatText(s?: string): string {
|
||||
return (s || '').trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
// dedupeDoneThought 移除与 done 同文的 thought,终局总结只保留 done(Markdown) // Logan.Li
|
||||
export function dedupeDoneThought(items: ChatItem[]): ChatItem[] {
|
||||
const doneTexts = new Set(
|
||||
items.filter((it) => it.kind === 'done').map((it) => normalizeChatText(it.content)),
|
||||
);
|
||||
if (doneTexts.size === 0) return items;
|
||||
return items.filter((it) => {
|
||||
if (it.kind !== 'thought') return true;
|
||||
return !doneTexts.has(normalizeChatText(it.content));
|
||||
});
|
||||
}
|
||||
|
||||
// calcLatestRound 取当前最新 ReAct 轮次 // Logan.Li
|
||||
export function calcLatestRound(items: ChatItem[], runningRound = 0): number {
|
||||
if (runningRound > 0) return runningRound;
|
||||
return items.reduce((max, it) => Math.max(max, it.round ?? 0), 0);
|
||||
}
|
||||
|
||||
type TimelineSegment =
|
||||
| { type: 'single'; item: ChatItem }
|
||||
| { type: 'round'; round: number; items: ChatItem[] };
|
||||
|
||||
// groupChatItems 按轮次聚合 thought/tool,便于整轮折叠 // Logan.Li
|
||||
function groupChatItems(items: ChatItem[]): TimelineSegment[] {
|
||||
const segments: TimelineSegment[] = [];
|
||||
let i = 0;
|
||||
while (i < items.length) {
|
||||
const it = items[i];
|
||||
if (it.round != null && (it.kind === 'thought' || it.kind === 'tool')) {
|
||||
const r = it.round;
|
||||
const group: ChatItem[] = [];
|
||||
while (i < items.length) {
|
||||
const cur = items[i];
|
||||
if (cur.round !== r || (cur.kind !== 'thought' && cur.kind !== 'tool')) break;
|
||||
group.push(cur);
|
||||
i++;
|
||||
}
|
||||
segments.push({ type: 'round', round: r, items: group });
|
||||
} else {
|
||||
segments.push({ type: 'single', item: it });
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
// AgentChatTimeline 对话时间线(旧轮自动折叠) // Logan.Li
|
||||
export function AgentChatTimeline({ items, activeRound = 0 }: { items: ChatItem[]; activeRound?: number }) {
|
||||
const displayItems = useMemo(() => dedupeDoneThought(items), [items]);
|
||||
const latestRound = useMemo(() => calcLatestRound(displayItems, activeRound), [displayItems, activeRound]);
|
||||
const segments = useMemo(
|
||||
() => groupChatItems(displayItems).filter((seg) => seg.type !== 'round' || seg.items.length > 0),
|
||||
[displayItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, idx) => {
|
||||
if (seg.type === 'single') {
|
||||
return <MessageView key={seg.item.id} item={seg.item} />;
|
||||
}
|
||||
return (
|
||||
<RoundGroup
|
||||
key={`round-${seg.round}-${idx}`}
|
||||
round={seg.round}
|
||||
items={seg.items}
|
||||
isLatest={seg.round === latestRound}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// RoundGroup 单轮 ReAct 块(非最新轮默认折叠) // Logan.Li
|
||||
function RoundGroup({ round, items, isLatest }: { round: number; items: ChatItem[]; isLatest: boolean }) {
|
||||
const hasPending = items.some((it) => it.pending || it.streaming);
|
||||
const [open, setOpen] = useState(isLatest || hasPending);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLatest && !hasPending) setOpen(false);
|
||||
else if (isLatest) setOpen(true);
|
||||
}, [isLatest, hasPending]);
|
||||
|
||||
const toolCount = items.filter((it) => it.kind === 'tool').length;
|
||||
const preview = items.find((it) => it.kind === 'thought')?.content?.slice(0, 60) || '';
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--dg-border)]/80 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 w-full px-2.5 py-2 text-xs text-left bg-[var(--dg-bg-tertiary)]/50 hover:bg-[var(--dg-bg-hover)]/40 transition-colors"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{open ? <ChevronDown className="w-3.5 h-3.5 dg-text-secondary shrink-0" /> : <ChevronRight className="w-3.5 h-3.5 dg-text-secondary shrink-0" />}
|
||||
<Layers className="w-3.5 h-3.5 dg-text-purple shrink-0" />
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">第 {round} 轮</span>
|
||||
<span className="dg-text-muted">{toolCount > 0 ? `· ${toolCount} 个工具` : ''}</span>
|
||||
{!open && preview && (
|
||||
<span className="ml-1 dg-text-muted truncate flex-1">{preview}{preview.length >= 60 ? '…' : ''}</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="p-2 space-y-2 bg-[var(--dg-bg-secondary)]/20">
|
||||
{items.map((it) => (
|
||||
<MessageView key={it.id} item={it} inRoundGroup />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MessageView 单条消息渲染(按 kind 分流) // Logan.Li
|
||||
export function MessageView({ item }: { item: ChatItem }) {
|
||||
export function MessageView({ item, inRoundGroup = false }: { item: ChatItem; inRoundGroup?: boolean }) {
|
||||
if (item.kind === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
@@ -102,37 +221,54 @@ export function MessageView({ item }: { item: ChatItem }) {
|
||||
return <div className="text-center text-xs dg-text-muted py-1">{item.content}</div>;
|
||||
}
|
||||
if (item.kind === 'thought') {
|
||||
return <ThoughtBlock text={item.content || ''} round={item.round} streaming={item.streaming} />;
|
||||
return (
|
||||
<ThoughtBlock
|
||||
text={item.content || ''}
|
||||
round={item.round}
|
||||
streaming={item.streaming}
|
||||
hideRoundLabel={inRoundGroup}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'tool') {
|
||||
return <ToolBlock item={item} />;
|
||||
return <ToolBlock item={item} inRoundGroup={inRoundGroup} />;
|
||||
}
|
||||
if (item.kind === 'done') {
|
||||
const text = item.content?.trim() || '任务已完成,请查看上方工具执行结果。';
|
||||
return (
|
||||
<div className="flex gap-2 rounded-lg bg-emerald-500/10 border border-emerald-500/30 p-3">
|
||||
<CheckCircle2 className="w-5 h-5 dg-text-green shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-emerald-100 whitespace-pre-wrap leading-relaxed">{item.content}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{looksLikeMarkdown(text) ? (
|
||||
<AgentMarkdown content={text} />
|
||||
) : (
|
||||
<div className="text-sm text-[var(--dg-text-primary)] whitespace-pre-wrap leading-relaxed">{text}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-2 rounded-lg bg-red-500/10 border border-red-500/30 p-3">
|
||||
<AlertTriangle className="w-5 h-5 dg-text-red shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-red-100 whitespace-pre-wrap">{item.content}</div>
|
||||
<div className="text-sm text-[var(--dg-text-primary)] whitespace-pre-wrap">{item.content || '发生错误'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ThoughtBlock AI 思考(可折叠,灰底斜体,支持流式) // Logan.Li
|
||||
function ThoughtBlock({ text, round, streaming }: { text: string; round?: number; streaming?: boolean }) {
|
||||
const [open, setOpen] = useState(streaming || false);
|
||||
function ThoughtBlock({
|
||||
text, round, streaming, hideRoundLabel,
|
||||
}: { text: string; round?: number; streaming?: boolean; hideRoundLabel?: boolean }) {
|
||||
const [open, setOpen] = useState(hideRoundLabel ? true : !!streaming);
|
||||
const preview = text.length > 80 ? text.slice(0, 80) + '...' : text;
|
||||
return (
|
||||
<div className="rounded-lg bg-[var(--dg-bg-tertiary)]/40 border border-[var(--dg-border)] p-2.5">
|
||||
<button className="flex items-center gap-1.5 text-xs dg-text-secondary w-full text-left" onClick={() => setOpen((v) => !v)}>
|
||||
<button type="button" className="flex items-center gap-1.5 text-xs dg-text-secondary w-full text-left" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
<Brain className="w-3.5 h-3.5 dg-text-purple" />
|
||||
{round != null && <span className="dg-text-muted">第 {round} 轮思考</span>}
|
||||
{!hideRoundLabel && round != null && <span className="dg-text-muted">第 {round} 轮思考</span>}
|
||||
{hideRoundLabel && <span className="dg-text-muted">思考</span>}
|
||||
{streaming && <Loader2 className="w-3 h-3 dg-text-purple animate-spin" />}
|
||||
<span className="ml-1 dg-text-muted truncate">{!open && preview}</span>
|
||||
</button>
|
||||
@@ -142,16 +278,16 @@ function ThoughtBlock({ text, round, streaming }: { text: string; round?: number
|
||||
}
|
||||
|
||||
// ToolBlock 工具调用块(命令 + 风险 + 可展开输出) // Logan.Li
|
||||
function ToolBlock({ item }: { item: ChatItem }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
function ToolBlock({ item, inRoundGroup }: { item: ChatItem; inRoundGroup?: boolean }) {
|
||||
const [open, setOpen] = useState(!item.pending);
|
||||
return (
|
||||
<div className="rounded-lg bg-[var(--dg-bg-tertiary)]/40 border border-[var(--dg-border)] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)]/60">
|
||||
<button className="flex items-center gap-1.5 flex-1 min-w-0 text-left" onClick={() => setOpen((v) => !v)}>
|
||||
<button type="button" className="flex items-center gap-1.5 flex-1 min-w-0 text-left" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? <ChevronDown className="w-3.5 h-3.5 dg-text-secondary" /> : <ChevronRight className="w-3.5 h-3.5 dg-text-secondary" />}
|
||||
<Terminal className="w-3.5 h-3.5 text-sky-400 shrink-0" />
|
||||
<span className="text-xs dg-text-secondary shrink-0">{item.toolName}</span>
|
||||
{item.round != null && <span className="text-xs dg-text-muted">· R{item.round}</span>}
|
||||
{!inRoundGroup && item.round != null && <span className="text-xs dg-text-muted">· R{item.round}</span>}
|
||||
</button>
|
||||
{item.risk && <Badge className={`text-[10px] border ${riskClass(item.risk)}`}>{item.risk}</Badge>}
|
||||
{item.pending && <Loader2 className="w-3.5 h-3.5 dg-text-blue animate-spin" />}
|
||||
@@ -169,7 +305,13 @@ function ToolBlock({ item }: { item: ChatItem }) {
|
||||
{!item.pending && item.output != null && (
|
||||
<>
|
||||
{item.duration && <div className="text-[10px] dg-text-muted">耗时 {item.duration}</div>}
|
||||
<pre className="text-xs text-[var(--dg-text-secondary)] bg-[var(--dg-bg-primary)]/40 rounded px-2 py-1.5 overflow-x-auto max-h-80 whitespace-pre-wrap">{item.output || '(无输出)'}</pre>
|
||||
{(item.toolName === 'analyze_output' || looksLikeMarkdown(item.output)) ? (
|
||||
<div className="bg-[var(--dg-bg-primary)]/40 rounded px-2 py-1.5 max-h-80 overflow-y-auto">
|
||||
<AgentMarkdown content={item.output || '(无输出)'} />
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-xs text-[var(--dg-text-secondary)] bg-[var(--dg-bg-primary)]/40 rounded px-2 py-1.5 overflow-x-auto max-h-80 whitespace-pre-wrap">{item.output || '(无输出)'}</pre>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// AgentMarkdown.tsx - Agent CLI 富文本 Markdown 渲染(总结/分析报告) // Logan.Li
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// looksLikeMarkdown 简单检测是否含 Markdown 结构 // Logan.Li
|
||||
export function looksLikeMarkdown(text: string): boolean {
|
||||
const t = text.trim();
|
||||
if (!t) return false;
|
||||
return (
|
||||
/^#{1,3}\s/m.test(t)
|
||||
|| /^\|.+\|/m.test(t)
|
||||
|| /^>\s/m.test(t)
|
||||
|| /^[-*]\s/m.test(t)
|
||||
|| /\*\*[^*]+\*\*/.test(t)
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentMarkdown({ content, className }: Props) {
|
||||
return (
|
||||
<div className={cn('agent-md min-w-0', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-base font-bold text-[var(--dg-text-primary)] mt-3 mb-2 first:mt-0">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-sm font-semibold text-[var(--dg-text-primary)] mt-3 mb-1.5 first:mt-0">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] mt-2 mb-1 first:mt-0">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] leading-relaxed my-1.5">{children}</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="text-sm text-[var(--dg-text-secondary)] list-disc pl-5 my-1.5 space-y-0.5">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="text-sm text-[var(--dg-text-secondary)] list-decimal pl-5 my-1.5 space-y-0.5">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-[var(--dg-text-primary)]">{children}</strong>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-[var(--dg-blue)]/50 pl-3 my-2 text-sm dg-text-muted italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="my-2 overflow-x-auto rounded-md border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs border-collapse">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-[var(--dg-bg-tertiary)]/80">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-2.5 py-1.5 text-left font-medium text-[var(--dg-text-primary)] border-b border-[var(--dg-border)] whitespace-nowrap">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-2.5 py-1.5 text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]/60">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
tr: ({ children }) => <tr className="hover:bg-[var(--dg-bg-hover)]/30">{children}</tr>,
|
||||
code: ({ className: codeClass, children }) => {
|
||||
const isBlock = codeClass?.includes('language-');
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="text-xs bg-[var(--dg-bg-primary)]/60 rounded px-2 py-1.5 overflow-x-auto my-2">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="text-xs px-1 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
hr: () => <hr className="my-3 border-[var(--dg-border)]" />,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +1,162 @@
|
||||
// LLMConfig.tsx - 大模型设置独立页(AIOps 智能菜单下统一 LLM 配置入口) // Logan.Li
|
||||
// 调用既有 /aiops/llm-config 接口(GET/PUT/POST test),Agent CLI 与智能助手共用同一配置。
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Loader2, Save, Wifi, KeyRound, Brain, Sparkles, MessageSquare, AlertCircle,
|
||||
ExternalLink, CheckCircle2, ShoppingBag,
|
||||
} from 'lucide-react';
|
||||
import { aiApi, type LLMConfigData, type LLMTestResult } from '@/api';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
|
||||
// 单个提供商配置:默认 endpoint 与候选模型列表(第一个为推荐/最新模型) // Logan.Li
|
||||
// 单个提供商配置:默认 endpoint、候选模型、套餐/充值引导链接 // Logan.Li
|
||||
interface ProviderMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
endpoint: string;
|
||||
models: string[];
|
||||
apiKeyOptional?: boolean; // 本地/自建模型 API Key 可留空 // Logan.Li
|
||||
planUrl?: string;
|
||||
planLabel?: string;
|
||||
planHint?: string;
|
||||
}
|
||||
|
||||
// 提供商列表(后端按 provider 自动路由协议) // Logan.Li
|
||||
// Anthropic 协议(glm-coding / anthropic):端点不含 /v1,后端拼 /v1/messages。
|
||||
// OpenAI 协议(其余):端点含 /v1,后端拼 /chat/completions。
|
||||
const PROVIDERS: ProviderMeta[] = [
|
||||
{
|
||||
id: 'openai', name: 'OpenAI', endpoint: 'https://api.openai.com/v1',
|
||||
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'o3-mini', 'o1-mini'],
|
||||
},
|
||||
// 连接凭证快照(仅含影响连通的字段,切换后需重新验证) // Logan.Li
|
||||
const connectionSnapshot = (c: LLMConfigData) =>
|
||||
`${c.provider}|${c.model}|${c.endpoint}|${c.api_key}`;
|
||||
|
||||
// 云服务(按量 / 通用 API) // Logan.Li
|
||||
const CLOUD_GENERAL_PROVIDERS: ProviderMeta[] = [
|
||||
{
|
||||
id: 'deepseek', name: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-reasoner', 'deepseek-chat'],
|
||||
planUrl: 'https://platform.deepseek.com',
|
||||
planLabel: '获取 API Key',
|
||||
planHint: '在 DeepSeek 开放平台注册并充值后创建 API Key',
|
||||
},
|
||||
{
|
||||
id: 'glm', name: '智谱 GLM (通用)', endpoint: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
models: ['glm-4.5', 'glm-4.6', 'glm-4-flash', 'glm-4-air', 'glm-4-plus'],
|
||||
planUrl: 'https://open.bigmodel.cn/',
|
||||
planLabel: '智谱开放平台',
|
||||
planHint: '注册智谱账号并开通模型服务后获取 API Key',
|
||||
},
|
||||
{
|
||||
id: 'glm-coding', name: '智谱 GLM (编码套餐)', endpoint: 'https://open.bigmodel.cn/api/anthropic',
|
||||
models: ['GLM-5.2', 'GLM-5.1'],
|
||||
},
|
||||
{
|
||||
id: 'qwen', name: '通义千问 (DashScope)', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
models: ['qwen-max', 'qwen-plus', 'qwen-turbo', 'qwen-coder-plus'],
|
||||
},
|
||||
{
|
||||
id: 'moonshot', name: 'Moonshot (Kimi)', endpoint: 'https://api.moonshot.cn/v1',
|
||||
id: 'moonshot', name: 'Kimi (Moonshot)', endpoint: 'https://api.moonshot.cn/v1',
|
||||
models: ['kimi-latest', 'moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'],
|
||||
planUrl: 'https://www.kimi.com/membership/pricing',
|
||||
planLabel: '购买 Kimi 套餐',
|
||||
planHint: 'Kimi 通用 API 需在会员页开通并充值',
|
||||
},
|
||||
{
|
||||
id: 'kimi', name: 'Kimi 编码套餐', endpoint: 'https://api.kimi.com/coding/v1',
|
||||
models: ['kimi-for-coding', 'kimi-coding-latest'],
|
||||
id: 'minimax', name: 'MiniMax (按量)', endpoint: 'https://api.minimaxi.com/v1',
|
||||
models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'],
|
||||
planUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',
|
||||
planLabel: '获取按量 API Key',
|
||||
planHint: '按 Token 计费,在开放平台创建接口密钥(与编码套餐订阅密钥不同)',
|
||||
},
|
||||
{
|
||||
id: 'mimo', name: '小米 MiMo', endpoint: 'https://api.mimo.mi.com/v1',
|
||||
models: ['mimo-v2', 'mimo-v2-flash'],
|
||||
planUrl: 'https://mimo.mi.com/',
|
||||
planLabel: 'MiMo 开放平台',
|
||||
},
|
||||
{
|
||||
id: 'siliconflow', name: '硅基流动', endpoint: 'https://api.siliconflow.cn/v1',
|
||||
models: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct', 'Pro/deepseek-ai/DeepSeek-R1'],
|
||||
planUrl: 'https://cloud.siliconflow.cn/',
|
||||
planLabel: '硅基流动控制台',
|
||||
planHint: '国内模型聚合平台,一个 Key 可调用多家开源模型',
|
||||
},
|
||||
{
|
||||
id: 'openai', name: 'OpenAI', endpoint: 'https://api.openai.com/v1',
|
||||
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o3-mini', 'o4-mini', 'o1-mini'],
|
||||
planUrl: 'https://platform.openai.com/api-keys',
|
||||
planLabel: '创建 API Key',
|
||||
planHint: '在 platform.openai.com 注册并充值后创建 API Key;国内需自备网络',
|
||||
},
|
||||
{
|
||||
id: 'anthropic', name: 'Anthropic Claude', endpoint: 'https://api.anthropic.com',
|
||||
models: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-haiku-4-5'],
|
||||
planUrl: 'https://console.anthropic.com/settings/billing',
|
||||
planLabel: 'Anthropic 账单',
|
||||
planHint: '国内直连可能受限,需自备网络或使用「大模型自定义」接入兼容代理',
|
||||
},
|
||||
];
|
||||
|
||||
// 编码套餐 Coding Plan(固定月费,适合 Agent CLI 高频调用) // Logan.Li
|
||||
const CLOUD_CODING_PROVIDERS: ProviderMeta[] = [
|
||||
{
|
||||
id: 'glm-coding', name: '智谱 GLM 编码套餐', endpoint: 'https://open.bigmodel.cn/api/anthropic',
|
||||
models: ['GLM-5.2', 'GLM-5.1'],
|
||||
planUrl: 'https://bigmodel.cn/glm-coding',
|
||||
planLabel: '购买 GLM 编码套餐',
|
||||
planHint: '需单独购买编码套餐,走 Anthropic 协议;Key 与通用 GLM 不互通',
|
||||
},
|
||||
{
|
||||
id: 'kimi', name: 'Kimi 编码套餐', endpoint: 'https://api.kimi.com/coding/v1',
|
||||
models: ['kimi-for-coding', 'kimi-coding-latest'],
|
||||
planUrl: 'https://www.kimi.com/membership/pricing',
|
||||
planLabel: '购买 Kimi 套餐',
|
||||
planHint: 'Kimi 编码会员专用端点,适合编程与 Agent 场景',
|
||||
},
|
||||
{
|
||||
id: 'minimax-coding', name: 'MiniMax 编码套餐', endpoint: 'https://api.minimaxi.com/anthropic',
|
||||
models: ['MiniMax-M3', 'MiniMax-M3[1m]', 'MiniMax-M2.7'],
|
||||
planUrl: 'https://platform.minimaxi.com/user-center/payment/token-plan',
|
||||
planLabel: '购买 Token 套餐',
|
||||
planHint: '使用订阅密钥(Subscription Key),走 Anthropic 协议;与按量 API Key 不互通',
|
||||
},
|
||||
];
|
||||
|
||||
const CLOUD_PROVIDERS: ProviderMeta[] = [...CLOUD_GENERAL_PROVIDERS, ...CLOUD_CODING_PROVIDERS];
|
||||
|
||||
// 自定义接入:手动填请求地址 + API Key + 模型名(OpenAI 兼容) // Logan.Li
|
||||
const CUSTOM_PROVIDERS: ProviderMeta[] = [
|
||||
{
|
||||
id: 'custom', name: '大模型自定义', endpoint: '', models: [],
|
||||
apiKeyOptional: true,
|
||||
planHint: '填写 OpenAI 兼容的请求地址、API Key(无鉴权可留空)和模型名;适用于 OpenRouter、内网代理、其他未列出的服务',
|
||||
},
|
||||
];
|
||||
|
||||
// 本地/自建模型(API Key 可选,置于列表底部) // Logan.Li
|
||||
const LOCAL_PROVIDERS: ProviderMeta[] = [
|
||||
{
|
||||
id: 'ollama', name: 'Ollama 本地', endpoint: 'http://localhost:11434/v1',
|
||||
models: ['qwen2.5:7b', 'llama3.2', 'deepseek-r1:7b'],
|
||||
apiKeyOptional: true,
|
||||
},
|
||||
{
|
||||
id: 'custom', name: '自定义 (OpenAI 兼容)', endpoint: '', models: [] },
|
||||
id: 'vllm', name: 'vLLM 自建', endpoint: 'http://localhost:8000/v1',
|
||||
models: [],
|
||||
apiKeyOptional: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 生成参数(温度/max_tokens/系统提示词)已按 Claude Code 模式精简:UI 不再暴露,走内置默认。 // Logan.Li
|
||||
const PROVIDERS: ProviderMeta[] = [...CLOUD_PROVIDERS, ...CUSTOM_PROVIDERS, ...LOCAL_PROVIDERS];
|
||||
|
||||
// 模型名需手动输入的提供商(空 models 或需填接入点 ID) // Logan.Li
|
||||
const needsManualModel = (provider: string, prov?: ProviderMeta) =>
|
||||
provider === 'custom' || provider === 'vllm' || !prov?.models.length;
|
||||
|
||||
const endpointPlaceholder = (provider: string) => {
|
||||
switch (provider) {
|
||||
case 'custom': return 'https://openrouter.ai/api/v1';
|
||||
case 'ollama': return 'http://localhost:11434/v1';
|
||||
case 'vllm': return 'http://localhost:8000/v1';
|
||||
default: return 'https://api.xxx.com/v1';
|
||||
}
|
||||
};
|
||||
|
||||
const defaultCfg: LLMConfigData = {
|
||||
provider: 'deepseek', model: 'deepseek-v4-pro', api_key: '',
|
||||
endpoint: 'https://api.deepseek.com/v1', temperature: 0.7, max_tokens: 8192,
|
||||
@@ -86,9 +170,12 @@ export default function LLMConfigPage() {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<LLMTestResult | null>(null);
|
||||
const [modelMode, setModelMode] = useState<'select' | 'input'>('select');
|
||||
const [verifiedSnapshot, setVerifiedSnapshot] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
const load = async () => {
|
||||
const currentSnapshot = useMemo(() => connectionSnapshot(cfg), [cfg]);
|
||||
const isVerified = verifiedSnapshot === currentSnapshot;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const saved = await aiApi.getConfig();
|
||||
@@ -96,47 +183,100 @@ export default function LLMConfigPage() {
|
||||
setCfg(merged);
|
||||
const prov = PROVIDERS.find((x) => x.id === merged.provider);
|
||||
const inList = prov ? prov.models.includes(merged.model) : false;
|
||||
setModelMode(inList || merged.provider === 'custom' ? 'select' : 'input');
|
||||
setModelMode(needsManualModel(merged.provider, prov) || !inList ? 'input' : 'select');
|
||||
if (saved.configured) {
|
||||
setVerifiedSnapshot(connectionSnapshot(merged));
|
||||
}
|
||||
}
|
||||
catch { /* 首次无配置,使用默认 */ }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const invalidateVerify = () => {
|
||||
setVerifiedSnapshot(null);
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!isVerified) {
|
||||
toast.error('请先测试连接并通过验证');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try { await aiApi.saveConfig(cfg); toast.success('配置已保存'); }
|
||||
try {
|
||||
await aiApi.saveConfig(cfg);
|
||||
toast.success('配置已保存');
|
||||
}
|
||||
catch { toast.error('保存失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
const keyOptional = prov?.apiKeyOptional ?? false;
|
||||
if (!keyOptional && !cfg.api_key?.trim()) {
|
||||
toast.error('请先填写 API Key');
|
||||
return;
|
||||
}
|
||||
if (!cfg.model?.trim()) {
|
||||
toast.error('请先填写模型名');
|
||||
return;
|
||||
}
|
||||
if (!cfg.endpoint?.trim()) {
|
||||
toast.error('请先填写接口地址');
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await aiApi.testConnection();
|
||||
const res = await aiApi.testConnection(cfg);
|
||||
setTestResult(res);
|
||||
toast.success('连接成功');
|
||||
setVerifiedSnapshot(currentSnapshot);
|
||||
toast.success('连接验证通过,可以保存');
|
||||
}
|
||||
catch {
|
||||
setVerifiedSnapshot(null);
|
||||
}
|
||||
catch { /* 拦截器已提示 */ }
|
||||
finally { setTesting(false); }
|
||||
};
|
||||
|
||||
const onProvider = (p: string) => {
|
||||
const prov = PROVIDERS.find((x) => x.id === p);
|
||||
if (!prov) return;
|
||||
const model = prov.models[0] || cfg.model;
|
||||
setModelMode('select');
|
||||
setCfg({ ...cfg, provider: p, endpoint: prov.endpoint || cfg.endpoint, model });
|
||||
const next = PROVIDERS.find((x) => x.id === p);
|
||||
if (!next) return;
|
||||
const model = next.models[0] || '';
|
||||
setModelMode(needsManualModel(p, next) ? 'input' : 'select');
|
||||
invalidateVerify();
|
||||
const endpoint = p === 'custom' ? '' : (next.endpoint || cfg.endpoint);
|
||||
setCfg({ ...cfg, provider: p, endpoint, model });
|
||||
};
|
||||
|
||||
const onModel = (m: string) => {
|
||||
if (m === '__manual') {
|
||||
setModelMode('input');
|
||||
return;
|
||||
}
|
||||
invalidateVerify();
|
||||
setCfg({ ...cfg, model: m });
|
||||
};
|
||||
const set = <K extends keyof LLMConfigData>(k: K, v: LLMConfigData[K]) => setCfg({ ...cfg, [k]: v });
|
||||
|
||||
const set = <K extends keyof LLMConfigData>(k: K, v: LLMConfigData[K]) => {
|
||||
if (k === 'provider' || k === 'model' || k === 'api_key' || k === 'endpoint') {
|
||||
invalidateVerify();
|
||||
}
|
||||
setCfg({ ...cfg, [k]: v });
|
||||
};
|
||||
|
||||
const prov = PROVIDERS.find((x) => x.id === cfg.provider);
|
||||
const unknownProvider = Boolean(cfg.provider && !prov);
|
||||
const models = prov?.models ?? [];
|
||||
const manualModel = needsManualModel(cfg.provider, prov);
|
||||
const isCustom = cfg.provider === 'custom';
|
||||
const apiKeyOptional = prov?.apiKeyOptional ?? false;
|
||||
const endpointRequired = apiKeyOptional || cfg.provider === 'custom';
|
||||
const canTest = Boolean(cfg.model?.trim())
|
||||
&& (endpointRequired ? Boolean(cfg.endpoint?.trim()) : true)
|
||||
&& (apiKeyOptional || Boolean(cfg.api_key?.trim()));
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
@@ -157,15 +297,45 @@ export default function LLMConfigPage() {
|
||||
<Select value={cfg.provider} onValueChange={onProvider}>
|
||||
<SelectTrigger className="dg-input"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
{PROVIDERS.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||
{unknownProvider && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>已保存</SelectLabel>
|
||||
<SelectItem value={cfg.provider}>{cfg.provider}</SelectItem>
|
||||
</SelectGroup>
|
||||
)}
|
||||
<SelectGroup>
|
||||
<SelectLabel>云服务</SelectLabel>
|
||||
{CLOUD_GENERAL_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>编码套餐 Coding Plan</SelectLabel>
|
||||
{CLOUD_CODING_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>自定义接入</SelectLabel>
|
||||
{CUSTOM_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>本地 / 自建</SelectLabel>
|
||||
{LOCAL_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">模型</Label>
|
||||
{isCustom || modelMode === 'input' ? (
|
||||
{manualModel || modelMode === 'input' ? (
|
||||
<Input value={cfg.model} onChange={(e) => set('model', e.target.value)}
|
||||
placeholder={isCustom ? '自定义模型名' : '手动输入模型名'} className="dg-input" />
|
||||
placeholder={isCustom ? '如 deepseek/deepseek-chat 或 gpt-4o' : '手动输入模型名'}
|
||||
className="dg-input" />
|
||||
) : (
|
||||
<Select value={cfg.model} onValueChange={onModel}>
|
||||
<SelectTrigger className="dg-input">
|
||||
@@ -179,21 +349,69 @@ export default function LLMConfigPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unknownProvider && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-[var(--dg-text-secondary)]">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-amber-500" />
|
||||
<span>当前提供商「{cfg.provider}」不在预设列表中,可继续使用或切换到其他提供商。</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prov?.planUrl && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2.5">
|
||||
<ShoppingBag className="w-4 h-4 shrink-0 mt-0.5 text-[var(--dg-orange)]" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="text-sm text-[var(--dg-text-secondary)]">
|
||||
{prov.planHint || `使用 ${prov.name} 需先开通服务并获取 API Key`}
|
||||
</div>
|
||||
<a
|
||||
href={prov.planUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs dg-text-blue hover:underline"
|
||||
>
|
||||
{prov.planLabel || '前往开通'}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCustom && prov?.planHint && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2.5">
|
||||
<KeyRound className="w-4 h-4 shrink-0 mt-0.5 text-[var(--dg-blue)]" />
|
||||
<div className="text-sm text-[var(--dg-text-secondary)]">{prov.planHint}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)] flex items-center gap-1"><KeyRound className="w-3.5 h-3.5" />API Key</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)] flex items-center gap-1">
|
||||
<KeyRound className="w-3.5 h-3.5" />
|
||||
API Key{apiKeyOptional && <span className="text-xs dg-text-muted font-normal">(可选)</span>}
|
||||
</Label>
|
||||
<Input type="password" value={cfg.api_key} onChange={(e) => set('api_key', e.target.value)}
|
||||
placeholder="sk-..." className="dg-input" />
|
||||
placeholder={apiKeyOptional ? '无鉴权可留空' : 'sk-...'} className="dg-input" />
|
||||
{apiKeyOptional && (
|
||||
<p className="text-xs dg-text-muted">本地或内网自建通常无需密钥;若网关启用了鉴权再填写。</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">接口地址(Endpoint)</Label>
|
||||
<Input value={cfg.endpoint} onChange={(e) => set('endpoint', e.target.value)}
|
||||
placeholder="https://api.xxx.com/v1" className="dg-input" />
|
||||
placeholder={endpointPlaceholder(cfg.provider)} className="dg-input" />
|
||||
{prov?.planHint && !prov.planUrl && (
|
||||
<p className="text-xs dg-text-muted">{prov.planHint}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardHeader><CardTitle className="text-[var(--dg-text-primary)] text-base flex items-center gap-2"><Sparkles className="w-4 h-4 text-[var(--dg-orange)]" />推理 / Plan 模式</CardTitle></CardHeader>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[var(--dg-text-primary)] text-base flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-[var(--dg-orange)]" />推理 / Plan 模式
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
@@ -212,14 +430,25 @@ export default function LLMConfigPage() {
|
||||
<Switch checked={cfg.enabled} onCheckedChange={(v) => set('enabled', v)} />
|
||||
<span>启用 LLM 服务</span>
|
||||
</div>
|
||||
<Button onClick={save} disabled={saving} className="dg-btn-primary">
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />} 保存配置
|
||||
<Button variant="outline" onClick={test} disabled={testing || !canTest} className="dg-btn-secondary">
|
||||
{testing
|
||||
? <Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
: <Wifi className="w-4 h-4 mr-1" />}
|
||||
测试连接
|
||||
</Button>
|
||||
<Button variant="outline" onClick={test} disabled={testing || !cfg.enabled}
|
||||
className="dg-btn-secondary">
|
||||
{testing ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Wifi className="w-4 h-4 mr-1" />} 测试连接
|
||||
<Button onClick={save} disabled={saving || !isVerified} className="dg-btn-primary">
|
||||
{saving
|
||||
? <Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
: <Save className="w-4 h-4 mr-1" />}
|
||||
保存配置
|
||||
</Button>
|
||||
{!cfg.enabled && <span className="text-xs dg-text-muted">请先保存并启用后再测试</span>}
|
||||
{isVerified ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-[var(--dg-green)]">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />已验证,可保存
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs dg-text-muted">切换提供商或修改凭证后,请先测试连接</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
@@ -231,12 +460,16 @@ export default function LLMConfigPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-sm text-[var(--dg-text-secondary)] whitespace-pre-wrap leading-relaxed">{testResult.answer || '(模型未返回内容)'}</div>
|
||||
<div className="text-sm text-[var(--dg-text-secondary)] whitespace-pre-wrap leading-relaxed">
|
||||
{testResult.answer || '(模型未返回内容)'}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs dg-text-muted">
|
||||
<span className="bg-[var(--dg-bg-tertiary)] px-2 py-1 rounded">模型: {testResult.model || '未知'}</span>
|
||||
<span className="bg-[var(--dg-bg-tertiary)] px-2 py-1 rounded">提供商: {testResult.provider}</span>
|
||||
<span className="bg-[var(--dg-bg-tertiary)] px-2 py-1 rounded">消耗 Token: {testResult.tokens_used ?? '-'}</span>
|
||||
{testResult.reasoning_enabled && <span className="bg-amber-900/40 dg-text-yellow px-2 py-1 rounded">推理模式已开启</span>}
|
||||
{testResult.reasoning_enabled && (
|
||||
<span className="bg-amber-900/40 dg-text-yellow px-2 py-1 rounded">推理模式已开启</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -244,8 +477,14 @@ export default function LLMConfigPage() {
|
||||
|
||||
<div className="flex items-start gap-2 text-xs dg-text-muted">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>测试时会向当前模型发送「你是什么模型?请简短回答。」,并在页面上方展示模型返回内容,用于确认接入的是目标模型。</span>
|
||||
<span>
|
||||
云服务:选择提供商 → 开通并填写 API Key → 测试连接 → 保存。
|
||||
编码套餐:GLM / Kimi / MiniMax 固定月费套餐,适合 Agent CLI 高频调用(注意 Key 与按量版不互通)。
|
||||
大模型自定义:填请求地址 + API Key + 模型名,兼容 OpenRouter 等 OpenAI 格式服务。
|
||||
本地/自建(Ollama、vLLM):填写 Endpoint 与模型名即可,API Key 可留空。
|
||||
测试时会发送「你是什么模型?请简短回答。」以确认接入目标模型。
|
||||
</span>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useUserStore, useLanguageStore } from '@/store';
|
||||
import { authApi } from '@/api';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { ThemePicker } from '@/components/layout/ThemePicker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '@/i18n';
|
||||
import {
|
||||
@@ -57,17 +58,26 @@ export function Login() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 语言 / 主题 */}
|
||||
<div className="fixed top-4 right-4 z-50 flex items-center gap-2">
|
||||
{/* 语言 / 主题 — 与 Header 同款 Button,避免 dg-btn-secondary 挤压图标 // Logan.Li */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-4 right-4 z-50 flex items-center gap-0.5 p-1 rounded-lg',
|
||||
'border border-[var(--border-default)] bg-[var(--surface-raised)]/90 backdrop-blur-sm',
|
||||
)}
|
||||
style={{ boxShadow: 'var(--shadow-dropdown)' }}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t('header.language')}
|
||||
className="dg-btn-secondary h-9 w-9 p-0 rounded-lg"
|
||||
title={t('header.language')}
|
||||
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
>
|
||||
<Languages className="size-4" />
|
||||
</button>
|
||||
<Languages className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
@@ -81,13 +91,14 @@ export function Login() {
|
||||
key={lang.code}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 cursor-pointer',
|
||||
'focus:bg-[var(--surface-hover)] focus:text-[var(--text-primary)]',
|
||||
language === lang.code ? 'text-[var(--text-primary)]' : 'dg-text-secondary',
|
||||
)}
|
||||
onClick={() => setLanguage(lang.code as AppLanguage)}
|
||||
>
|
||||
<span className="text-base">{lang.flag}</span>
|
||||
<span className="flex-1 text-sm">{lang.label}</span>
|
||||
{language === lang.code && <Check className="size-4 shrink-0 text-[var(--brand)]" />}
|
||||
{language === lang.code && <Check className="h-4 w-4 shrink-0 text-[var(--brand)]" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -95,13 +106,16 @@ export function Login() {
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t('header.theme')}
|
||||
className="dg-btn-secondary h-9 w-9 p-0 rounded-lg"
|
||||
title={t('header.theme')}
|
||||
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
>
|
||||
<Palette className="size-4" />
|
||||
</button>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
|
||||
@@ -4,20 +4,21 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Clock, Server, ArrowRight } from 'lucide-react';
|
||||
import { dashboardStatsApi, serverApi, kubernetesApi } from '@/api';
|
||||
import { dashboardStatsApi, kubernetesApi } from '@/api';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import { CostOverviewCard, TopResourceUsageCard, ResourceUsageCard } from './DashboardCharts';
|
||||
import {
|
||||
AlertMessagesCard,
|
||||
CertificateExpiryCard,
|
||||
ReleaseHistoryCard,
|
||||
ServerStatusCard,
|
||||
OpsOverviewCard,
|
||||
KubernetesClusterCard,
|
||||
} from './DashboardRecent';
|
||||
import type {
|
||||
DashboardRelease,
|
||||
DashboardServer,
|
||||
DashboardCluster,
|
||||
DashboardAutomationStats,
|
||||
DashboardOpsStats,
|
||||
} from './DashboardRecent';
|
||||
import { SkeletonDashboard } from '@/components/layout/SkeletonCard';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
@@ -41,7 +42,6 @@ export function Dashboard() {
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [stats, setStats] = useState<JsonData>(null);
|
||||
const [servers, setServers] = useState<DashboardServer[]>([]);
|
||||
const [clusters, setClusters] = useState<DashboardCluster[]>([]);
|
||||
const [alerts, setAlerts] = useState<AlertEventRecord[]>([]);
|
||||
const [releases, setReleases] = useState<DashboardRelease[]>([]);
|
||||
@@ -51,15 +51,11 @@ export function Dashboard() {
|
||||
const refreshData = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const [statsRes, serversRes, clustersRes, alertsRes, releasesRes, metricsRes] = await Promise.all([
|
||||
const [statsRes, clustersRes, alertsRes, releasesRes, metricsRes] = await Promise.all([
|
||||
dashboardStatsApi.getStats().catch((e) => {
|
||||
console.error('Failed to fetch dashboard stats:', e);
|
||||
return null;
|
||||
}),
|
||||
serverApi.getServers({ page: 1, page_size: 100 }).catch((e) => {
|
||||
console.error('Failed to fetch servers:', e);
|
||||
return null;
|
||||
}),
|
||||
kubernetesApi.getClusters().catch((e) => {
|
||||
console.error('Failed to fetch k8s clusters:', e);
|
||||
return null;
|
||||
@@ -79,7 +75,6 @@ export function Dashboard() {
|
||||
]);
|
||||
|
||||
setStats(statsRes);
|
||||
setServers(serversRes?.list ?? []);
|
||||
setClusters(clustersRes?.list ?? []);
|
||||
setAlerts(Array.isArray(alertsRes) ? alertsRes : []);
|
||||
setReleases(Array.isArray(releasesRes) ? releasesRes : []);
|
||||
@@ -95,8 +90,8 @@ export function Dashboard() {
|
||||
|
||||
useEffect(() => {
|
||||
refreshData();
|
||||
// 每 5 秒自动刷新
|
||||
const interval = setInterval(refreshData, 5000);
|
||||
// 每 30 秒自动刷新(集群列表含实时统计,不宜过频)// Logan.Li
|
||||
const interval = setInterval(refreshData, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshData]);
|
||||
|
||||
@@ -108,6 +103,9 @@ export function Dashboard() {
|
||||
const k8sStats = stats?.kubernetes_stats;
|
||||
const alertStats = stats?.alert_stats;
|
||||
const costStats = stats?.cost_stats;
|
||||
const automationStats = stats?.automation_stats as DashboardAutomationStats | undefined;
|
||||
const opsStats = stats?.ops_stats as DashboardOpsStats | undefined;
|
||||
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
@@ -136,18 +134,19 @@ export function Dashboard() {
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: t('dashboard.stat.servers'), value: serverStats?.total ?? 0, color: 'blue' },
|
||||
{ label: t('dashboard.stat.kubernetes'), value: k8sStats?.total ?? 0, color: 'purple' },
|
||||
{ label: t('dashboard.stat.running'), value: serverStats?.running ?? 0, color: 'emerald' },
|
||||
{ label: t('dashboard.stat.k8sClusters'), value: k8sStats?.total ?? 0, color: 'purple' },
|
||||
{ label: t('dashboard.stat.healthyClusters'), value: k8sStats?.healthy ?? 0, color: 'cyan' },
|
||||
{ label: t('dashboard.stat.cronJobs'), value: automationStats?.cron_job_count ?? 0, color: 'cyan' },
|
||||
{ label: t('dashboard.stat.siteProbes'), value: opsStats?.site_task_count ?? 0, color: 'green' },
|
||||
{ label: t('dashboard.stat.alerts'), value: alertStats?.firing ?? 0, color: alertStats?.firing > 0 ? 'red' : 'muted' },
|
||||
{ label: t('dashboard.stat.critical'), value: alertStats?.critical ?? 0, color: 'red' },
|
||||
{ label: t('dashboard.stat.monthlyCost'), value: `¥${Number(costStats?.month_cost ?? 0).toLocaleString()}`, color: 'amber' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
contentClassName="space-y-6"
|
||||
>
|
||||
<div>
|
||||
{/* 资源使用率趋势 — 第一行 */}
|
||||
<div className="animate-fade-in-delay-1">
|
||||
<ResourceUsageCard
|
||||
avgCpu={Number(serverStats?.avg_cpu ?? 0)}
|
||||
avgMemory={Number(serverStats?.avg_memory ?? 0)}
|
||||
@@ -161,23 +160,29 @@ export function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cost & Top Resource Usage */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-fade-in-delay-3">
|
||||
<CostOverviewCard costStats={costStats} />
|
||||
{/* 运维概览 → K8s 集群 → Top5 资源使用 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 animate-fade-in-delay-2">
|
||||
<OpsOverviewCard
|
||||
automation={automationStats}
|
||||
ops={opsStats}
|
||||
serverCount={serverStats?.total}
|
||||
hostMonitorCount={serverStats?.running}
|
||||
firingAlerts={alertStats?.firing}
|
||||
/>
|
||||
<KubernetesClusterCard clusters={clusters} />
|
||||
<TopResourceUsageCard topUsage={stats?.top_resource_usage} />
|
||||
</div>
|
||||
|
||||
{/* Alert Messages & Certificate Expiry */}
|
||||
{/* 费用概览 & 发布历史 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-fade-in-delay-3">
|
||||
<AlertMessagesCard alerts={alerts} />
|
||||
<CertificateExpiryCard expCerts={expCerts} />
|
||||
<CostOverviewCard costStats={costStats} />
|
||||
<ReleaseHistoryCard releases={releases} />
|
||||
</div>
|
||||
|
||||
{/* Bottom Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 animate-fade-in-delay-4">
|
||||
<ReleaseHistoryCard releases={releases} />
|
||||
<ServerStatusCard servers={servers} />
|
||||
<KubernetesClusterCard clusters={clusters} />
|
||||
{/* 告警消息 & 证书到期提醒 — 置底 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-fade-in-delay-4">
|
||||
<AlertMessagesCard alerts={alerts} />
|
||||
<CertificateExpiryCard expCerts={expCerts} />
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
ArrowRight,
|
||||
Cloud,
|
||||
Shield,
|
||||
Clock3,
|
||||
Globe,
|
||||
Database,
|
||||
Server,
|
||||
Circle,
|
||||
Monitor,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AlertEventRecord, CertExpiryRecord } from '@/types';
|
||||
@@ -27,14 +31,6 @@ export interface DashboardRelease {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DashboardServer {
|
||||
id: number | string;
|
||||
status: string;
|
||||
hostname?: string;
|
||||
ip?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DashboardCluster {
|
||||
id: number | string;
|
||||
code?: string;
|
||||
@@ -44,9 +40,34 @@ export interface DashboardCluster {
|
||||
memory_usage?: number;
|
||||
node_count?: number;
|
||||
pod_count?: number;
|
||||
ingress_count?: number;
|
||||
deployment_count?: number;
|
||||
service_count?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DashboardAutomationStats {
|
||||
cron_job_count?: number;
|
||||
script_count?: number;
|
||||
today_executions?: number;
|
||||
success_rate?: number;
|
||||
}
|
||||
|
||||
export interface DashboardOpsStats {
|
||||
domain_count?: number;
|
||||
site_task_count?: number;
|
||||
middleware_count?: number;
|
||||
snmp_device_count?: number;
|
||||
monitor_target_count?: number;
|
||||
}
|
||||
|
||||
export interface DashboardPipelineStats {
|
||||
today_count?: number;
|
||||
success?: number;
|
||||
failed?: number;
|
||||
running?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardRecent - 优化后的仪表盘底部卡片组件
|
||||
*
|
||||
@@ -336,84 +357,70 @@ export function ReleaseHistoryCard({ releases }: { releases: DashboardRelease[]
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerStatusCard({ servers }: { servers: DashboardServer[] }) {
|
||||
/** 运维概览 — 资产 / 监控 / 任务快捷入口 // Logan.Li */
|
||||
export function OpsOverviewCard({
|
||||
automation,
|
||||
ops,
|
||||
serverCount,
|
||||
hostMonitorCount,
|
||||
firingAlerts,
|
||||
}: {
|
||||
automation?: DashboardAutomationStats;
|
||||
ops?: DashboardOpsStats;
|
||||
serverCount?: number;
|
||||
hostMonitorCount?: number;
|
||||
firingAlerts?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const runningServers = servers.filter((s) => s.status === 'running').length;
|
||||
const stoppedServers = servers.filter((s) => s.status === 'stopped').length;
|
||||
const maintenanceServers = servers.filter((s) => s.status === 'maintenance').length;
|
||||
|
||||
const tiles = [
|
||||
{ key: 'servers', label: t('dashboard.ops.servers'), value: serverCount ?? 0, icon: Server, path: '/servers', color: 'text-[var(--dg-blue)]' },
|
||||
{ key: 'domains', label: t('dashboard.ops.domains'), value: ops?.domain_count ?? 0, icon: Globe, path: '/domains', color: 'text-[var(--dg-cyan)]' },
|
||||
{ key: 'host', label: t('dashboard.ops.hostMonitor'), value: hostMonitorCount ?? serverCount ?? 0, icon: Monitor, path: '/host-monitor', color: 'text-[var(--dg-green)]' },
|
||||
{ key: 'mw', label: t('dashboard.ops.middleware'), value: ops?.middleware_count ?? 0, icon: Database, path: '/monitor-center?tab=middleware', color: 'text-[var(--dg-red)]' },
|
||||
{ key: 'site', label: t('dashboard.ops.siteProbes'), value: ops?.site_task_count ?? 0, icon: Activity, path: '/monitor-center?tab=site', color: 'text-[var(--dg-yellow)]' },
|
||||
{ key: 'cron', label: t('dashboard.ops.cronJobs'), value: automation?.cron_job_count ?? 0, icon: Clock3, path: '/cronjobs', color: 'text-[var(--dg-purple)]' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="dg-card dg-card-hover">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-[var(--dg-text-primary)]">{t('dashboard.serversStatus.title')}</CardTitle>
|
||||
<CardDescription className="text-[var(--dg-text-muted)]">{t('dashboard.serversStatus.description')}</CardDescription>
|
||||
<CardTitle className="text-[var(--dg-text-primary)]">{t('dashboard.ops.title')}</CardTitle>
|
||||
<CardDescription className="text-[var(--dg-text-muted)]">{t('dashboard.ops.description')}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="dg-btn-ghost text-xs"
|
||||
onClick={() => navigate('/servers')}
|
||||
>
|
||||
{t('dashboard.serversStatus.viewAll')}
|
||||
<Button variant="ghost" size="sm" className="dg-btn-ghost text-xs" onClick={() => navigate('/dashboards')}>
|
||||
{t('dashboard.ops.monitorScreen')}
|
||||
<ArrowRight className="h-3.5 w-3.5 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Status Summary */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="text-center p-3 rounded-xl bg-[var(--dg-green)]/8 border border-[var(--dg-green)]/15">
|
||||
<div className="text-2xl font-bold text-[var(--dg-green)]">{runningServers}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{t('dashboard.serversStatus.running')}</div>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-xl bg-[var(--dg-yellow)]/8 border border-[var(--dg-yellow)]/15">
|
||||
<div className="text-2xl font-bold text-[var(--dg-yellow)]">{maintenanceServers}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{t('dashboard.serversStatus.maintenance')}</div>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-xl bg-[var(--dg-text-muted)]/8 border border-[var(--dg-border)]">
|
||||
<div className="text-2xl font-bold text-[var(--dg-text-muted)]">{stoppedServers}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{t('dashboard.serversStatus.stopped')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server List */}
|
||||
<div className="space-y-1.5">
|
||||
{servers.slice(0, 4).map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
className="flex items-center gap-3 p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)]/50 hover:bg-[var(--dg-bg-hover)] transition-all duration-200"
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
|
||||
{tiles.map((tile) => (
|
||||
<button
|
||||
key={tile.key}
|
||||
type="button"
|
||||
onClick={() => navigate(tile.path)}
|
||||
className="flex items-center gap-2.5 p-2.5 rounded-xl bg-[var(--dg-bg-tertiary)]/50 hover:bg-[var(--dg-bg-hover)] transition-all text-left"
|
||||
>
|
||||
<Circle
|
||||
className={cn(
|
||||
'h-2.5 w-2.5 flex-shrink-0 fill-current',
|
||||
server.status === 'running' ? 'text-[var(--dg-green)]' :
|
||||
server.status === 'maintenance' ? 'text-[var(--dg-yellow)]' :
|
||||
'text-[var(--dg-text-muted)]'
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-[var(--dg-text-primary)] truncate">{server.hostname}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{server.ip}</p>
|
||||
<tile.icon className={cn('h-4 w-4 shrink-0', tile.color)} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-bold text-[var(--dg-text-primary)] leading-none">{tile.value}</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mt-0.5 truncate">{tile.label}</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
{t('dashboard.serversStatus.cpu')} <span className="text-[var(--dg-text-secondary)] font-medium">{Number(server.cpu_usage ?? 0).toFixed(1)}%</span>
|
||||
</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
{t('dashboard.serversStatus.memory')} <span className="text-[var(--dg-text-secondary)] font-medium">{Number(server.memory_usage ?? 0).toFixed(1)}%</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-[var(--dg-text-muted)]">
|
||||
<Server className="h-8 w-8 mb-2 opacity-30" />
|
||||
<p className="text-sm">{t('dashboard.serversStatus.empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs px-1 pt-1 border-t border-[var(--dg-border)]">
|
||||
<span className="text-[var(--dg-text-muted)]">
|
||||
{t('dashboard.ops.todayExecutions')}: <span className="text-[var(--dg-text-primary)] font-medium">{automation?.today_executions ?? 0}</span>
|
||||
</span>
|
||||
<button type="button" className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]" onClick={() => navigate('/alert-center')}>
|
||||
{t('dashboard.ops.firingAlerts')}: <span className={cn('font-medium', (firingAlerts ?? 0) > 0 ? 'text-[var(--dg-red)]' : 'text-[var(--dg-text-primary)]')}>{firingAlerts ?? 0}</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -454,6 +461,20 @@ export function KubernetesClusterCard({ clusters }: { clusters: DashboardCluster
|
||||
{clusters.map((cluster) => {
|
||||
const cpuUsage = Number(cluster.cpu_usage ?? 0);
|
||||
const memoryUsage = Number(cluster.memory_usage ?? 0);
|
||||
const hasUsage = cpuUsage > 0 || memoryUsage > 0;
|
||||
const metrics = hasUsage
|
||||
? [
|
||||
{ label: t('dashboard.clusters.nodes'), value: cluster.node_count ?? 0 },
|
||||
{ label: t('dashboard.clusters.pods'), value: cluster.pod_count ?? 0 },
|
||||
{ label: t('dashboard.clusters.cpu'), value: `${cpuUsage.toFixed(1)}%` },
|
||||
{ label: t('dashboard.clusters.memory'), value: `${memoryUsage.toFixed(1)}%` },
|
||||
]
|
||||
: [
|
||||
{ label: t('dashboard.clusters.nodes'), value: cluster.node_count ?? 0 },
|
||||
{ label: t('dashboard.clusters.pods'), value: cluster.pod_count ?? 0 },
|
||||
{ label: t('dashboard.clusters.ingress'), value: cluster.ingress_count ?? 0 },
|
||||
{ label: t('dashboard.clusters.deployments'), value: cluster.deployment_count ?? 0 },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
key={cluster.id}
|
||||
@@ -478,12 +499,7 @@ export function KubernetesClusterCard({ clusters }: { clusters: DashboardCluster
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 text-center">
|
||||
{[
|
||||
{ label: t('dashboard.clusters.nodes'), value: cluster.node_count ?? 0 },
|
||||
{ label: t('dashboard.clusters.pods'), value: cluster.pod_count ?? 0 },
|
||||
{ label: t('dashboard.clusters.cpu'), value: `${cpuUsage.toFixed(1)}%` },
|
||||
{ label: t('dashboard.clusters.memory'), value: `${memoryUsage.toFixed(1)}%` },
|
||||
].map((item) => (
|
||||
{metrics.map((item) => (
|
||||
<div key={item.label} className="p-1.5 rounded-lg bg-[var(--dg-bg-primary)]/50">
|
||||
<div className="text-sm font-semibold text-[var(--dg-text-primary)]">{item.value}</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{item.label}</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Plus, Pencil, Trash2, RefreshCw, Key, Shield, Search,
|
||||
@@ -39,7 +40,7 @@ export default function CredentialManager() {
|
||||
...(filterType && filterType !== 'all' ? { secret_type: filterType } : {}),
|
||||
};
|
||||
|
||||
const { data: credentials = [], isLoading: loading, refetch } = useCredentials(listParams);
|
||||
const { data: credentials = [], isLoading: loading, isFetching, refetch } = useCredentials(listParams);
|
||||
const { deleteMutation, toggleActiveMutation } = useCredentialMutations();
|
||||
|
||||
const handleDelete = async (cred: Credential) => {
|
||||
@@ -118,8 +119,8 @@ export default function CredentialManager() {
|
||||
toolbar={toolbar}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => refetch()}>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-1', isFetching && 'animate-spin')} />{t('common.refresh')}
|
||||
</Button>
|
||||
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditing(null); setDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />{t('deploy.cred.addCredential')}
|
||||
|
||||
@@ -33,7 +33,7 @@ import { StatStrip } from '@/components/ui/stat-strip';
|
||||
export function PipelineManager() {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { data: pipelines = [], isLoading: loading, refetch: refetchPipelines } = usePipelines();
|
||||
const { data: pipelines = [], isLoading: loading, isFetching, refetch: refetchPipelines } = usePipelines();
|
||||
const { triggerMutation, deleteMutation, stopRunMutation } = usePipelineMutations();
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
||||
const [runs, setRuns] = useState<Record<number, Record<string, unknown>[]>>({});
|
||||
@@ -210,8 +210,8 @@ export function PipelineManager() {
|
||||
toolbar={toolbar}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchPipelines()}>
|
||||
<Clock className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />{t('common.refresh')}
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchPipelines()} disabled={isFetching}>
|
||||
<Clock className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />{t('common.refresh')}
|
||||
</Button>
|
||||
<Button className="dg-btn-primary" onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />{t('deploy.pipeline.newPipeline')}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
FileCode,
|
||||
Shield,
|
||||
Users,
|
||||
Menu,
|
||||
Network,
|
||||
CloudCog,
|
||||
Globe,
|
||||
@@ -389,17 +388,7 @@ export function Sidebar() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] flex-shrink-0"
|
||||
onClick={toggleSidebar}
|
||||
title={t('header.collapseSidebar')}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* 折叠按钮统一放在 Header,避免与顶栏重复 // Logan.Li */}
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 告警中心主组件 — TanStack Query 编排 // Logan.Li
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AlertTriangle, CheckCircle, Search, RefreshCw,
|
||||
Eye, Check, VolumeX, Plus, Rocket,
|
||||
Eye, Check, VolumeX, Plus,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -12,23 +13,24 @@ import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { alertCenterApi, alertApi } from '@/api';
|
||||
import { useAlerts, useAgentAlertRules, useAlertMutations } from '@/hooks/api';
|
||||
import { useAlerts, useUnifiedAlertRules, useMetricCatalog, useAlertMutations } from '@/hooks/api';
|
||||
import {
|
||||
type Alert, type AlertRule, getSeverityBadge, getStatusBadge,
|
||||
getChannelTypeLabel,
|
||||
type Alert, getSeverityBadge, getStatusBadge, getChannelTypeLabel,
|
||||
} from './alert-center-helpers';
|
||||
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
||||
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
|
||||
import { MetricCatalogTab } from './MetricCatalogTab';
|
||||
import { AlertRulesTab } from './AlertRulesTab';
|
||||
import { ContactDialog } from './ContactDialog';
|
||||
import { ContactGroupDialog } from './ContactGroupDialog';
|
||||
import SilenceRuleTab from './SilenceRuleTab';
|
||||
import { NotifyPolicyTab } from './NotifyPolicyTab';
|
||||
|
||||
import type { UnifiedAlertRule } from '@/api';
|
||||
import type { Contact, ContactGroup } from './alert-center-helpers';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -39,13 +41,11 @@ const AlertCenter: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sourceFilter, setSourceFilter] = useState('all');
|
||||
const [activeTab, setActiveTab] = useState('alerts');
|
||||
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [silenceOpen, setSilenceOpen] = useState(false);
|
||||
// 规则弹框
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<JsonData>(null);
|
||||
// 联系人 & 联系组
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [contactGroups, setContactGroups] = useState<ContactGroup[]>([]);
|
||||
@@ -58,20 +58,17 @@ const AlertCenter: React.FC = () => {
|
||||
const [pageSize] = useState(10);
|
||||
|
||||
const alertParams = useMemo(() => ({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
severity: severityFilter,
|
||||
status: statusFilter,
|
||||
}), [page, pageSize, severityFilter, statusFilter]);
|
||||
page, page_size: pageSize,
|
||||
severity: severityFilter, status: statusFilter, source_type: sourceFilter,
|
||||
}), [page, pageSize, severityFilter, statusFilter, sourceFilter]);
|
||||
|
||||
const { data: alertData, isLoading: loading, refetch: refetchAlerts } = useAlerts(alertParams);
|
||||
const { data: rules = [], refetch: refetchRules } = useAgentAlertRules();
|
||||
const { data: alertData, isLoading: loading, isFetching: fetchingAlerts, refetch: refetchAlerts } = useAlerts(alertParams);
|
||||
const { data: rules = [], isFetching: fetchingRules, refetch: refetchRules } = useUnifiedAlertRules('all');
|
||||
const { data: catalog = [] } = useMetricCatalog();
|
||||
const {
|
||||
ackMutation,
|
||||
resolveMutation,
|
||||
quickSetupMutation,
|
||||
toggleAgentRuleMutation,
|
||||
deleteAgentRuleMutation,
|
||||
ackMutation, resolveMutation, quickSetupMutation, monitorQuickSetupMutation,
|
||||
toggleAgentRuleMutation, deleteAgentRuleMutation,
|
||||
deleteMonitorRuleMutation, toggleMonitorRuleMutation,
|
||||
} = useAlertMutations();
|
||||
|
||||
const alerts = alertData?.list ?? [];
|
||||
@@ -86,15 +83,15 @@ const AlertCenter: React.FC = () => {
|
||||
|
||||
const fetchContacts = async () => {
|
||||
try {
|
||||
const res: JsonData = await alertApi.getContacts();
|
||||
setContacts((res?.data?.list || res?.list || []) as Contact[]);
|
||||
const res = await alertApi.getContacts() as { list?: Contact[] };
|
||||
setContacts(res?.list || []);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const fetchContactGroups = async () => {
|
||||
try {
|
||||
const res: JsonData = await alertApi.getContactGroups();
|
||||
setContactGroups((res?.data?.list || res?.list || []) as ContactGroup[]);
|
||||
const res = await alertApi.getContactGroups() as { list?: ContactGroup[] };
|
||||
setContactGroups(res?.list || []);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
@@ -140,19 +137,6 @@ const AlertCenter: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRule = async (ruleId: string, enabled: boolean) => {
|
||||
try {
|
||||
await toggleAgentRuleMutation.mutateAsync({ id: Number(ruleId), enabled });
|
||||
toast.success(t('alert.ruleUpdated'));
|
||||
} catch {
|
||||
toast.error(t('alert.updateFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAddRule = () => { setEditingRule(null); setRuleDialogOpen(true); };
|
||||
const handleEditRule = (rule: AlertRule) => { setEditingRule(rule); setRuleDialogOpen(true); };
|
||||
const handleRuleDialogClose = () => { setRuleDialogOpen(false); setEditingRule(null); };
|
||||
|
||||
const handleQuickSetup = async () => {
|
||||
const ok = await _confirm({
|
||||
title: t('alert.quickSetup'),
|
||||
@@ -168,24 +152,32 @@ const AlertCenter: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRule = async (rule: AlertRule) => {
|
||||
const handleDeleteAgentRule = async (rule: UnifiedAlertRule) => {
|
||||
const ok = await _confirm({ title: t('alert.deleteRule'), description: `${rule.name}`, confirmText: t('common.delete'), variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try { await deleteAgentRuleMutation.mutateAsync(Number(rule.id)); toast.success(t('alert.deleted')); } catch { toast.error(t('alert.deleteFailed')); }
|
||||
try { await deleteAgentRuleMutation.mutateAsync(rule.id); toast.success(t('alert.deleted')); } catch { toast.error(t('alert.deleteFailed')); }
|
||||
};
|
||||
|
||||
const handleFilterChange = (type: 'severity' | 'status', value: string) => {
|
||||
const handleDeleteMonitorRule = async (rule: UnifiedAlertRule) => {
|
||||
const ok = await _confirm({ title: t('alert.deleteRule'), description: `${rule.name}`, confirmText: t('common.delete'), variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try { await deleteMonitorRuleMutation.mutateAsync(rule.id); toast.success(t('alert.deleted')); } catch { toast.error(t('alert.deleteFailed')); }
|
||||
};
|
||||
|
||||
const handleFilterChange = (type: 'severity' | 'status' | 'source', value: string) => {
|
||||
if (type === 'severity') setSeverityFilter(value);
|
||||
else setStatusFilter(value);
|
||||
setPage(1); // 过滤条件变化时重置到第一页
|
||||
else if (type === 'status') setStatusFilter(value);
|
||||
else setSourceFilter(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const pageTabs = [
|
||||
{ id: 'alerts', label: t('alert.tab.alerts') },
|
||||
{ id: 'rules', label: t('alert.tab.rules') },
|
||||
{ id: 'silences', label: t('alert.tab.silences') },
|
||||
{ id: 'metrics', label: '指标目录' },
|
||||
{ id: 'contacts', label: t('alert.tab.contacts') },
|
||||
{ id: 'groups', label: t('alert.tab.groups') },
|
||||
{ id: 'notify', label: '通知策略' },
|
||||
];
|
||||
|
||||
const alertsToolbar = activeTab === 'alerts' ? (
|
||||
@@ -222,11 +214,24 @@ const AlertCenter: React.FC = () => {
|
||||
<SelectItem value="resolved">{t('alert.status.resolved')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sourceFilter} onValueChange={(v) => handleFilterChange('source', v)}>
|
||||
<SelectTrigger className="w-[120px] h-8 text-xs">
|
||||
<SelectValue placeholder="来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部来源</SelectItem>
|
||||
<SelectItem value="host">主机监控</SelectItem>
|
||||
<SelectItem value="middleware">中间件监控</SelectItem>
|
||||
<SelectItem value="snmp">SNMP 监控</SelectItem>
|
||||
<SelectItem value="site">站点拨测</SelectItem>
|
||||
<SelectItem value="log">日志告警</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs dg-text-muted">
|
||||
<span>{t('alert.totalItems', { count: total })}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => refetchAlerts()} className="h-7 text-xs px-2 dg-btn-secondary">
|
||||
<RefreshCw className="w-3 h-3 mr-1" />{t('common.refresh')}
|
||||
<Button variant="outline" size="sm" onClick={() => refetchAlerts()} disabled={fetchingAlerts} className="h-7 text-xs px-2 dg-btn-secondary">
|
||||
<RefreshCw className={cn('w-3 h-3 mr-1', fetchingAlerts && 'animate-spin')} />{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,18 +258,9 @@ const AlertCenter: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
actions={activeTab === 'rules' ? (
|
||||
<>
|
||||
<Button className="dg-btn-primary" onClick={handleOpenAddRule}>
|
||||
<Plus className="w-4 h-4 mr-1" />{t('alert.addRule')}
|
||||
</Button>
|
||||
<Button variant="secondary" className="dg-btn-secondary" onClick={handleQuickSetup} disabled={quickSetupMutation.isPending}>
|
||||
<Rocket className="w-4 h-4 mr-1" />
|
||||
{quickSetupMutation.isPending ? t('alert.quickSetupCreating') : t('alert.quickSetup')}
|
||||
</Button>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchRules()}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />{t('common.refresh')}
|
||||
</Button>
|
||||
</>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchRules()} disabled={fetchingRules}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', fetchingRules && 'animate-spin')} />{t('common.refresh')}
|
||||
</Button>
|
||||
) : activeTab === 'contacts' ? (
|
||||
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditingContact(null); setContactDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />添加联系人
|
||||
@@ -393,66 +389,22 @@ const AlertCenter: React.FC = () => {
|
||||
)}
|
||||
|
||||
{activeTab === 'rules' && (
|
||||
rules.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<AlertTriangle className="w-10 h-10" />}
|
||||
title={t('alert.noRules')}
|
||||
description={t('alert.noRulesHint')}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{rules.map((rule) => (
|
||||
<Card key={rule.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium">{rule.name}</span>
|
||||
{getSeverityBadge(rule.severity)}
|
||||
<Badge variant={rule.enabled ? 'default' : 'secondary'}>
|
||||
{rule.enabled ? t('alert.enabled') : t('alert.disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">{rule.description}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{rule.expression && (
|
||||
<span className="font-mono bg-muted px-2 py-1 rounded">{rule.expression}</span>
|
||||
)}
|
||||
{rule.duration ? <span>{t('alert.durationSeconds', { count: rule.duration })}</span> : null}
|
||||
</div>
|
||||
{(() => {
|
||||
let chs: string[] = [];
|
||||
try {
|
||||
const raw = rule.notify_channels;
|
||||
if (raw) chs = Array.isArray(raw) ? raw : JSON.parse(raw);
|
||||
} catch { /* ignore */ }
|
||||
if (!chs.length) return null;
|
||||
return (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{chs.map(ch => (
|
||||
<Badge key={ch} variant="outline" className="text-xs">{ch}</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditRule(rule)}>{t('common.edit')}</Button>
|
||||
<Button variant="ghost" size="sm" className="dg-text-red" onClick={() => handleDeleteRule(rule)}>{t('common.delete')}</Button>
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
onCheckedChange={(checked) => toggleRule(String(rule.id), checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
<AlertRulesTab
|
||||
rules={rules}
|
||||
loading={fetchingRules}
|
||||
catalog={catalog}
|
||||
onRefresh={() => refetchRules()}
|
||||
onToggleAgent={(id, enabled) => toggleAgentRuleMutation.mutate({ id, enabled })}
|
||||
onDeleteAgent={handleDeleteAgentRule}
|
||||
onDeleteMonitor={handleDeleteMonitorRule}
|
||||
onToggleMonitor={(id, enabled) => toggleMonitorRuleMutation.mutate({ id, enabled })}
|
||||
onQuickSetupHost={handleQuickSetup}
|
||||
onQuickSetupMonitor={(cat) => monitorQuickSetupMutation.mutate(cat)}
|
||||
quickSetupPending={quickSetupMutation.isPending || monitorQuickSetupMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'silences' && <SilenceRuleTab />}
|
||||
{activeTab === 'metrics' && <MetricCatalogTab />}
|
||||
|
||||
{activeTab === 'contacts' && (
|
||||
contacts.length === 0 ? (
|
||||
@@ -503,6 +455,8 @@ const AlertCenter: React.FC = () => {
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'notify' && <NotifyPolicyTab />}
|
||||
|
||||
{activeTab === 'groups' && (
|
||||
contactGroups.length === 0 ? (
|
||||
<PageEmpty title="暂无报警联系组" description="点击「添加联系组」创建" />
|
||||
@@ -564,13 +518,6 @@ const AlertCenter: React.FC = () => {
|
||||
onOpenChange={setSilenceOpen}
|
||||
onSilence={handleSilence}
|
||||
/>
|
||||
<AlertRuleDialog
|
||||
open={ruleDialogOpen}
|
||||
onOpenChange={handleRuleDialogClose}
|
||||
editingRule={editingRule}
|
||||
notifyChannels={[]}
|
||||
onSaved={() => { handleRuleDialogClose(); refetchRules(); }}
|
||||
/>
|
||||
<ContactDialog
|
||||
open={contactDialogOpen}
|
||||
onOpenChange={setContactDialogOpen}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// 告警规则 Tab — 主机/中间件/SNMP/站点 四类分栏 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Plus, Rocket, Server, Database, Network, Globe } from 'lucide-react';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { getSeverityBadge } from './alert-center-helpers';
|
||||
import { AgentRuleDialog } from '@/components/agent/AgentRuleDialog';
|
||||
import { MonitorRuleDialog } from './MonitorRuleDialog';
|
||||
import type { MetricCatalogCategory, UnifiedAlertRule } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
type RuleCategory = 'host' | 'middleware' | 'snmp' | 'site';
|
||||
type MonitorCategory = 'middleware' | 'snmp' | 'site';
|
||||
|
||||
const RULE_CATEGORIES: Array<{
|
||||
id: RuleCategory;
|
||||
label: string;
|
||||
icon: typeof Server;
|
||||
desc: string;
|
||||
}> = [
|
||||
{ id: 'host', label: '主机监控', icon: Server, desc: 'Agent CPU/内存/磁盘/离线' },
|
||||
{ id: 'middleware', label: '中间件', icon: Database, desc: 'Redis / MySQL 指标' },
|
||||
{ id: 'snmp', label: 'SNMP', icon: Network, desc: '网络设备 / BMC' },
|
||||
{ id: 'site', label: '站点拨测', icon: Globe, desc: 'HTTP/HTTPS 可用性与证书' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
rules: UnifiedAlertRule[];
|
||||
loading: boolean;
|
||||
catalog: MetricCatalogCategory[];
|
||||
onRefresh: () => void;
|
||||
onToggleAgent: (id: number, enabled: boolean) => void;
|
||||
onDeleteAgent: (rule: UnifiedAlertRule) => void;
|
||||
onDeleteMonitor: (rule: UnifiedAlertRule) => void;
|
||||
onToggleMonitor: (id: number, enabled: boolean) => void;
|
||||
onQuickSetupHost: () => void;
|
||||
onQuickSetupMonitor: (cat: string) => void;
|
||||
quickSetupPending: boolean;
|
||||
}
|
||||
|
||||
export function AlertRulesTab({
|
||||
rules, loading, catalog, onRefresh,
|
||||
onToggleAgent, onDeleteAgent, onDeleteMonitor, onToggleMonitor,
|
||||
onQuickSetupHost, onQuickSetupMonitor, quickSetupPending,
|
||||
}: Props) {
|
||||
const [activeCat, setActiveCat] = useState<RuleCategory>('host');
|
||||
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
|
||||
const [monitorDialogOpen, setMonitorDialogOpen] = useState(false);
|
||||
const [monitorCategory, setMonitorCategory] = useState<MonitorCategory>('middleware');
|
||||
const [editingAgent, setEditingAgent] = useState<JsonData>(null);
|
||||
const [editingMonitor, setEditingMonitor] = useState<UnifiedAlertRule | null>(null);
|
||||
|
||||
const filtered = rules.filter((r) => r.category === activeCat);
|
||||
const catMeta = RULE_CATEGORIES.find((c) => c.id === activeCat)!;
|
||||
|
||||
const getMetricsFor = (cat: MonitorCategory) =>
|
||||
catalog.find((c) => c.id === cat)?.metrics || [];
|
||||
|
||||
const openAdd = () => {
|
||||
if (activeCat === 'host') {
|
||||
setEditingAgent(null);
|
||||
setAgentDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
setMonitorCategory(activeCat as MonitorCategory);
|
||||
setEditingMonitor(null);
|
||||
setMonitorDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleQuickSetup = () => {
|
||||
if (activeCat === 'host') {
|
||||
onQuickSetupHost();
|
||||
} else {
|
||||
onQuickSetupMonitor(activeCat);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <PageLoading label="加载规则..." />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{RULE_CATEGORIES.map((cat) => {
|
||||
const Icon = cat.icon;
|
||||
const count = rules.filter((r) => r.category === cat.id).length;
|
||||
return (
|
||||
<Button
|
||||
key={cat.id}
|
||||
variant={activeCat === cat.id ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
onClick={() => setActiveCat(cat.id)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{cat.label}
|
||||
{count > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1">{count}</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs text-muted-foreground flex-1 min-w-[200px]">{catMeta.desc}</p>
|
||||
<Button size="sm" className="h-8" onClick={openAdd}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> 添加规则
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" className="h-8" onClick={handleQuickSetup} disabled={quickSetupPending}>
|
||||
<Rocket className="h-3.5 w-3.5 mr-1" /> 默认规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<PageEmpty title={`暂无${catMeta.label}规则`} description="点击「添加规则」或「默认规则」创建" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{filtered.map((rule) => (
|
||||
<Card key={`${rule.rule_type}-${rule.id}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<span className="font-medium text-sm">{rule.name}</span>
|
||||
{getSeverityBadge(rule.severity)}
|
||||
<Badge variant={rule.enabled ? 'default' : 'secondary'} className="text-[10px]">
|
||||
{rule.enabled ? '启用' : '停用'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{rule.metric} {rule.operator} {rule.threshold}
|
||||
{rule.target ? ` · ${rule.target}` : ''}
|
||||
</p>
|
||||
{rule.description && (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{rule.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
if (rule.rule_type === 'agent') {
|
||||
setEditingAgent(rule as unknown as JsonData);
|
||||
setAgentDialogOpen(true);
|
||||
} else {
|
||||
setEditingMonitor(rule);
|
||||
setMonitorCategory(rule.category as MonitorCategory);
|
||||
setMonitorDialogOpen(true);
|
||||
}
|
||||
}}>编辑</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red"
|
||||
onClick={() => rule.rule_type === 'agent' ? onDeleteAgent(rule) : onDeleteMonitor(rule)}>删除</Button>
|
||||
<Switch checked={rule.enabled}
|
||||
onCheckedChange={(v) => rule.rule_type === 'agent'
|
||||
? onToggleAgent(rule.id, v)
|
||||
: onToggleMonitor(rule.id, v)} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentRuleDialog
|
||||
open={agentDialogOpen}
|
||||
onOpenChange={setAgentDialogOpen}
|
||||
editingRule={editingAgent}
|
||||
onSuccess={() => { setAgentDialogOpen(false); onRefresh(); }}
|
||||
/>
|
||||
<MonitorRuleDialog
|
||||
open={monitorDialogOpen}
|
||||
onOpenChange={setMonitorDialogOpen}
|
||||
category={monitorCategory}
|
||||
metrics={getMetricsFor(monitorCategory)}
|
||||
editing={editingMonitor ? {
|
||||
id: editingMonitor.id, name: editingMonitor.name, metric: editingMonitor.metric,
|
||||
operator: editingMonitor.operator, threshold: editingMonitor.threshold,
|
||||
severity: editingMonitor.severity, enabled: editingMonitor.enabled,
|
||||
} : null}
|
||||
onSaved={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// 监控指标目录 — 主机/中间件/站点拨测可采集指标 // Logan.Li
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, RefreshCw, Server, Database, Globe, Network } from 'lucide-react';
|
||||
import { alertMetricsApi, type MetricCatalogCategory } from '@/api';
|
||||
|
||||
const CATEGORY_ORDER = ['host', 'middleware', 'snmp', 'site'];
|
||||
|
||||
const categoryIcons: Record<string, typeof Server> = {
|
||||
host: Server,
|
||||
middleware: Database,
|
||||
snmp: Network,
|
||||
site: Globe,
|
||||
};
|
||||
|
||||
export function MetricCatalogTab() {
|
||||
const [categories, setCategories] = useState<MetricCatalogCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeCat, setActiveCat] = useState<string>('host');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await alertMetricsApi.getCatalog();
|
||||
const cats = (data as { categories?: MetricCatalogCategory[] })?.categories || [];
|
||||
setCategories(cats);
|
||||
if (cats.length > 0 && !cats.find(c => c.id === activeCat)) {
|
||||
setActiveCat(cats[0].id);
|
||||
}
|
||||
} catch { /* ignore */ } finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const current = categories.find(c => c.id === activeCat);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
主机 / 中间件 / SNMP / 站点拨测可采集指标,含默认告警阈值参考
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={load}>
|
||||
<RefreshCw className="h-3 w-3 mr-1" /> 刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[...categories].sort((a, b) => {
|
||||
const ai = CATEGORY_ORDER.indexOf(a.id);
|
||||
const bi = CATEGORY_ORDER.indexOf(b.id);
|
||||
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
|
||||
}).map(cat => {
|
||||
const Icon = categoryIcons[cat.id] || Server;
|
||||
return (
|
||||
<Button
|
||||
key={cat.id}
|
||||
variant={activeCat === cat.id ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
onClick={() => setActiveCat(cat.id)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{cat.label}
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1">{cat.metrics.length}</Badge>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{current && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-3">{current.description}</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left font-medium">指标</th>
|
||||
<th className="px-2 py-2 text-left font-medium">单位</th>
|
||||
<th className="px-2 py-2 text-left font-medium">类型</th>
|
||||
<th className="px-2 py-2 text-left font-medium">警告阈值</th>
|
||||
<th className="px-2 py-2 text-left font-medium">严重阈值</th>
|
||||
<th className="px-2 py-2 text-left font-medium">上限</th>
|
||||
<th className="px-2 py-2 text-left font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{current.metrics.map(m => (
|
||||
<tr key={m.key} className="hover:bg-muted/30">
|
||||
<td className="px-2 py-2">
|
||||
<div className="font-medium">{m.label}</div>
|
||||
<code className="text-[10px] text-muted-foreground">{m.key}</code>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{m.unit || '—'}</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge variant="outline" className="text-[10px]">{m.type}</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-2 dg-text-yellow">
|
||||
{m.default_op} {m.default_warn}{m.unit && m.unit !== '' ? m.unit : ''}
|
||||
</td>
|
||||
<td className="px-2 py-2 dg-text-red">
|
||||
{m.default_op} {m.default_crit}{m.unit && m.unit !== '' ? m.unit : ''}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{m.max}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground max-w-[200px]">{m.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// 中间件/站点拨测告警规则弹框 // Logan.Li
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { alertMetricsApi, type MetricCatalogItem } from '@/api';
|
||||
import { SEVERITY_OPTIONS } from '@/components/agent/agentAlertHelper';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
category: 'middleware' | 'snmp' | 'site';
|
||||
metrics: MetricCatalogItem[];
|
||||
editing?: { id: number; name: string; metric: string; operator: string; threshold: number; severity: string; enabled: boolean; target_id?: number } | null;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: '>', label: '> 大于' },
|
||||
{ value: '<', label: '< 小于' },
|
||||
{ value: '>=', label: '≥ 大于等于' },
|
||||
{ value: '<=', label: '≤ 小于等于' },
|
||||
{ value: '==', label: '= 等于' },
|
||||
];
|
||||
|
||||
export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editing, onSaved }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
name: '', metric: '', operator: '>', threshold: '0', severity: 'warning', enabled: true,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editing) {
|
||||
setForm({
|
||||
name: editing.name,
|
||||
metric: editing.metric,
|
||||
operator: editing.operator || '>',
|
||||
threshold: String(editing.threshold),
|
||||
severity: editing.severity || 'warning',
|
||||
enabled: editing.enabled,
|
||||
});
|
||||
} else {
|
||||
const first = metrics[0];
|
||||
setForm({
|
||||
name: '', metric: first?.key || '', operator: first?.default_op || '>',
|
||||
threshold: String(first?.default_warn ?? 0), severity: 'warning', enabled: true,
|
||||
});
|
||||
}
|
||||
}, [open, editing, metrics]);
|
||||
|
||||
const selectedMetric = metrics.find(m => m.key === form.metric);
|
||||
|
||||
const handleMetricChange = (key: string) => {
|
||||
const m = metrics.find(x => x.key === key);
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
metric: key,
|
||||
operator: m?.default_op || prev.operator,
|
||||
threshold: String(m?.default_warn ?? prev.threshold),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim() || !form.metric) {
|
||||
toast.error('请填写规则名称和指标');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
category, name: form.name.trim(), metric: form.metric,
|
||||
operator: form.operator, threshold: Number(form.threshold) || 0,
|
||||
severity: form.severity, enabled: form.enabled,
|
||||
};
|
||||
if (editing) {
|
||||
await alertMetricsApi.updateMonitorRule(editing.id, payload);
|
||||
toast.success('规则已更新');
|
||||
} else {
|
||||
await alertMetricsApi.createMonitorRule(payload);
|
||||
toast.success('规则已创建');
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
toast.error((e as { message?: string }).message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const catLabels: Record<string, string> = {
|
||||
middleware: '中间件', snmp: 'SNMP', site: '站点拨测',
|
||||
};
|
||||
const catLabel = catLabels[category] || category;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? '编辑' : '新建'}{catLabel}告警规则</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
基于监控中心采集指标配置阈值告警
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>规则名称</Label>
|
||||
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="例如:Redis 连接数过高" />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>监控指标</Label>
|
||||
<Select value={form.metric} onValueChange={handleMetricChange}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{metrics.map(m => (
|
||||
<SelectItem key={m.key} value={m.key}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedMetric && (
|
||||
<p className="text-[10px] text-muted-foreground">{selectedMetric.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>运算符</Label>
|
||||
<Select value={form.operator} onValueChange={v => setForm({ ...form, operator: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>阈值{selectedMetric?.unit ? ` (${selectedMetric.unit})` : ''}</Label>
|
||||
<Input type="number" value={form.threshold}
|
||||
min={selectedMetric?.min} max={selectedMetric?.max}
|
||||
onChange={e => setForm({ ...form, threshold: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>严重程度</Label>
|
||||
<Select value={form.severity} onValueChange={v => setForm({ ...form, severity: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEVERITY_OPTIONS.map(s => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.enabled} onCheckedChange={v => setForm({ ...form, enabled: v })} />
|
||||
<Label>启用规则</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />} 保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// 告警集合通知策略 — 四类监控绑定联系组 + 消息模版 // Logan.Li
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Server, Database, Globe, Network, Bell, Zap, Loader2, Save, FileText } from 'lucide-react';
|
||||
import { SilenceRulesPanel } from './SilenceRulesPanel';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { alertApi } from '@/api';
|
||||
import type { Contact, ContactGroup } from './alert-center-helpers';
|
||||
|
||||
export interface NotifyPolicy {
|
||||
id: number;
|
||||
category: 'host' | 'middleware' | 'site' | 'snmp';
|
||||
label?: string;
|
||||
enabled: boolean;
|
||||
contact_group_ids?: string;
|
||||
contact_ids?: string;
|
||||
notify_mode?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
import type { NotifyMessageSettings } from '@/api/monitor/alert';
|
||||
|
||||
type PolicyDraft = { enabled: boolean; groupIds: number[]; contactIds: number[] };
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'host' as const, label: '主机监控', desc: 'Agent 采集 CPU/内存/磁盘/离线等', icon: Server },
|
||||
{ id: 'middleware' as const, label: '中间件监控', desc: 'Redis/MySQL 等指标告警', icon: Database },
|
||||
{ id: 'site' as const, label: '站点拨测', desc: 'HTTP/TCP/DNS 拨测失败与延迟', icon: Globe },
|
||||
{ id: 'snmp' as const, label: 'SNMP 监控', desc: 'H3C/华为/Cisco/锐捷/Dell/浪潮', icon: Network },
|
||||
];
|
||||
|
||||
const DEFAULT_TITLE = '[{{severity_label}}] {{rule_name}} — {{hostname}} ({{metric}})';
|
||||
const DEFAULT_FOOTER = '{{time}} | {{platform}}';
|
||||
const DEFAULT_PLATFORM = 'AIOps 智能运维平台';
|
||||
|
||||
const TEMPLATE_VARS = [
|
||||
'{{time}}', '{{platform}}', '{{rule_name}}', '{{hostname}}',
|
||||
'{{metric}}', '{{severity}}', '{{severity_label}}',
|
||||
];
|
||||
|
||||
function parseIDs(raw?: string): number[] {
|
||||
if (!raw || raw === 'null') return [];
|
||||
try {
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? arr.map(Number).filter(n => !isNaN(n)) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function draftKey(d: PolicyDraft) {
|
||||
return `${d.enabled}|${d.groupIds.join(',')}|${d.contactIds.join(',')}`;
|
||||
}
|
||||
|
||||
export function NotifyPolicyTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [testCategory, setTestCategory] = useState('host');
|
||||
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [groups, setGroups] = useState<ContactGroup[]>([]);
|
||||
const [drafts, setDrafts] = useState<Record<string, PolicyDraft>>({});
|
||||
const [savedDraftKeys, setSavedDraftKeys] = useState<Record<string, string>>({});
|
||||
const [msgSettings, setMsgSettings] = useState<NotifyMessageSettings>({
|
||||
title_template: DEFAULT_TITLE,
|
||||
footer_template: DEFAULT_FOOTER,
|
||||
platform_name: DEFAULT_PLATFORM,
|
||||
});
|
||||
const [savedMsgSettings, setSavedMsgSettings] = useState<NotifyMessageSettings>({
|
||||
title_template: DEFAULT_TITLE,
|
||||
footer_template: DEFAULT_FOOTER,
|
||||
platform_name: DEFAULT_PLATFORM,
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [policyRes, contactRes, groupRes, msgRes] = await Promise.all([
|
||||
alertApi.getNotifyPolicies(),
|
||||
alertApi.getContacts(),
|
||||
alertApi.getContactGroups(),
|
||||
alertApi.getNotifyMessageSettings(),
|
||||
]);
|
||||
const list = (policyRes as { list?: NotifyPolicy[] })?.list || [];
|
||||
setContacts((contactRes as { list?: Contact[] })?.list || []);
|
||||
setGroups((groupRes as { list?: ContactGroup[] })?.list || []);
|
||||
|
||||
const next: Record<string, PolicyDraft> = {};
|
||||
const keys: Record<string, string> = {};
|
||||
for (const p of list) {
|
||||
const d = {
|
||||
enabled: p.enabled,
|
||||
groupIds: parseIDs(p.contact_group_ids),
|
||||
contactIds: parseIDs(p.contact_ids),
|
||||
};
|
||||
next[p.category] = d;
|
||||
keys[p.category] = draftKey(d);
|
||||
}
|
||||
for (const c of CATEGORIES) {
|
||||
if (!next[c.id]) {
|
||||
const d = { enabled: false, groupIds: [], contactIds: [] };
|
||||
next[c.id] = d;
|
||||
keys[c.id] = draftKey(d);
|
||||
}
|
||||
}
|
||||
setDrafts(next);
|
||||
setSavedDraftKeys(keys);
|
||||
|
||||
const msg = (msgRes || {}) as NotifyMessageSettings;
|
||||
const merged = {
|
||||
title_template: msg.title_template || DEFAULT_TITLE,
|
||||
footer_template: msg.footer_template || DEFAULT_FOOTER,
|
||||
platform_name: msg.platform_name || DEFAULT_PLATFORM,
|
||||
};
|
||||
setMsgSettings(merged);
|
||||
setSavedMsgSettings(merged);
|
||||
} catch {
|
||||
toast.error('加载通知策略失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const policiesDirty = useMemo(() =>
|
||||
CATEGORIES.some(c => draftKey(drafts[c.id] || { enabled: false, groupIds: [], contactIds: [] }) !== savedDraftKeys[c.id]),
|
||||
[drafts, savedDraftKeys]);
|
||||
|
||||
const templateDirty = useMemo(() =>
|
||||
msgSettings.title_template !== savedMsgSettings.title_template
|
||||
|| msgSettings.footer_template !== savedMsgSettings.footer_template
|
||||
|| msgSettings.platform_name !== savedMsgSettings.platform_name,
|
||||
[msgSettings, savedMsgSettings]);
|
||||
|
||||
const isDirty = policiesDirty || templateDirty;
|
||||
|
||||
const footerPreview = useMemo(() => {
|
||||
const now = new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
|
||||
return (msgSettings.footer_template || DEFAULT_FOOTER)
|
||||
.replace('{{time}}', now)
|
||||
.replace('{{platform}}', msgSettings.platform_name || DEFAULT_PLATFORM);
|
||||
}, [msgSettings]);
|
||||
|
||||
const toggleGroup = (cat: string, gid: number) => {
|
||||
setDrafts(prev => {
|
||||
const d = prev[cat] || { enabled: false, groupIds: [], contactIds: [] };
|
||||
const groupIds = d.groupIds.includes(gid) ? d.groupIds.filter(id => id !== gid) : [...d.groupIds, gid];
|
||||
return { ...prev, [cat]: { ...d, groupIds } };
|
||||
});
|
||||
};
|
||||
|
||||
const toggleContact = (cat: string, cid: number) => {
|
||||
setDrafts(prev => {
|
||||
const d = prev[cat] || { enabled: false, groupIds: [], contactIds: [] };
|
||||
const contactIds = d.contactIds.includes(cid) ? d.contactIds.filter(id => id !== cid) : [...d.contactIds, cid];
|
||||
return { ...prev, [cat]: { ...d, contactIds } };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
...CATEGORIES.map(c => {
|
||||
const d = drafts[c.id];
|
||||
if (!d) return Promise.resolve();
|
||||
return alertApi.upsertNotifyPolicy(c.id, {
|
||||
enabled: d.enabled,
|
||||
contact_group_ids: d.groupIds,
|
||||
contact_ids: d.contactIds,
|
||||
});
|
||||
}),
|
||||
alertApi.saveNotifyMessageSettings(msgSettings),
|
||||
]);
|
||||
toast.success('通知配置已保存');
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error((e as { message?: string }).message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (category: string) => {
|
||||
setTesting(category);
|
||||
try {
|
||||
await alertApi.testNotifyPolicy(category);
|
||||
toast.success('测试通知已发送');
|
||||
} catch (e) {
|
||||
toast.error((e as { message?: string }).message || '测试失败,请检查策略与联系人');
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <PageLoading label="加载通知策略..." />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 顶栏:说明 + 统一保存 / 测试 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] max-w-xl">
|
||||
按监控类型配置默认通知对象;消息模版作用于飞书卡片标题与底部署名。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={testCategory} onValueChange={setTestCategory}>
|
||||
<SelectTrigger className="h-8 w-[7.5rem] text-xs dg-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
{CATEGORIES.map(c => (
|
||||
<SelectItem key={c.id} value={c.id} className="text-xs">{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
disabled={testing !== null || !drafts[testCategory]?.enabled}
|
||||
onClick={() => handleTest(testCategory)}
|
||||
>
|
||||
{testing ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Zap className="h-3 w-3 mr-1" />}
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs dg-btn-primary"
|
||||
disabled={saving || !isDirty}
|
||||
onClick={handleSaveAll}
|
||||
>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
|
||||
保存全部
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 && contacts.length === 0 && (
|
||||
<div className="text-xs text-amber-500 bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2">
|
||||
请先在「报警联系人」和「报警联系组」中创建通知对象。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息模版 */}
|
||||
<Card className="border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-[var(--dg-blue)]" />
|
||||
消息通知模版
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">标题模版</Label>
|
||||
<Input
|
||||
className="dg-input text-xs"
|
||||
value={msgSettings.title_template}
|
||||
onChange={e => setMsgSettings(s => ({ ...s, title_template: e.target.value }))}
|
||||
placeholder={DEFAULT_TITLE}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">平台署名</Label>
|
||||
<Input
|
||||
className="dg-input text-xs"
|
||||
value={msgSettings.platform_name}
|
||||
onChange={e => setMsgSettings(s => ({ ...s, platform_name: e.target.value }))}
|
||||
placeholder={DEFAULT_PLATFORM}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">卡片底部</Label>
|
||||
<Input
|
||||
className="dg-input text-xs"
|
||||
value={msgSettings.footer_template}
|
||||
onChange={e => setMsgSettings(s => ({ ...s, footer_template: e.target.value }))}
|
||||
placeholder={DEFAULT_FOOTER}
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">
|
||||
预览:{footerPreview}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{TEMPLATE_VARS.map(v => (
|
||||
<Badge key={v} variant="outline" className="text-[10px] font-mono">{v}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 四类通知策略 */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{CATEGORIES.map(cat => {
|
||||
const Icon = cat.icon;
|
||||
const d = drafts[cat.id] || { enabled: false, groupIds: [], contactIds: [] };
|
||||
const memberCount = d.groupIds.length + d.contactIds.length;
|
||||
const catDirty = draftKey(d) !== savedDraftKeys[cat.id];
|
||||
return (
|
||||
<Card key={cat.id} className={`border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] ${catDirty ? 'ring-1 ring-[var(--dg-orange)]/40' : ''}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-primary/10">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
{cat.label}
|
||||
{catDirty && <span className="text-[10px] text-[var(--dg-orange)]">未保存</span>}
|
||||
</CardTitle>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{cat.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Switch
|
||||
checked={d.enabled}
|
||||
onCheckedChange={v => setDrafts(prev => ({
|
||||
...prev,
|
||||
[cat.id]: { ...d, enabled: v },
|
||||
}))}
|
||||
/>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)] min-w-[2rem]">
|
||||
{d.enabled ? '启用' : '停用'}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">通知联系组</Label>
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">暂无联系组</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{groups.map(g => (
|
||||
<Button
|
||||
key={g.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={d.groupIds.includes(g.id) ? 'default' : 'outline'}
|
||||
className="h-7 text-xs"
|
||||
onClick={() => toggleGroup(cat.id, g.id)}
|
||||
>
|
||||
{g.group_name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">额外联系人</Label>
|
||||
{contacts.length === 0 ? (
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">暂无联系人</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{contacts.map(c => (
|
||||
<Button
|
||||
key={c.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={d.contactIds.includes(c.id) ? 'default' : 'outline'}
|
||||
className="h-7 text-xs"
|
||||
onClick={() => toggleContact(cat.id, c.id)}
|
||||
>
|
||||
{c.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{memberCount > 0 && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
已选 {d.groupIds.length} 组 + {d.contactIds.length} 人
|
||||
</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-[10px] text-[var(--dg-text-muted)]">
|
||||
<Bell className="h-3 w-3" />
|
||||
未配置策略时将回退到「通知渠道」中的全局飞书渠道。
|
||||
</div>
|
||||
|
||||
<SilenceRulesPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// 告警静默规则面板 — 嵌入通知策略 Tab // Logan.Li
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { VolumeX, Plus, RefreshCw, Clock, Calendar, Repeat, Edit, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { alertCenterApi } from '@/api';
|
||||
import { SilenceDialog } from './SilenceDialog';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
const SILENCE_TYPE_LABELS: Record<string, string> = {
|
||||
onetime: '一次性',
|
||||
daily: '每天',
|
||||
weekly: '每周',
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
function formatTime(t: string | Date): string {
|
||||
if (!t) return '—';
|
||||
const d = typeof t === 'string' ? new Date(t) : t;
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function SilenceRulesPanel() {
|
||||
const confirm = useConfirm();
|
||||
const [silences, setSilences] = useState<JsonData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<JsonData>(null);
|
||||
|
||||
const fetchSilences = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: JsonData = await alertCenterApi.getSilences();
|
||||
const list = res?.list || res?.data?.list || [];
|
||||
setSilences(list);
|
||||
} catch {
|
||||
toast.error('获取静默规则失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchSilences(); }, [fetchSilences]);
|
||||
|
||||
const handleDelete = async (silence: JsonData) => {
|
||||
const ok = await confirm({
|
||||
title: '删除静默规则',
|
||||
description: `确定删除「${silence.name}」?`,
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await alertCenterApi.deleteSilence(silence.id);
|
||||
toast.success('已删除');
|
||||
fetchSilences();
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (silence: JsonData) => {
|
||||
try {
|
||||
await alertCenterApi.toggleSilence(silence.id);
|
||||
toast.success(silence.status === 1 ? '已禁用' : '已启用');
|
||||
fetchSilences();
|
||||
} catch {
|
||||
toast.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentlyActive = (s: JsonData): boolean => {
|
||||
if (s.is_active !== undefined) return s.is_active;
|
||||
if (s.status !== 1) return false;
|
||||
const now = new Date();
|
||||
return now >= new Date(s.start_time) && now <= new Date(s.end_time);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 border-t border-[var(--dg-border)] pt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<VolumeX className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">静默规则</h3>
|
||||
<span className="text-xs text-muted-foreground">维护窗口 / 免打扰时段</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={fetchSilences} disabled={loading}>
|
||||
<RefreshCw className={`w-3 h-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => { setEditing(null); setDialogOpen(true); }}>
|
||||
<Plus className="w-3 h-3 mr-1" />添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && silences.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-6">加载中...</p>
|
||||
) : silences.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
<p className="text-xs">暂无静默规则,告警将按通知策略正常发送</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{silences.map((s) => {
|
||||
const active = isCurrentlyActive(s);
|
||||
const enabled = s.status === 1;
|
||||
const sType = s.silence_type || 'onetime';
|
||||
const weekdayList: number[] = s.weekday_list || (() => {
|
||||
try { return JSON.parse(s.weekdays || '[]'); } catch { return []; }
|
||||
})();
|
||||
return (
|
||||
<Card key={s.id} className={active ? 'border-primary/50' : ''}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="font-medium text-xs">{s.name}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{SILENCE_TYPE_LABELS[sType] || sType}
|
||||
</Badge>
|
||||
{active && <Badge className="bg-green-500 text-[10px]">生效中</Badge>}
|
||||
{!enabled && <Badge variant="secondary" className="text-[10px]">已禁用</Badge>}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{sType === 'onetime'
|
||||
? `${formatTime(s.start_time)} — ${formatTime(s.end_time)}`
|
||||
: `每日 ${formatTime(s.start_time)} — ${formatTime(s.end_time)}`}
|
||||
{sType === 'weekly' && weekdayList.length > 0 && (
|
||||
<span className="ml-2">
|
||||
周{weekdayList.sort((a, b) => a - b).map((d) => WEEKDAY_LABELS[d]).join('')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Switch checked={enabled} onCheckedChange={() => handleToggle(s)} />
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => { setEditing(s); setDialogOpen(true); }}>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 dg-text-red" onClick={() => handleDelete(s)}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SilenceDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
editingSilence={editing}
|
||||
onSaved={fetchSilences}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,27 +76,35 @@ export interface ContactGroup {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
host: '主机监控', middleware: '中间件监控', site: '站点拨测', snmp: 'SNMP 监控', log: '日志告警',
|
||||
};
|
||||
|
||||
// Logan.Li — 后端字段映射到前端 Alert 接口
|
||||
export function mapAlert(a: JsonData): Alert {
|
||||
const sourceType = String(a.source_type || a.sourceType || '');
|
||||
return {
|
||||
id: String(a.id),
|
||||
title: a.rule_name || a.title || `告警 #${a.id}`,
|
||||
title: a.title || a.rule_name || `告警 #${a.id}`,
|
||||
description: a.message || a.description || '',
|
||||
severity: a.severity || 'info',
|
||||
status: a.status || 'firing',
|
||||
source: a.target || a.source || '',
|
||||
service: a.service || '',
|
||||
severity: (a.severity || 'info') as Alert['severity'],
|
||||
status: (a.status || 'firing') as Alert['status'],
|
||||
source: SOURCE_LABELS[sourceType] || a.target || a.source || '',
|
||||
service: a.target || a.service || '',
|
||||
metric: a.metric || '',
|
||||
value: a.value || 0,
|
||||
threshold: a.threshold || 0,
|
||||
startedAt: a.started_at || a.startedAt || a.created_at || new Date().toISOString(),
|
||||
resolvedAt: a.resolved_at || a.resolvedAt,
|
||||
labels: typeof a.labels === 'string'
|
||||
? (() => { try { return JSON.parse(a.labels || '{}'); } catch { return {}; } })()
|
||||
: (a.labels || {}),
|
||||
value: Number(a.value) || 0,
|
||||
threshold: Number(a.threshold) || 0,
|
||||
startedAt: String(a.started_at || a.startedAt || a.created_at || new Date().toISOString()),
|
||||
resolvedAt: a.resolved_at ? String(a.resolved_at) : (a.resolvedAt ? String(a.resolvedAt) : undefined),
|
||||
labels: {
|
||||
...(typeof a.labels === 'string'
|
||||
? (() => { try { return JSON.parse(a.labels || '{}'); } catch { return {}; } })()
|
||||
: (a.labels as Record<string, string> || {})),
|
||||
...(sourceType ? { source_type: sourceType } : {}),
|
||||
},
|
||||
annotations: typeof a.annotations === 'string'
|
||||
? (() => { try { return JSON.parse(a.annotations || '{}'); } catch { return {}; } })()
|
||||
: (a.annotations || {}),
|
||||
: (a.annotations as Record<string, string> || {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export function ServerManager() {
|
||||
return params;
|
||||
}, [page, pageSize, searchQuery, statusFilter]);
|
||||
|
||||
const { data, isLoading: loading, refetch } = useServers(queryParams);
|
||||
const { data, isLoading: loading, isFetching, refetch } = useServers(queryParams);
|
||||
const { createMutation, updateMutation, deleteMutation, batchDeleteMutation } = useServerMutations();
|
||||
const servers = data?.list ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
@@ -414,8 +414,8 @@ export function ServerManager() {
|
||||
metrics={<ServerStatsStrip servers={servers} />}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetch()}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={handleExport}>
|
||||
@@ -466,7 +466,6 @@ export function ServerManager() {
|
||||
selectedServers.length === servers.length && servers.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.hostname')}</TableHead>
|
||||
|
||||
@@ -60,8 +60,7 @@ export function ServerTableRow({
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={() => onToggleSelect(server.id)}
|
||||
className="border-[var(--dg-border)]"
|
||||
onCheckedChange={() => onToggleSelect(String(server.id))}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -166,8 +167,8 @@ export function CredentialManager() {
|
||||
)}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={fetchList}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />刷新
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={fetchList} disabled={loading}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />刷新
|
||||
</Button>
|
||||
<Button className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />添加凭据
|
||||
|
||||
@@ -57,7 +57,7 @@ export function LdapImportDialog({
|
||||
}: LdapImportDialogProps) {
|
||||
return (
|
||||
<Dialog open={importDialogOpen === 'ldap'} onOpenChange={(v) => !v && setImportDialogOpen(null)}>
|
||||
<DialogContent className="dg-card max-w-2xl">
|
||||
<DialogContent className="dg-card max-w-6xl w-[calc(100vw-2rem)] max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Network className="h-5 w-5 dg-text-cyan" />
|
||||
@@ -70,7 +70,7 @@ export function LdapImportDialog({
|
||||
|
||||
{!importResult ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs dg-text-muted whitespace-nowrap">选择 OU</span>
|
||||
@@ -99,10 +99,10 @@ export function LdapImportDialog({
|
||||
</p>
|
||||
|
||||
{ldapUsers.length > 0 && (
|
||||
<div className="max-h-80 overflow-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<Table>
|
||||
<div className="flex-1 min-h-0 max-h-[min(28rem,50vh)] overflow-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<Table className="table-fixed w-full min-w-[720px]">
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] sticky top-0 bg-[var(--dg-bg-secondary)]">
|
||||
<TableRow className="border-[var(--dg-border)] sticky top-0 bg-[var(--dg-bg-secondary)] z-10">
|
||||
<TableHead className="w-10 dg-text-secondary text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -117,9 +117,9 @@ export function LdapImportDialog({
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs">用户名</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs">显示名</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs">邮箱</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[100px]">用户名</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[120px]">显示名</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[160px]">邮箱</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs">DN</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -142,7 +142,7 @@ export function LdapImportDialog({
|
||||
<TableCell className="text-[var(--dg-text-primary)] text-xs">{u.uid}</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-secondary)] text-xs">{u.cn}</TableCell>
|
||||
<TableCell className="dg-text-secondary text-xs">{u.mail}</TableCell>
|
||||
<TableCell className="dg-text-muted text-xs font-mono truncate max-w-[200px]">{u.dn}</TableCell>
|
||||
<TableCell className="dg-text-muted text-xs font-mono break-all whitespace-normal leading-relaxed" title={u.dn}>{u.dn}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -50,7 +50,7 @@ interface LdapManagementSectionProps {
|
||||
setSelectedLDAPUsers: (v: string[]) => void;
|
||||
loadLdapOUs: () => void;
|
||||
loadLdapOUUsers: (ouDn?: string) => void;
|
||||
handleLdapImport: () => void;
|
||||
handleLdapImport: (opts?: { importAll?: boolean }) => void | Promise<void>;
|
||||
importing: boolean;
|
||||
importFeedback: string | null;
|
||||
importResultOpen: boolean;
|
||||
@@ -309,7 +309,7 @@ export function LdapManagementSection(props: LdapManagementSectionProps) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-blue-600/50 dg-text-blue hover:bg-blue-600/10 ml-auto"
|
||||
onClick={handleLdapImport}
|
||||
onClick={() => handleLdapImport({ importAll: selectedLDAPUsers.length === 0 })}
|
||||
disabled={ldapUsers.length === 0 || importing}
|
||||
>
|
||||
{importing ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Download className="h-3.5 w-3.5 mr-1" />}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle, XCircle, Settings } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { CheckCircle, XCircle, Settings, Users, Shield } from 'lucide-react';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { userApi } from '@/api';
|
||||
import type { Role } from '@/types';
|
||||
@@ -12,6 +13,7 @@ import { SystemRolesTab } from './SystemRolesTab';
|
||||
import { SystemSettingsTab } from './SystemSettingsTab';
|
||||
import { ResetPasswordDialog } from './ResetPasswordDialog';
|
||||
import { useLdapManagement } from './useLdapManagement';
|
||||
import { useAuditSettings } from './useAuditSettings';
|
||||
import type { UserFormData } from './UserFormDialog';
|
||||
|
||||
// 后端用户数据转换为前端兼容格式
|
||||
@@ -40,20 +42,32 @@ export function SystemManager() {
|
||||
const confirm = useConfirm();
|
||||
const location = useLocation();
|
||||
// 侧边栏菜单路径映射到 Tab // Logan.Li
|
||||
const pathToTab: Record<string, string> = {
|
||||
// 侧边栏已有独立菜单项,按路由直接渲染对应页面,不再重复 Tab // Logan.Li
|
||||
const pathToTab: Record<string, 'users' | 'roles' | 'settings'> = {
|
||||
'/users': 'users',
|
||||
'/roles': 'roles',
|
||||
'/settings': 'settings',
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(pathToTab[location.pathname] || 'users');
|
||||
const activeTab = pathToTab[location.pathname] || 'users';
|
||||
|
||||
// URL 变化时同步 Tab(侧边栏菜单点击触发) // Logan.Li
|
||||
useEffect(() => {
|
||||
const tab = pathToTab[location.pathname];
|
||||
if (tab && tab !== activeTab) {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
const pageMeta: Record<string, { title: string; subtitle: string; icon: React.ReactNode }> = {
|
||||
users: {
|
||||
title: '用户管理',
|
||||
subtitle: '本地与 LDAP 用户账号、状态与角色分配',
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
},
|
||||
roles: {
|
||||
title: '角色权限',
|
||||
subtitle: '角色绑定权限码,同时约束前端菜单与后端 API',
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
},
|
||||
settings: {
|
||||
title: '系统设置',
|
||||
subtitle: '通用与审计、安全策略、LDAP 目录服务',
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
},
|
||||
};
|
||||
const meta = pageMeta[activeTab];
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -71,6 +85,7 @@ export function SystemManager() {
|
||||
const [importDialogOpen, setImportDialogOpen] = useState<'csv' | 'ldap' | null>(null);
|
||||
const [importPreview, setImportPreview] = useState<any[]>([]);
|
||||
const [importResult, setImportResult] = useState<any>(null);
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
@@ -88,6 +103,7 @@ export function SystemManager() {
|
||||
|
||||
// LDAP 状态全部由 hook 管理(在 loadUsers 之后调用避免 TDZ) // Logan.Li
|
||||
const ldap = useLdapManagement(loadUsers);
|
||||
const audit = useAuditSettings();
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
@@ -175,9 +191,30 @@ export function SystemManager() {
|
||||
if (!ok) return;
|
||||
try {
|
||||
await userApi.deleteUser(user.id);
|
||||
setSelectedUserIds((prev) => prev.filter((id) => id !== user.id));
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
toast.success('用户已删除');
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
const ok = await confirm({
|
||||
title: '批量删除用户',
|
||||
description: `确定删除选中的 ${selectedUserIds.length} 个用户吗?此操作不可恢复。`,
|
||||
confirmText: '批量删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await userApi.batchDelete(selectedUserIds) as { message?: string };
|
||||
toast.success(res?.message || `已删除 ${selectedUserIds.length} 个用户`);
|
||||
setSelectedUserIds([]);
|
||||
await loadUsers();
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : '批量删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,21 +268,13 @@ export function SystemManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const pageTabs = [
|
||||
{ id: 'users', label: '用户管理', badge: users.length || undefined },
|
||||
{ id: 'roles', label: '角色权限' },
|
||||
{ id: 'settings', label: '系统设置' },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="系统管理"
|
||||
subtitle="用户、权限与系统配置"
|
||||
icon={<Settings className="h-5 w-5" />}
|
||||
tabs={pageTabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
variant={activeTab === 'settings' ? 'config' : 'list'}
|
||||
maxWidth={activeTab === 'settings' ? 'xl' : 'full'}
|
||||
title={meta.title}
|
||||
subtitle={meta.subtitle}
|
||||
icon={meta.icon}
|
||||
>
|
||||
<div className="w-full">
|
||||
{activeTab === 'users' && (
|
||||
@@ -268,6 +297,9 @@ export function SystemManager() {
|
||||
openCreate={openCreate}
|
||||
openEdit={openEdit}
|
||||
handleDelete={handleDelete}
|
||||
handleBatchDelete={handleBatchDelete}
|
||||
selectedUserIds={selectedUserIds}
|
||||
setSelectedUserIds={setSelectedUserIds}
|
||||
onResetPassword={(u) => { setSelectedUser(null); setResetPasswordUser(u); }}
|
||||
importDialogOpen={importDialogOpen}
|
||||
setImportDialogOpen={setImportDialogOpen}
|
||||
@@ -286,7 +318,7 @@ export function SystemManager() {
|
||||
ldapImportOUDN={ldap.ldapImportOUDN}
|
||||
setLdapImportOUDN={ldap.setLdapImportOUDN}
|
||||
loadLdapUsers={ldap.loadLdapUsers}
|
||||
handleLdapImport={ldap.handleLdapImport}
|
||||
handleLdapImport={() => ldap.handleLdapImport({ setResult: setImportResult })}
|
||||
setLdapUsers={ldap.setLdapUsers}
|
||||
/>
|
||||
</div>
|
||||
@@ -300,7 +332,18 @@ export function SystemManager() {
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<SystemSettingsTab {...ldap.settingsTabProps} />
|
||||
<SystemSettingsTab
|
||||
{...ldap.settingsTabProps}
|
||||
auditRetentionDays={audit.retentionDays}
|
||||
setAuditRetentionDays={audit.setRetentionDays}
|
||||
auditRecordAll={audit.recordAll}
|
||||
setAuditRecordAll={audit.setRecordAll}
|
||||
auditLoading={audit.loading}
|
||||
auditApplying={audit.applying}
|
||||
handleAuditApply={audit.handleApply}
|
||||
auditLastCleanupText={audit.lastCleanupText}
|
||||
auditRetentionOptions={audit.retentionOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -185,6 +185,14 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-4 py-3 flex items-start gap-3">
|
||||
<ShieldCheck className="h-5 w-5 dg-text-blue shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-[var(--dg-text-secondary)] space-y-1">
|
||||
<p className="font-medium text-[var(--dg-text-primary)]">权限如何生效</p>
|
||||
<p>用户通过「用户管理」绑定角色;角色在此配置权限码。权限码同时作用于<strong className="text-[var(--dg-text-primary)]">后端 API 鉴权</strong>与<strong className="text-[var(--dg-text-primary)]">前端菜单/按钮显隐</strong>,修改后用户需重新登录生效。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{roles.length} 个角色</span>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -10,9 +12,18 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Lock, History, Key, Settings, Network, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { PageTabs } from '@/components/layout/PageTabs';
|
||||
import { LdapManagementSection } from './LdapManagementSection';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
// 系统设置子 Tab:通用 / 安全 / LDAP // Logan.Li
|
||||
const SETTINGS_TABS = [
|
||||
{ id: 'general', label: '通用' },
|
||||
{ id: 'security', label: '安全' },
|
||||
{ id: 'ldap', label: 'LDAP' },
|
||||
] as const;
|
||||
type SettingsSubTab = (typeof SETTINGS_TABS)[number]['id'];
|
||||
|
||||
export interface LdapFormData {
|
||||
ldap_host: string;
|
||||
ldap_port: number;
|
||||
@@ -24,6 +35,16 @@ export interface LdapFormData {
|
||||
}
|
||||
|
||||
interface SystemSettingsTabProps {
|
||||
// 审计策略 // Logan.Li
|
||||
auditRetentionDays: string;
|
||||
setAuditRetentionDays: (v: string) => void;
|
||||
auditRecordAll: boolean;
|
||||
setAuditRecordAll: (v: boolean) => void;
|
||||
auditLoading: boolean;
|
||||
auditApplying: boolean;
|
||||
handleAuditApply: () => void;
|
||||
auditLastCleanupText: string | null;
|
||||
auditRetentionOptions: number[];
|
||||
// LDAP config
|
||||
ldapConfig: unknown;
|
||||
ldapForm: LdapFormData;
|
||||
@@ -44,7 +65,7 @@ interface SystemSettingsTabProps {
|
||||
setSelectedLDAPUsers: (v: string[]) => void;
|
||||
loadLdapOUs: () => void;
|
||||
loadLdapOUUsers: (ouDn?: string) => void;
|
||||
handleLdapImport: () => void;
|
||||
handleLdapImport: (opts?: { importAll?: boolean }) => void | Promise<void>;
|
||||
importing: boolean;
|
||||
importFeedback: string | null;
|
||||
importResultOpen: boolean;
|
||||
@@ -79,6 +100,15 @@ interface SystemSettingsTabProps {
|
||||
|
||||
export function SystemSettingsTab(props: SystemSettingsTabProps) {
|
||||
const {
|
||||
auditRetentionDays,
|
||||
setAuditRetentionDays,
|
||||
auditRecordAll,
|
||||
setAuditRecordAll,
|
||||
auditLoading,
|
||||
auditApplying,
|
||||
handleAuditApply,
|
||||
auditLastCleanupText,
|
||||
auditRetentionOptions,
|
||||
ldapConfig,
|
||||
ldapForm,
|
||||
setLdapForm,
|
||||
@@ -89,43 +119,33 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
|
||||
handleLdapTest,
|
||||
} = props;
|
||||
|
||||
// 作为 SystemManager 子 Tab 渲染,外层已有 PageShell,此处仅输出配置内容 // Logan.Li
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto w-full space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="dg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Lock className="h-5 w-5 dg-text-blue" />
|
||||
安全设置
|
||||
</CardTitle>
|
||||
<CardDescription className="dg-text-muted">密码策略与登录安全</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">强制密码复杂度</p>
|
||||
<p className="text-xs dg-text-muted">要求密码包含大小写字母、数字和特殊字符</p>
|
||||
</div>
|
||||
<Switch defaultChecked className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">登录失败锁定</p>
|
||||
<p className="text-xs dg-text-muted">连续5次登录失败锁定账户30分钟</p>
|
||||
</div>
|
||||
<Switch defaultChecked className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">双因素认证</p>
|
||||
<p className="text-xs dg-text-muted">启用后需要手机验证码登录</p>
|
||||
</div>
|
||||
<Switch className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tabFromUrl = searchParams.get('tab') as SettingsSubTab | null;
|
||||
const [subTab, setSubTab] = useState<SettingsSubTab>(
|
||||
SETTINGS_TABS.some(t => t.id === tabFromUrl) ? tabFromUrl! : 'general',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabFromUrl && SETTINGS_TABS.some(t => t.id === tabFromUrl) && tabFromUrl !== subTab) {
|
||||
setSubTab(tabFromUrl);
|
||||
}
|
||||
}, [tabFromUrl, subTab]);
|
||||
|
||||
const changeSubTab = (id: string) => {
|
||||
const next = id as SettingsSubTab;
|
||||
setSubTab(next);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (next === 'general') params.delete('tab');
|
||||
else params.set('tab', next);
|
||||
setSearchParams(params, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<PageTabs items={[...SETTINGS_TABS]} value={subTab} onChange={changeSubTab} />
|
||||
|
||||
{subTab === 'general' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
|
||||
<Card className="dg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
@@ -140,60 +160,56 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">记录所有操作</p>
|
||||
<p className="text-xs dg-text-muted">记录用户的所有操作行为</p>
|
||||
</div>
|
||||
<Switch defaultChecked className="data-[state=checked]:bg-blue-600" />
|
||||
<Switch
|
||||
checked={auditRecordAll}
|
||||
onCheckedChange={setAuditRecordAll}
|
||||
disabled={auditLoading || auditApplying}
|
||||
className="data-[state=checked]:bg-blue-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">日志保留时间</p>
|
||||
<p className="text-xs dg-text-muted">操作日志保留180天</p>
|
||||
<p className="text-xs dg-text-muted">
|
||||
应用后立即清理超期记录;之后每天自动检查
|
||||
</p>
|
||||
{auditLastCleanupText && (
|
||||
<p className="text-xs dg-text-muted mt-1">{auditLastCleanupText}</p>
|
||||
)}
|
||||
</div>
|
||||
<Select defaultValue="180">
|
||||
<SelectTrigger className="w-32 h-8 dg-input">
|
||||
<Select
|
||||
value={auditRetentionDays}
|
||||
onValueChange={setAuditRetentionDays}
|
||||
disabled={auditLoading || auditApplying}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 dg-input shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
<SelectItem value="30">30天</SelectItem>
|
||||
<SelectItem value="90">90天</SelectItem>
|
||||
<SelectItem value="180">180天</SelectItem>
|
||||
<SelectItem value="365">1年</SelectItem>
|
||||
{auditRetentionOptions.map((d) => (
|
||||
<SelectItem key={d} value={String(d)}>
|
||||
{d}天
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Key className="h-5 w-5 dg-text-yellow" />
|
||||
会话设置
|
||||
</CardTitle>
|
||||
<CardDescription className="dg-text-muted">会话超时与Token配置</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">会话超时时间</p>
|
||||
<p className="text-xs dg-text-muted">用户无操作30分钟后自动登出</p>
|
||||
</div>
|
||||
<Select defaultValue="30">
|
||||
<SelectTrigger className="w-32 h-8 dg-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
<SelectItem value="15">15分钟</SelectItem>
|
||||
<SelectItem value="30">30分钟</SelectItem>
|
||||
<SelectItem value="60">1小时</SelectItem>
|
||||
<SelectItem value="120">2小时</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">单点登录</p>
|
||||
<p className="text-xs dg-text-muted">同一账号只能在一个设备登录</p>
|
||||
</div>
|
||||
<Switch className="data-[state=checked]:bg-blue-600" />
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="dg-btn-primary"
|
||||
onClick={handleAuditApply}
|
||||
disabled={auditLoading || auditApplying}
|
||||
>
|
||||
{auditApplying ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
应用中...
|
||||
</>
|
||||
) : (
|
||||
'应用'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -218,9 +234,84 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LDAP 配置 */}
|
||||
<Card className="dg-card mt-4">
|
||||
{subTab === 'security' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
|
||||
<Card className="dg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Lock className="h-5 w-5 dg-text-blue" />
|
||||
安全设置
|
||||
</CardTitle>
|
||||
<CardDescription className="dg-text-muted">密码策略与登录安全(规划中)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">强制密码复杂度</p>
|
||||
<p className="text-xs dg-text-muted">要求密码包含大小写字母、数字和特殊字符</p>
|
||||
</div>
|
||||
<Switch disabled className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">登录失败锁定</p>
|
||||
<p className="text-xs dg-text-muted">连续5次登录失败锁定账户30分钟</p>
|
||||
</div>
|
||||
<Switch disabled defaultChecked className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">双因素认证</p>
|
||||
<p className="text-xs dg-text-muted">启用后需要手机验证码登录</p>
|
||||
</div>
|
||||
<Switch disabled className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Key className="h-5 w-5 dg-text-yellow" />
|
||||
会话设置
|
||||
</CardTitle>
|
||||
<CardDescription className="dg-text-muted">会话超时与 Token 配置(规划中)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">会话超时时间</p>
|
||||
<p className="text-xs dg-text-muted">用户无操作30分钟后自动登出</p>
|
||||
</div>
|
||||
<Select defaultValue="30" disabled>
|
||||
<SelectTrigger className="w-32 h-8 dg-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
<SelectItem value="15">15分钟</SelectItem>
|
||||
<SelectItem value="30">30分钟</SelectItem>
|
||||
<SelectItem value="60">1小时</SelectItem>
|
||||
<SelectItem value="120">2小时</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">单点登录</p>
|
||||
<p className="text-xs dg-text-muted">同一账号只能在一个设备登录</p>
|
||||
</div>
|
||||
<Switch disabled className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subTab === 'ldap' && (
|
||||
<>
|
||||
<Card className="dg-card pt-2">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -233,7 +324,7 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs dg-text-muted">LDAP 服务器地址</label>
|
||||
<Input
|
||||
@@ -331,11 +422,8 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* LDAP 账号管理 - 可折叠 */}
|
||||
{ldapConfig && (
|
||||
<div className="mt-4">
|
||||
<LdapManagementSection {...props} />
|
||||
</div>
|
||||
{ldapConfig && <LdapManagementSection {...props} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Edit,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { UserFormDialog, type UserFormData } from './UserFormDialog';
|
||||
import { UserDetailDialog } from './UserDetailDialog';
|
||||
import { CsvImportDialog } from './CsvImportDialog';
|
||||
@@ -49,6 +50,9 @@ interface SystemUserTabProps {
|
||||
openCreate: () => void;
|
||||
openEdit: ( user: any) => void;
|
||||
handleDelete: ( user: any) => void;
|
||||
handleBatchDelete: () => void;
|
||||
selectedUserIds: number[];
|
||||
setSelectedUserIds: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
onResetPassword?: (user: any) => void;
|
||||
// CSV import
|
||||
importDialogOpen: 'csv' | 'ldap' | null;
|
||||
@@ -91,6 +95,9 @@ export function SystemUserTab({
|
||||
openCreate,
|
||||
openEdit,
|
||||
handleDelete,
|
||||
handleBatchDelete,
|
||||
selectedUserIds,
|
||||
setSelectedUserIds,
|
||||
onResetPassword,
|
||||
importDialogOpen,
|
||||
setImportDialogOpen,
|
||||
@@ -113,6 +120,19 @@ export function SystemUserTab({
|
||||
setLdapUsers,
|
||||
}: SystemUserTabProps) {
|
||||
const { hasPermission } = usePermission();
|
||||
const canManage = hasPermission('system:user');
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedUserIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const allSelected = filteredUsers.length > 0 && selectedUserIds.length === filteredUsers.length;
|
||||
const toggleSelectAll = () => {
|
||||
setSelectedUserIds(allSelected ? [] : filteredUsers.map((u) => u.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filters */}
|
||||
@@ -148,6 +168,23 @@ export function SystemUserTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManage && selectedUserIds.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-2.5 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||||
<span className="text-sm dg-text-secondary">
|
||||
已选择 {selectedUserIds.length} 项 / 共 {filteredUsers.length} 项
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => setSelectedUserIds([])}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="dg-btn-danger dg-btn-sm" onClick={handleBatchDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
@@ -155,6 +192,15 @@ export function SystemUserTab({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
{canManage && (
|
||||
<TableHead className="w-10 text-[var(--dg-text-secondary)]">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">用户信息</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">联系方式</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">角色</TableHead>
|
||||
@@ -167,7 +213,7 @@ export function SystemUserTab({
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<TableCell colSpan={canManage ? 8 : 7} className="text-center py-12">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="h-6 w-6 border-2 border-[var(--dg-border)] border-t-[var(--dg-blue)] rounded-full animate-spin" />
|
||||
</div>
|
||||
@@ -175,13 +221,22 @@ export function SystemUserTab({
|
||||
</TableRow>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={7} className="text-center py-12 text-sm text-[var(--dg-text-muted)]">
|
||||
<TableCell colSpan={canManage ? 8 : 7} className="text-center py-12 text-sm text-[var(--dg-text-muted)]">
|
||||
暂无用户数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<TableRow key={user.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]/50">
|
||||
{canManage && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onCheckedChange={() => toggleSelect(user.id)}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--dg-blue)] flex items-center justify-center text-white text-sm font-medium">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -39,7 +40,7 @@ function formatSize(bytes: number): string {
|
||||
*/
|
||||
export function TerminalSessionsTab() {
|
||||
const confirm = useConfirm();
|
||||
const { data, isLoading: loading, refetch } = useTerminalSessions({ page: 1, page_size: 100 });
|
||||
const { data, isLoading: loading, isFetching, refetch } = useTerminalSessions({ page: 1, page_size: 100 });
|
||||
const sessions = data?.list ?? [];
|
||||
const { deleteMutation, batchDeleteMutation } = useTerminalSessionMutations();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
@@ -175,8 +176,9 @@ export function TerminalSessionsTab() {
|
||||
variant="outline"
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
{selected.size > 0 && (
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// 审计策略配置 — 保留天数与应用后立即清理 // Logan.Li
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { auditSettingsApi, type AuditSettings } from '@/api';
|
||||
|
||||
const RETENTION_OPTIONS = [7, 15, 30, 90, 180, 365] as const;
|
||||
|
||||
export function useAuditSettings() {
|
||||
const [settings, setSettings] = useState<AuditSettings | null>(null);
|
||||
const [retentionDays, setRetentionDays] = useState('180');
|
||||
const [recordAll, setRecordAll] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await auditSettingsApi.get();
|
||||
setSettings(data);
|
||||
setRetentionDays(String(data.retention_days || 180));
|
||||
setRecordAll(data.record_all !== false);
|
||||
} catch {
|
||||
toast.error('加载审计设置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleApply = async () => {
|
||||
const days = Number(retentionDays);
|
||||
if (!RETENTION_OPTIONS.includes(days as (typeof RETENTION_OPTIONS)[number])) {
|
||||
toast.error('请选择有效的保留天数');
|
||||
return;
|
||||
}
|
||||
setApplying(true);
|
||||
try {
|
||||
const res = await auditSettingsApi.apply({ retention_days: days, record_all: recordAll });
|
||||
setSettings({
|
||||
retention_days: res.retention_days ?? days,
|
||||
record_all: res.record_all ?? recordAll,
|
||||
last_cleanup_at: res.last_cleanup_at,
|
||||
last_cleanup_summary: res.last_cleanup_summary,
|
||||
});
|
||||
const total = res.cleanup?.total ?? 0;
|
||||
toast.success(
|
||||
total > 0
|
||||
? `已应用 ${days} 天保留策略,立即清理 ${total} 条超期记录`
|
||||
: `已应用 ${days} 天保留策略,暂无超期记录需清理`,
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '应用失败');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lastCleanupText = (() => {
|
||||
if (!settings?.last_cleanup_at) return null;
|
||||
const t = new Date(settings.last_cleanup_at).toLocaleString('zh-CN', { hour12: false });
|
||||
let detail = '';
|
||||
if (settings.last_cleanup_summary) {
|
||||
try {
|
||||
const s = JSON.parse(settings.last_cleanup_summary) as { total?: number };
|
||||
if (typeof s.total === 'number') {
|
||||
detail = s.total > 0 ? `,清理 ${s.total} 条` : ',无超期记录';
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return `上次清理:${t}${detail}`;
|
||||
})();
|
||||
|
||||
return {
|
||||
retentionDays,
|
||||
setRetentionDays,
|
||||
recordAll,
|
||||
setRecordAll,
|
||||
loading,
|
||||
applying,
|
||||
handleApply,
|
||||
lastCleanupText,
|
||||
retentionOptions: settings?.allowed_retention_days ?? [...RETENTION_OPTIONS],
|
||||
};
|
||||
}
|
||||
@@ -220,30 +220,57 @@ export function useLdapManagement(onUsersChanged: () => Promise<void> | void) {
|
||||
}
|
||||
};
|
||||
|
||||
/** 执行 LDAP 批量导入 // Logan.Li */
|
||||
const handleLdapImport = async () => {
|
||||
let selected: Record<string, unknown>[] = [];
|
||||
if (selectedLDAPUsers.length > 0) {
|
||||
/** 执行 LDAP 批量导入;用户管理弹窗用 ldapSelected,设置页可传 importAll 导入全部 // Logan.Li */
|
||||
const handleLdapImport = async (opts?: {
|
||||
importAll?: boolean;
|
||||
setResult?: (r: JsonData) => void;
|
||||
}) => {
|
||||
let selected: JsonData[] = [];
|
||||
if (ldapSelected.size > 0) {
|
||||
selected = ldapUsers.filter((u) => ldapSelected.has(u.dn));
|
||||
} else if (selectedLDAPUsers.length > 0) {
|
||||
selected = ldapUsers.filter((u) => selectedLDAPUsers.includes(u.dn));
|
||||
} else if (opts?.importAll) {
|
||||
selected = ldapUsers;
|
||||
} else {
|
||||
selected = ldapUsers; // 没有选择则导入全部
|
||||
toast.error('请至少选择一个用户');
|
||||
return;
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
toast.error('没有可导入的用户');
|
||||
return;
|
||||
}
|
||||
if (selected.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const mapped = selected.map((u) => ({
|
||||
uid: u.uid || '',
|
||||
cn: u.cn || '',
|
||||
mail: u.mail || '',
|
||||
username: u.uid || '',
|
||||
nickname: u.cn || '',
|
||||
email: u.mail || '',
|
||||
}));
|
||||
const res = await ldapApi.batchImport(mapped);
|
||||
setImportFeedback(res?.message || `成功导入 ${selected.length} 个用户`);
|
||||
setImportResultOpen(true);
|
||||
const message = (res as JsonData)?.message || `成功导入 ${selected.length} 个用户`;
|
||||
const result = { code: 200, message };
|
||||
if (opts?.setResult) {
|
||||
opts.setResult(result);
|
||||
} else {
|
||||
setImportFeedback(message);
|
||||
setImportResultOpen(true);
|
||||
}
|
||||
setLdapSelected(new Set());
|
||||
setSelectedLDAPUsers([]);
|
||||
await onUsersChanged();
|
||||
} catch (error) {
|
||||
setImportFeedback('导入失败: ' + (error?.message || '未知错误'));
|
||||
setImportResultOpen(true);
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : '未知错误';
|
||||
const result = { code: 500, message: '导入失败: ' + errMsg };
|
||||
if (opts?.setResult) {
|
||||
opts.setResult(result);
|
||||
} else {
|
||||
setImportFeedback(result.message);
|
||||
setImportResultOpen(true);
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
|
||||
@@ -25,11 +25,20 @@ import type { JsonData } from '@/types/common';
|
||||
|
||||
// 定时任务类型定义 // Logan.Li
|
||||
const jobTypes = [
|
||||
{ value: 'shell', label: 'Shell 脚本', desc: '关联脚本库中的 Shell 脚本' },
|
||||
{ value: 'python', label: 'Python 脚本', desc: '关联脚本库中的 Python 脚本' },
|
||||
{ value: 'command', label: '自定义命令', desc: '手动输入命令或脚本路径' },
|
||||
{ value: 'shell', label: 'Shell', desc: '关联脚本库中的 Shell 脚本' },
|
||||
{ value: 'python', label: 'Python', desc: '关联脚本库中的 Python 脚本' },
|
||||
{ value: 'powershell', label: 'PowerShell', desc: '关联脚本库中的 PowerShell 脚本' },
|
||||
{ value: 'command', label: '自定义命令', desc: '手动输入命令或脚本路径' },
|
||||
];
|
||||
|
||||
const scriptJobTypes = ['shell', 'python', 'powershell'];
|
||||
|
||||
function scriptTypeLabel(type: string) {
|
||||
if (type === 'python') return 'Python';
|
||||
if (type === 'powershell') return 'PowerShell';
|
||||
return 'Shell';
|
||||
}
|
||||
|
||||
export function CronJobFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -84,8 +93,8 @@ export function CronJobFormDialog({
|
||||
}
|
||||
}, [open, editJob]);
|
||||
|
||||
// 按类型过滤脚本库
|
||||
const filteredScripts = form.type === 'shell' || form.type === 'python'
|
||||
// 按类型过滤脚本库 // Logan.Li
|
||||
const filteredScripts = scriptJobTypes.includes(form.type)
|
||||
? scripts.filter(s => s.type === form.type)
|
||||
: [];
|
||||
|
||||
@@ -114,8 +123,8 @@ export function CronJobFormDialog({
|
||||
toast.error('请填写任务名称和 Cron 表达式');
|
||||
return;
|
||||
}
|
||||
// shell/python 类型必须选脚本
|
||||
if ((form.type === 'shell' || form.type === 'python') && !form.script_id) {
|
||||
// 脚本类型必须选关联脚本 // Logan.Li
|
||||
if (scriptJobTypes.includes(form.type) && !form.script_id) {
|
||||
toast.error('请选择关联的脚本');
|
||||
return;
|
||||
}
|
||||
@@ -206,7 +215,7 @@ export function CronJobFormDialog({
|
||||
{/* 执行类型 */}
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-primary)]">执行类型</Label>
|
||||
<div className="grid grid-cols-3 gap-2 mt-1">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-1">
|
||||
{jobTypes.map(t => (
|
||||
<button
|
||||
key={t.value}
|
||||
@@ -227,8 +236,8 @@ export function CronJobFormDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 脚本选择 (shell/python) 或 命令输入 (command) */}
|
||||
{form.type === 'shell' || form.type === 'python' ? (
|
||||
{/* 脚本选择 (shell/python/powershell) 或 命令输入 (command) */}
|
||||
{scriptJobTypes.includes(form.type) ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-primary)]">
|
||||
@@ -237,10 +246,10 @@ export function CronJobFormDialog({
|
||||
{filteredScripts.length === 0 ? (
|
||||
<div className="mt-1 px-3 py-4 rounded-md border border-dashed border-[var(--dg-border)] text-center">
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">
|
||||
脚本库中没有 {form.type === 'shell' ? 'Shell' : 'Python'} 脚本
|
||||
脚本库中没有 {scriptTypeLabel(form.type)} 脚本
|
||||
</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">
|
||||
请先到「脚本库」创建 {form.type === 'shell' ? 'Shell' : 'Python'} 脚本
|
||||
请先到「脚本库」创建 {scriptTypeLabel(form.type)} 脚本
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -267,7 +276,7 @@ export function CronJobFormDialog({
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{selectedScript.type === 'shell' ? 'Shell' : 'Python'}
|
||||
{scriptTypeLabel(selectedScript.type || form.type)}
|
||||
</Badge>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
{(selectedScript.content || '').split('\n').length} 行
|
||||
|
||||
@@ -1,67 +1,83 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { CronJobStatsPanel } from './CronJobStatsPanel';
|
||||
import { CronJobTable } from './CronJobTable';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
Clock,
|
||||
Pause,
|
||||
Edit,
|
||||
Trash2,
|
||||
History,
|
||||
Loader2,
|
||||
Terminal,
|
||||
FileCode,
|
||||
RefreshCw,
|
||||
PackageOpen,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Play, Plus, Search, Clock, Trash2, History, RefreshCw, PackageOpen,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cronJobApi } from '@/api';
|
||||
import { cronJobApi, scriptApi, systemTaskApi } from '@/api';
|
||||
import type { ScriptRuntimeInfo } from '@/api/task/script';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { getStatusBadge, parseCron } from './cronJobHelpers';
|
||||
import { ScriptRuntimeBanner } from './ScriptRuntimeBanner';
|
||||
import { CronJobFormDialog } from './CronJobFormDialog';
|
||||
import { ExecutionHistoryDialog } from './ExecutionHistoryDialog';
|
||||
import { CronJobViewDialog } from './CronJobViewDialog';
|
||||
import { SystemTasksPanel } from './SystemTasksPanel';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import type { CronJobRecord } from '@/types';
|
||||
|
||||
type StatusFilter = 'all' | 'enabled' | 'disabled';
|
||||
type HealthFilter = 'all' | 'healthy' | 'failed';
|
||||
type TypeFilter = 'all' | 'shell' | 'python' | 'powershell' | 'command';
|
||||
|
||||
export function CronJobManager() {
|
||||
const { hasAnyPermission, isAdmin } = usePermission();
|
||||
const canWrite = isAdmin || hasAnyPermission(['auto:script', 'auto:cron', 'auto:ansible']);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [healthFilter, setHealthFilter] = useState<HealthFilter>('all');
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||
const [runtime, setRuntime] = useState<ScriptRuntimeInfo | null>(null);
|
||||
const [runtimeLoading, setRuntimeLoading] = useState(true);
|
||||
const [jobs, setJobs] = useState<CronJobRecord[]>([]);
|
||||
const [systemTaskCount, setSystemTaskCount] = useState(0);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [isFormDialogOpen, setIsFormDialogOpen] = useState(false);
|
||||
const [isLogDialogOpen, setIsLogDialogOpen] = useState(false);
|
||||
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
|
||||
const [viewJob, setViewJob] = useState<CronJobRecord | null>(null);
|
||||
const [historyJob, setHistoryJob] = useState<CronJobRecord | null>(null);
|
||||
const [executingId, setExecutingId] = useState<number | null>(null);
|
||||
// editJob=null 表示新建模式;非 null 表示编辑已有任务 // Logan.Li
|
||||
const [editJob, setEditJob] = useState<CronJobRecord | null>(null);
|
||||
// 批量选择(批量删除) // Logan.Li
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('user');
|
||||
|
||||
const _confirm = useConfirm();
|
||||
|
||||
const pageTabs = useMemo(() => [
|
||||
{ id: 'user', label: '用户任务', badge: jobs.length || undefined },
|
||||
{ id: 'system', label: '系统任务', badge: systemTaskCount || undefined },
|
||||
], [jobs.length, systemTaskCount]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditJob(null);
|
||||
setIsFormDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = ( job: CronJobRecord) => {
|
||||
const openEdit = (job: CronJobRecord) => {
|
||||
setEditJob(job);
|
||||
setIsFormDialogOpen(true);
|
||||
};
|
||||
|
||||
// 加载任务列表(双 loading state:首次全屏 spinner,手动刷新只 spin icon) // Logan.Li
|
||||
const refreshData = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const data = await cronJobApi.getJobs() as { list?: CronJobRecord[] };
|
||||
setJobs(data.list || []);
|
||||
const [jobRes, sysRes] = await Promise.all([
|
||||
cronJobApi.getJobs() as Promise<{ list?: CronJobRecord[] }>,
|
||||
systemTaskApi.list().catch(() => ({ list: [] })),
|
||||
]);
|
||||
setJobs(jobRes.list || []);
|
||||
setSystemTaskCount(sysRes.list?.length ?? 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to load cron jobs:', error);
|
||||
toast.error('加载失败');
|
||||
@@ -73,15 +89,35 @@ export function CronJobManager() {
|
||||
|
||||
useEffect(() => {
|
||||
refreshData();
|
||||
scriptApi.getRuntime()
|
||||
.then(setRuntime)
|
||||
.catch(() => {})
|
||||
.finally(() => setRuntimeLoading(false));
|
||||
}, [refreshData]);
|
||||
|
||||
const filteredJobs = jobs.filter(
|
||||
(job) =>
|
||||
(job.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(job.description || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const filteredJobs = useMemo(() => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return jobs.filter((job) => {
|
||||
const matchSearch =
|
||||
(job.name || '').toLowerCase().includes(q) ||
|
||||
(job.description || '').toLowerCase().includes(q) ||
|
||||
(job.script?.name || '').toLowerCase().includes(q);
|
||||
const matchStatus =
|
||||
statusFilter === 'all' ||
|
||||
(statusFilter === 'enabled' && job.enabled) ||
|
||||
(statusFilter === 'disabled' && !job.enabled);
|
||||
const matchHealth =
|
||||
healthFilter === 'all' ||
|
||||
(healthFilter === 'failed' && job.last_status === 'failed') ||
|
||||
(healthFilter === 'healthy' && job.last_status !== 'failed');
|
||||
const matchType = typeFilter === 'all' || job.type === typeFilter;
|
||||
return matchSearch && matchStatus && matchHealth && matchType;
|
||||
});
|
||||
}, [jobs, searchQuery, statusFilter, healthFilter, typeFilter]);
|
||||
|
||||
const handleToggleJob = async ( id: number) => {
|
||||
const allSelected = filteredJobs.length > 0 && selectedIds.length === filteredJobs.length;
|
||||
|
||||
const handleToggleJob = async (id: number) => {
|
||||
try {
|
||||
await cronJobApi.toggleJob(id);
|
||||
toast.success('状态已切换');
|
||||
@@ -92,7 +128,7 @@ export function CronJobManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteJob = async ( id: number) => {
|
||||
const handleExecuteJob = async (id: number) => {
|
||||
setExecutingId(id);
|
||||
try {
|
||||
const result = await cronJobApi.executeJob(id);
|
||||
@@ -111,8 +147,7 @@ export function CronJobManager() {
|
||||
}
|
||||
};
|
||||
|
||||
// 删除任务(带确认框) // Logan.Li
|
||||
const handleDeleteJob = async ( job: CronJobRecord) => {
|
||||
const handleDeleteJob = async (job: CronJobRecord) => {
|
||||
const ok = await _confirm({
|
||||
title: '删除定时任务',
|
||||
description: `确定删除「${job.name}」吗?此操作不可恢复。`,
|
||||
@@ -129,22 +164,16 @@ export function CronJobManager() {
|
||||
}
|
||||
};
|
||||
|
||||
// 批量选择相关 // Logan.Li
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.length === filteredJobs.length && filteredJobs.length > 0) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(filteredJobs.map((j) => j.id));
|
||||
}
|
||||
setSelectedIds(allSelected ? [] : filteredJobs.map((j) => j.id));
|
||||
};
|
||||
|
||||
// 批量删除任务(带确认框) // Logan.Li
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const ok = await _confirm({
|
||||
@@ -165,25 +194,56 @@ export function CronJobManager() {
|
||||
}
|
||||
};
|
||||
|
||||
// 工具栏:搜索 + 全选 + 批量操作 // Logan.Li
|
||||
const hasActiveFilters = statusFilter !== 'all' || healthFilter !== 'all' || typeFilter !== 'all';
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap flex-1 min-w-[280px]">
|
||||
<div className="relative flex-1 min-w-[180px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
placeholder="搜索任务..."
|
||||
placeholder="搜索名称、描述、脚本…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 dg-input"
|
||||
className="pl-10 dg-input h-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<SelectTrigger className="w-[108px] dg-input h-9 text-xs">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="enabled">已启用</SelectItem>
|
||||
<SelectItem value="disabled">已禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={healthFilter} onValueChange={(v) => setHealthFilter(v as HealthFilter)}>
|
||||
<SelectTrigger className="w-[108px] dg-input h-9 text-xs">
|
||||
<SelectValue placeholder="健康" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部健康</SelectItem>
|
||||
<SelectItem value="healthy">正常</SelectItem>
|
||||
<SelectItem value="failed">最近失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as TypeFilter)}>
|
||||
<SelectTrigger className="w-[108px] dg-input h-9 text-xs">
|
||||
<SelectValue placeholder="类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="shell">Shell</SelectItem>
|
||||
<SelectItem value="python">Python</SelectItem>
|
||||
<SelectItem value="powershell">PowerShell</SelectItem>
|
||||
<SelectItem value="command">命令</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{filteredJobs.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm dg-text-secondary cursor-pointer select-none">
|
||||
<label className="flex items-center gap-1.5 text-xs dg-text-secondary cursor-pointer select-none">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === filteredJobs.length && filteredJobs.length > 0
|
||||
}
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
@@ -191,20 +251,22 @@ export function CronJobManager() {
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm dg-text-secondary">已选择 {selectedIds.length} 项</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="dg-btn-danger dg-btn-sm"
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hasActiveFilters && (
|
||||
<span className="text-xs dg-text-muted">
|
||||
筛选结果 {filteredJobs.length}/{jobs.length}
|
||||
</span>
|
||||
)}
|
||||
{selectedIds.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs dg-text-secondary">已选 {selectedIds.length}</span>
|
||||
<Button variant="outline" size="sm" className="dg-btn-danger h-8" onClick={handleBatchDelete}>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -213,30 +275,26 @@ export function CronJobManager() {
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="定时任务"
|
||||
subtitle="Cron 任务管理与执行"
|
||||
subtitle="Cron 调度 · 脚本执行 · 内置系统任务"
|
||||
icon={<Clock className="h-5 w-5" />}
|
||||
metrics={(
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '任务总数', value: jobs.length, color: 'blue' },
|
||||
{ label: '已启用', value: jobs.filter((j) => j.enabled).length, color: 'emerald' },
|
||||
{ label: '累计成功', value: jobs.reduce((sum, j) => sum + (j.success_count || 0), 0), color: 'emerald' },
|
||||
{ label: '累计失败', value: jobs.reduce((sum, j) => sum + (j.fail_count || 0), 0), color: 'red' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
toolbar={toolbar}
|
||||
loading={initialLoading}
|
||||
tabs={pageTabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
metrics={activeTab === 'user' ? <CronJobStatsPanel jobs={jobs} /> : undefined}
|
||||
toolbar={activeTab === 'user' ? toolbar : undefined}
|
||||
loading={activeTab === 'user' && initialLoading}
|
||||
actions={(
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="dg-btn-secondary"
|
||||
onClick={() => setIsLogDialogOpen(true)}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
执行历史
|
||||
</Button>
|
||||
{activeTab === 'user' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="dg-btn-secondary"
|
||||
onClick={() => { setHistoryJob(null); setIsLogDialogOpen(true); }}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
全部历史
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="dg-btn-secondary"
|
||||
@@ -246,136 +304,74 @@ export function CronJobManager() {
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建任务
|
||||
</Button>
|
||||
{activeTab === 'user' && canWrite && (
|
||||
<Button className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{filteredJobs.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<PackageOpen className="h-12 w-12" />}
|
||||
title="暂无定时任务"
|
||||
action={(
|
||||
<Button className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建任务
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
{activeTab === 'system' ? (
|
||||
<SystemTasksPanel refreshing={refreshing} />
|
||||
) : (
|
||||
<ContentPanel>
|
||||
<div className="space-y-3">
|
||||
{filteredJobs.map((job) => (
|
||||
<Card key={job.id} className="dg-card">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center pt-1">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(job.id)}
|
||||
onCheckedChange={() => toggleSelect(job.id)}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h3 className="font-medium text-[var(--dg-text-primary)]">{job.name}</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
job.enabled
|
||||
? 'border-emerald-500/30 dg-text-green bg-emerald-500/10'
|
||||
: 'border-[var(--dg-border)] text-[var(--dg-text-muted)]'
|
||||
}
|
||||
>
|
||||
{job.enabled ? '已启用' : '已禁用'}
|
||||
</Badge>
|
||||
{getStatusBadge(job.last_status)}
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary">
|
||||
{job.type === 'shell' ? 'Shell' : job.type === 'python' ? 'Python' : '命令'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm dg-text-secondary mt-1">{job.description || '—'}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm">
|
||||
<span className="flex items-center gap-1 dg-text-secondary">
|
||||
<Clock className="h-3 w-3" />
|
||||
{parseCron(job.cron_expr)}
|
||||
</span>
|
||||
{(job.type === 'shell' || job.type === 'python') && job.script?.name ? (
|
||||
<span className="flex items-center gap-1 dg-text-secondary">
|
||||
<FileCode className="h-3 w-3" />
|
||||
<span className="text-xs">{job.script.name}</span>
|
||||
</span>
|
||||
) : job.content && (
|
||||
<span className="flex items-center gap-1 dg-text-secondary">
|
||||
<Terminal className="h-3 w-3" />
|
||||
<code className="text-xs bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded truncate max-w-xs">
|
||||
{job.content}
|
||||
</code>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>上次执行: {job.last_run_at ? new Date(job.last_run_at).toLocaleString() : '--'}</span>
|
||||
<span>成功: {job.success_count || 0} / 失败: {job.fail_count || 0}</span>
|
||||
<span>累计: {job.run_count || 0} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-emerald-600 dg-text-green hover:bg-emerald-600/20"
|
||||
disabled={executingId === job.id}
|
||||
onClick={() => handleExecuteJob(job.id)}
|
||||
>
|
||||
{executingId === job.id ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
立即执行
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 dg-text-secondary hover:dg-text-blue"
|
||||
title={job.enabled ? '禁用' : '启用'}
|
||||
onClick={() => handleToggleJob(job.id)}
|
||||
>
|
||||
{job.enabled ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 dg-text-secondary hover:dg-text-blue"
|
||||
title="编辑"
|
||||
onClick={() => openEdit(job)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 dg-text-secondary hover:dg-text-red"
|
||||
title="删除"
|
||||
onClick={() => handleDeleteJob(job)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<ScriptRuntimeBanner runtime={runtime} loading={runtimeLoading} />
|
||||
</div>
|
||||
</ContentPanel>
|
||||
{jobs.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<PackageOpen className="h-12 w-12" />}
|
||||
title="暂无定时任务"
|
||||
action={canWrite ? (
|
||||
<Button className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建任务
|
||||
</Button>
|
||||
) : undefined}
|
||||
/>
|
||||
) : filteredJobs.length === 0 ? (
|
||||
<PageEmpty
|
||||
title="无匹配任务"
|
||||
action={(
|
||||
<Button
|
||||
variant="outline"
|
||||
className="dg-btn-secondary"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setStatusFilter('all');
|
||||
setHealthFilter('all');
|
||||
setTypeFilter('all');
|
||||
}}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ContentPanel>
|
||||
<CronJobTable
|
||||
jobs={filteredJobs}
|
||||
selectedIds={selectedIds}
|
||||
executingId={executingId}
|
||||
canWrite={canWrite}
|
||||
onToggleSelect={toggleSelect}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
allSelected={allSelected}
|
||||
onView={(job) => { setViewJob(job); setIsViewDialogOpen(true); }}
|
||||
onHistory={(job) => { setHistoryJob(job); setIsLogDialogOpen(true); }}
|
||||
onExecute={handleExecuteJob}
|
||||
onToggle={handleToggleJob}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDeleteJob}
|
||||
/>
|
||||
</ContentPanel>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageShell>
|
||||
|
||||
{/* 新建/编辑定时任务弹窗 */}
|
||||
<CronJobFormDialog
|
||||
open={isFormDialogOpen}
|
||||
onOpenChange={setIsFormDialogOpen}
|
||||
@@ -383,10 +379,20 @@ export function CronJobManager() {
|
||||
onSuccess={refreshData}
|
||||
/>
|
||||
|
||||
{/* 执行历史弹窗 */}
|
||||
<CronJobViewDialog
|
||||
open={isViewDialogOpen}
|
||||
onOpenChange={setIsViewDialogOpen}
|
||||
job={viewJob}
|
||||
/>
|
||||
|
||||
<ExecutionHistoryDialog
|
||||
open={isLogDialogOpen}
|
||||
onOpenChange={setIsLogDialogOpen}
|
||||
onOpenChange={(v) => {
|
||||
setIsLogDialogOpen(v);
|
||||
if (!v) setHistoryJob(null);
|
||||
}}
|
||||
taskId={historyJob?.id ?? null}
|
||||
taskName={historyJob?.name}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// 定时任务可视化统计面板 — 含健康度与整体成功率条 // Logan.Li
|
||||
import type { ReactNode } from 'react';
|
||||
import { Clock, CheckCircle, XCircle, Activity } from 'lucide-react';
|
||||
import { computeSuccessRate } from './cronJobHelpers';
|
||||
import type { CronJobRecord } from '@/types';
|
||||
|
||||
interface Props {
|
||||
jobs: CronJobRecord[];
|
||||
}
|
||||
|
||||
function RingProgress({ percent, color }: { percent: number; color: string }) {
|
||||
const r = 16;
|
||||
const c = 2 * Math.PI * r;
|
||||
const offset = c - (percent / 100) * c;
|
||||
return (
|
||||
<svg width="40" height="40" className="shrink-0 -rotate-90">
|
||||
<circle cx="20" cy="20" r={r} fill="none" stroke="var(--dg-border)" strokeWidth="3.5" />
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={c}
|
||||
strokeDashoffset={offset}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
sub?: string;
|
||||
icon: ReactNode;
|
||||
borderClass: string;
|
||||
bgClass: string;
|
||||
valueClass: string;
|
||||
ring?: { percent: number; color: string };
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub, icon, borderClass, bgClass, valueClass, ring }: StatCardProps) {
|
||||
return (
|
||||
<div className={`rounded-lg border ${borderClass} ${bgClass} px-3 py-2 flex items-center gap-2 min-w-0`}>
|
||||
{ring ? (
|
||||
<div className="relative shrink-0">
|
||||
<RingProgress percent={ring.percent} color={ring.color} />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[9px] font-semibold dg-text-secondary">
|
||||
{ring.percent}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0">{icon}</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className={`text-lg font-semibold leading-tight tabular-nums ${valueClass}`}>{value}</div>
|
||||
<div className="text-[11px] dg-text-secondary truncate">{label}</div>
|
||||
{sub && <div className="text-[10px] dg-text-muted mt-0.5 truncate">{sub}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CronJobStatsPanel({ jobs }: Props) {
|
||||
const total = jobs.length;
|
||||
const enabled = jobs.filter((j) => j.enabled).length;
|
||||
const success = jobs.reduce((sum, j) => sum + (j.success_count || 0), 0);
|
||||
const failed = jobs.reduce((sum, j) => sum + (j.fail_count || 0), 0);
|
||||
const totalRuns = success + failed;
|
||||
const successRate = computeSuccessRate(success, failed) ?? 0;
|
||||
const enabledRate = total > 0 ? Math.round((enabled / total) * 100) : 0;
|
||||
|
||||
// 健康度:最近执行成功或未执行的任务占比 // Logan.Li
|
||||
const healthy = jobs.filter((j) => j.last_status !== 'failed').length;
|
||||
const healthRate = total > 0 ? Math.round((healthy / total) * 100) : 100;
|
||||
const recentFailed = jobs.filter((j) => j.last_status === 'failed').length;
|
||||
|
||||
const overallTone = successRate >= 90 ? 'var(--dg-green)' : successRate >= 70 ? 'var(--dg-yellow)' : 'var(--dg-red)';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-2">
|
||||
<StatCard
|
||||
label="用户任务"
|
||||
value={total}
|
||||
sub={`${enabled} 个启用中`}
|
||||
icon={<Clock className="h-6 w-6 dg-text-blue" />}
|
||||
borderClass="border-blue-500/20"
|
||||
bgClass="bg-blue-500/5"
|
||||
valueClass="dg-text-blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="已启用"
|
||||
value={enabled}
|
||||
sub={`占比 ${enabledRate}%`}
|
||||
borderClass="border-emerald-500/20"
|
||||
bgClass="bg-emerald-500/5"
|
||||
valueClass="dg-text-green"
|
||||
ring={{ percent: enabledRate, color: 'var(--dg-green)' }}
|
||||
icon={<Activity className="h-6 w-6 dg-text-green" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="任务健康"
|
||||
value={`${healthRate}%`}
|
||||
sub={recentFailed > 0 ? `${recentFailed} 个最近失败` : '全部正常'}
|
||||
borderClass={recentFailed > 0 ? 'border-amber-500/20' : 'border-emerald-500/20'}
|
||||
bgClass={recentFailed > 0 ? 'bg-amber-500/5' : 'bg-emerald-500/5'}
|
||||
valueClass={recentFailed > 0 ? 'dg-text-yellow' : 'dg-text-green'}
|
||||
ring={{ percent: healthRate, color: recentFailed > 0 ? 'var(--dg-yellow)' : 'var(--dg-green)' }}
|
||||
icon={<Activity className="h-6 w-6" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="累计成功"
|
||||
value={success}
|
||||
sub={totalRuns > 0 ? `成功率 ${successRate}%` : '暂无执行'}
|
||||
icon={<CheckCircle className="h-6 w-6 text-emerald-500" />}
|
||||
borderClass="border-emerald-500/20"
|
||||
bgClass="bg-emerald-500/5"
|
||||
valueClass="text-emerald-500"
|
||||
/>
|
||||
<StatCard
|
||||
label="累计失败"
|
||||
value={failed}
|
||||
sub={totalRuns > 0 ? `共 ${totalRuns} 次执行` : undefined}
|
||||
icon={<XCircle className="h-6 w-6 text-red-500" />}
|
||||
borderClass="border-red-500/20"
|
||||
bgClass="bg-red-500/5"
|
||||
valueClass="text-red-500"
|
||||
/>
|
||||
</div>
|
||||
{totalRuns > 0 && (
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<span className="text-[11px] dg-text-muted shrink-0">整体成功率</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-border)] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${successRate}%`, backgroundColor: overallTone }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] tabular-nums dg-text-secondary w-10 text-right">{successRate}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// 定时任务紧凑表格 — 对齐脚本库 ScriptTable 风格 // Logan.Li
|
||||
import {
|
||||
Play, Pause, Edit, Trash2, History, Loader2, FileCode,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
getStatusBadge, getEnabledBadge, getJobTypeBadge,
|
||||
parseCron, formatRelativeTime, SuccessRateBar,
|
||||
} from './cronJobHelpers';
|
||||
import type { CronJobRecord } from '@/types';
|
||||
|
||||
interface Props {
|
||||
jobs: CronJobRecord[];
|
||||
selectedIds: number[];
|
||||
executingId: number | null;
|
||||
canWrite: boolean;
|
||||
onToggleSelect: (id: number) => void;
|
||||
onToggleSelectAll: () => void;
|
||||
allSelected: boolean;
|
||||
onView: (job: CronJobRecord) => void;
|
||||
onHistory: (job: CronJobRecord) => void;
|
||||
onExecute: (id: number) => void;
|
||||
onToggle: (id: number) => void;
|
||||
onEdit: (job: CronJobRecord) => void;
|
||||
onDelete: (job: CronJobRecord) => void;
|
||||
}
|
||||
|
||||
export function CronJobTable({
|
||||
jobs, selectedIds, executingId, canWrite,
|
||||
onToggleSelect, onToggleSelectAll, allSelected,
|
||||
onView, onHistory, onExecute, onToggle, onEdit, onDelete,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--dg-border)] overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] hover:bg-[var(--dg-bg-secondary)]">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs min-w-[140px]">任务</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[120px] hidden lg:table-cell">调度</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[88px]">类型</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[72px] hidden sm:table-cell">开关</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[72px]">最近</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[100px] hidden md:table-cell">上次执行</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[96px] hidden md:table-cell">成功率</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[148px] text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map((job) => {
|
||||
const cronExpr = job.cron_expr || job.cron || '';
|
||||
const isExecuting = executingId === job.id;
|
||||
const scriptName = job.script?.name;
|
||||
return (
|
||||
<TableRow
|
||||
key={job.id}
|
||||
className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]/50"
|
||||
>
|
||||
<TableCell className="py-2">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(job.id)}
|
||||
onCheckedChange={() => onToggleSelect(job.id)}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 max-w-[220px]">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium text-[var(--dg-text-primary)] hover:dg-text-blue text-left truncate block w-full transition-colors"
|
||||
onClick={() => onView(job)}
|
||||
title={job.name}
|
||||
>
|
||||
{job.name}
|
||||
</button>
|
||||
{job.description && (
|
||||
<p className="text-[11px] dg-text-muted truncate mt-0.5" title={job.description}>
|
||||
{job.description}
|
||||
</p>
|
||||
)}
|
||||
{scriptName && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[10px] dg-text-secondary hover:dg-text-blue mt-0.5 transition-colors"
|
||||
onClick={() => onView(job)}
|
||||
>
|
||||
<FileCode className="h-3 w-3" />
|
||||
{scriptName}
|
||||
</button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-2 hidden lg:table-cell">
|
||||
<span className="text-xs dg-text-secondary" title={cronExpr}>
|
||||
{parseCron(cronExpr)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">{getJobTypeBadge(job.type)}</TableCell>
|
||||
<TableCell className="py-2 hidden sm:table-cell">{getEnabledBadge(job.enabled)}</TableCell>
|
||||
<TableCell className="py-2">{getStatusBadge(job.last_status)}</TableCell>
|
||||
<TableCell className="py-2 hidden md:table-cell">
|
||||
<span className="text-xs dg-text-muted" title={job.last_run_at ? new Date(job.last_run_at).toLocaleString() : undefined}>
|
||||
{formatRelativeTime(job.last_run_at)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 hidden md:table-cell">
|
||||
<SuccessRateBar success={job.success_count} fail={job.fail_count} />
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 dg-text-green hover:bg-emerald-500/10"
|
||||
title="立即执行"
|
||||
disabled={isExecuting}
|
||||
onClick={() => onExecute(job.id)}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title="执行历史"
|
||||
onClick={() => onHistory(job)}
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title={job.enabled ? '禁用' : '启用'}
|
||||
onClick={() => onToggle(job.id)}
|
||||
>
|
||||
{job.enabled ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title="编辑"
|
||||
onClick={() => onEdit(job)}
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canWrite && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title="删除"
|
||||
onClick={() => onDelete(job)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 dg-text-red" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 定时任务脚本/命令只读查看弹窗 — 含运行元数据 // Logan.Li
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScriptCodeEditor } from './ScriptCodeEditor';
|
||||
import {
|
||||
getJobTypeBadge, getEnabledBadge, getStatusBadge,
|
||||
parseCron, formatRelativeTime, SuccessRateBar, computeSuccessRate,
|
||||
} from './cronJobHelpers';
|
||||
import type { CronJobRecord } from '@/types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
job: CronJobRecord | null;
|
||||
}
|
||||
|
||||
function asText(v: unknown): string {
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
export function CronJobViewDialog({ open, onOpenChange, job }: Props) {
|
||||
if (!job) return null;
|
||||
|
||||
const isScriptType = job.type === 'shell' || job.type === 'python' || job.type === 'powershell';
|
||||
const scriptContent = asText(job.script?.content);
|
||||
const jobContent = asText(job.content);
|
||||
const content = isScriptType ? (scriptContent || jobContent) : jobContent;
|
||||
const scriptType = job.type || 'shell';
|
||||
const cronExpr = job.cron_expr || job.cron || '';
|
||||
const rate = computeSuccessRate(job.success_count, job.fail_count);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl w-[min(92vw,56rem)] bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate pr-6">{job.name}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
只读预览 · {parseCron(cronExpr)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{getJobTypeBadge(job.type)}
|
||||
{getEnabledBadge(job.enabled)}
|
||||
{getStatusBadge(job.last_status)}
|
||||
<span className="text-xs dg-text-muted font-mono">{cronExpr}</span>
|
||||
</div>
|
||||
|
||||
{job.description && (
|
||||
<p className="text-sm dg-text-secondary">{job.description}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2.5 text-xs">
|
||||
<div>
|
||||
<div className="dg-text-muted mb-0.5">上次执行</div>
|
||||
<div className="text-[var(--dg-text-primary)]">{formatRelativeTime(job.last_run_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dg-text-muted mb-0.5">累计执行</div>
|
||||
<div className="text-[var(--dg-text-primary)] tabular-nums">{job.run_count ?? 0} 次</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dg-text-muted mb-0.5">成功 / 失败</div>
|
||||
<div className="text-[var(--dg-text-primary)] tabular-nums">
|
||||
<span className="dg-text-green">{job.success_count ?? 0}</span>
|
||||
{' / '}
|
||||
<span className="dg-text-red">{job.fail_count ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dg-text-muted mb-0.5">成功率</div>
|
||||
{rate != null ? (
|
||||
<SuccessRateBar success={job.success_count} fail={job.fail_count} />
|
||||
) : (
|
||||
<span className="dg-text-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job.script?.name && (
|
||||
<div className="text-xs dg-text-secondary">
|
||||
关联脚本: <span className="text-[var(--dg-text-primary)]">{job.script.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScriptType ? (
|
||||
<ScriptCodeEditor
|
||||
value={content}
|
||||
scriptType={scriptType}
|
||||
onChange={() => {}}
|
||||
readOnly
|
||||
height="360px"
|
||||
/>
|
||||
) : (
|
||||
<pre className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] p-4 text-xs font-mono text-[var(--dg-text-primary)] whitespace-pre-wrap break-all max-h-96 overflow-auto">
|
||||
{content || '(无命令内容)'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, Fragment } from 'react';
|
||||
// 定时任务执行历史弹窗 — 统计可视化 + 趋势图 + 行内日志展开 // Logan.Li
|
||||
import { useState, useEffect, useMemo, Fragment } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,30 +15,80 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { History, Loader2, Terminal } from 'lucide-react';
|
||||
import { History, Loader2, CheckCircle, XCircle, BarChart3, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { taskExecutionApi } from '@/api';
|
||||
import { getStatusBadge } from './cronJobHelpers';
|
||||
import { getStatusBadge, formatDuration } from './cronJobHelpers';
|
||||
import { TerminalOutput } from './TerminalOutput';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
// 定时任务执行历史弹窗(打开时自动加载,点击行展开输出) // Logan.Li
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
taskId?: number | null;
|
||||
taskName?: string;
|
||||
}
|
||||
|
||||
interface TrendDay {
|
||||
date: string;
|
||||
label: string;
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
function buildTrend(executions: JsonData[], days = 7): TrendDay[] {
|
||||
const now = new Date();
|
||||
const buckets: TrendDay[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
buckets.push({
|
||||
date: key,
|
||||
label: `${d.getMonth() + 1}/${d.getDate()}`,
|
||||
total: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
});
|
||||
}
|
||||
const map = new Map(buckets.map((b) => [b.date, b]));
|
||||
for (const exec of executions) {
|
||||
const raw = exec.started_at || exec.created_at;
|
||||
if (!raw) continue;
|
||||
const key = new Date(raw).toISOString().slice(0, 10);
|
||||
const bucket = map.get(key);
|
||||
if (!bucket) continue;
|
||||
bucket.total += 1;
|
||||
if (exec.status === 'success') bucket.success += 1;
|
||||
else if (exec.status === 'failed') bucket.failed += 1;
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
export function ExecutionHistoryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
taskId = null,
|
||||
taskName,
|
||||
}: Props) {
|
||||
const [executions, setExecutions] = useState<JsonData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedExec, setExpandedExec] = useState<number | null>(null);
|
||||
const [viewingExecId, setViewingExecId] = useState<number | string | null>(null);
|
||||
|
||||
// 弹窗打开时加载执行记录 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open) {
|
||||
setViewingExecId(null);
|
||||
return;
|
||||
}
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await taskExecutionApi.getExecutions({ task_type: 'cron', page_size: 50 });
|
||||
const params: { task_type: string; page_size: number; task_id?: string } = {
|
||||
task_type: 'cron',
|
||||
page_size: 100,
|
||||
};
|
||||
if (taskId) params.task_id = String(taskId);
|
||||
const data = await taskExecutionApi.getExecutions(params);
|
||||
setExecutions(data.list || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load executions:', error);
|
||||
@@ -46,73 +97,168 @@ export function ExecutionHistoryDialog({
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [open]);
|
||||
}, [open, taskId]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const success = executions.filter((e) => e.status === 'success').length;
|
||||
const failed = executions.filter((e) => e.status === 'failed').length;
|
||||
const total = executions.length;
|
||||
const successRate = total > 0 ? Math.round((success / total) * 100) : 0;
|
||||
const failedRate = total > 0 ? Math.round((failed / total) * 100) : 0;
|
||||
return { success, failed, total, successRate, failedRate };
|
||||
}, [executions]);
|
||||
|
||||
const trend = useMemo(() => buildTrend(executions), [executions]);
|
||||
const trendMax = useMemo(() => Math.max(...trend.map((t) => t.total), 1), [trend]);
|
||||
|
||||
const viewingExec = useMemo(
|
||||
() => executions.find((e) => e.id === viewingExecId) ?? null,
|
||||
[executions, viewingExecId],
|
||||
);
|
||||
|
||||
const title = taskName ? `执行历史 — ${taskName}` : '全部执行历史';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>执行历史</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">定时任务执行记录与输出日志</DialogDescription>
|
||||
<DialogContent className="max-w-6xl w-[min(96vw,72rem)] bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] max-h-[92vh] flex flex-col overflow-hidden p-6">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
近 7 天趋势与执行日志(最近 100 条)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 text-[var(--dg-text-secondary)] animate-spin" />
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 text-[var(--dg-text-secondary)] animate-spin" />
|
||||
</div>
|
||||
) : executions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-secondary)]">
|
||||
<History className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无执行记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-0 flex-1 overflow-y-auto mt-1">
|
||||
<div className="grid grid-cols-3 gap-2.5 shrink-0">
|
||||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2.5 flex items-center gap-2.5">
|
||||
<CheckCircle className="h-7 w-7 text-emerald-500 shrink-0" />
|
||||
<div>
|
||||
<div className="text-xl font-semibold text-emerald-500 tabular-nums">{stats.success}</div>
|
||||
<div className="text-[11px] dg-text-secondary">成功 · {stats.successRate}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/5 px-3 py-2.5 flex items-center gap-2.5">
|
||||
<XCircle className="h-7 w-7 text-red-500 shrink-0" />
|
||||
<div>
|
||||
<div className="text-xl font-semibold text-red-500 tabular-nums">{stats.failed}</div>
|
||||
<div className="text-[11px] dg-text-secondary">失败 · {stats.failedRate}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2.5 flex items-center gap-2.5">
|
||||
<BarChart3 className="h-7 w-7 dg-text-blue shrink-0" />
|
||||
<div>
|
||||
<div className="text-xl font-semibold tabular-nums">{stats.total}</div>
|
||||
<div className="text-[11px] dg-text-secondary">总计</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : executions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-[var(--dg-text-secondary)]">
|
||||
<History className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无执行记录</p>
|
||||
|
||||
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-3 shrink-0">
|
||||
<div className="text-xs font-medium mb-2 dg-text-secondary">最近 7 天</div>
|
||||
<div className="flex items-end justify-between gap-1.5 h-24">
|
||||
{trend.map((item) => {
|
||||
const heightPct = item.total > 0 ? Math.max((item.total / trendMax) * 100, 12) : 4;
|
||||
const hasFailed = item.failed > 0;
|
||||
return (
|
||||
<div key={item.date} className="flex-1 flex flex-col items-center gap-0.5 min-w-0">
|
||||
<div className="w-full flex items-end justify-center h-16">
|
||||
<div
|
||||
className="w-full max-w-[32px] rounded-t transition-all"
|
||||
style={{
|
||||
height: `${heightPct}%`,
|
||||
background: hasFailed
|
||||
? 'linear-gradient(to top, #ef4444 0%, #22c55e 100%)'
|
||||
: item.total > 0 ? '#22c55e' : 'var(--dg-border)',
|
||||
}}
|
||||
title={`${item.label}: 成功 ${item.success} / 失败 ${item.failed}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] dg-text-muted">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">任务</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">耗时</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">执行时间</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">输出</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{executions.map((exec) => (
|
||||
<Fragment key={exec.id}>
|
||||
<TableRow className="border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-hover)]/50" onClick={() => setExpandedExec(expandedExec === exec.id ? null : exec.id)}>
|
||||
<TableCell className="text-[var(--dg-text-primary)]">{exec.task_name}</TableCell>
|
||||
<TableCell>{getStatusBadge(exec.status)}</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-secondary)]">{exec.duration ? `${exec.duration}ms` : '--'}</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-secondary)]">
|
||||
{exec.started_at ? new Date(exec.started_at).toLocaleString() : '--'}
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-secondary)]">
|
||||
{exec.output ? (
|
||||
<span className="dg-text-blue text-xs flex items-center gap-1">
|
||||
<Terminal className="h-3 w-3" />
|
||||
{expandedExec === exec.id ? '收起' : '查看'}
|
||||
</span>
|
||||
) : (
|
||||
'--'
|
||||
|
||||
<div className="rounded-lg border border-[var(--dg-border)] overflow-hidden shrink-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] hover:bg-[var(--dg-bg-secondary)]">
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-xs w-8" />
|
||||
{!taskId && <TableHead className="text-[var(--dg-text-secondary)] text-xs">任务</TableHead>}
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-xs w-[72px]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-xs w-[80px]">耗时</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-xs">执行时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{executions.map((exec) => {
|
||||
const expanded = viewingExecId === exec.id;
|
||||
const hasOutput = Boolean(exec.output);
|
||||
const colSpan = taskId ? 4 : 5;
|
||||
return (
|
||||
<Fragment key={exec.id}>
|
||||
<TableRow
|
||||
className={`border-[var(--dg-border)] ${expanded ? 'bg-[var(--dg-bg-hover)]/30' : ''} ${hasOutput ? 'cursor-pointer' : ''}`}
|
||||
onClick={() => {
|
||||
if (!hasOutput) return;
|
||||
setViewingExecId(expanded ? null : exec.id);
|
||||
}}
|
||||
>
|
||||
<TableCell className="py-2 w-8">
|
||||
{hasOutput && (
|
||||
expanded
|
||||
? <ChevronDown className="h-3.5 w-3.5 dg-text-muted" />
|
||||
: <ChevronRight className="h-3.5 w-3.5 dg-text-muted" />
|
||||
)}
|
||||
</TableCell>
|
||||
{!taskId && (
|
||||
<TableCell className="py-2 text-sm truncate max-w-[200px]">{exec.task_name}</TableCell>
|
||||
)}
|
||||
<TableCell className="py-2">{getStatusBadge(exec.status)}</TableCell>
|
||||
<TableCell className="py-2 text-xs dg-text-secondary tabular-nums">
|
||||
{formatDuration(exec.duration)}
|
||||
</TableCell>
|
||||
<TableCell className="py-2 text-xs dg-text-secondary">
|
||||
{exec.started_at ? new Date(exec.started_at).toLocaleString('zh-CN', { hour12: false }) : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded && hasOutput && (
|
||||
<TableRow key={`${exec.id}-log`} className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={colSpan} className="py-2 px-3 bg-[var(--dg-bg-tertiary)]/50">
|
||||
<TerminalOutput
|
||||
variant="plain"
|
||||
output={exec.output}
|
||||
status={exec.status}
|
||||
durationMs={exec.duration}
|
||||
startedAt={exec.started_at}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedExec === exec.id && exec.output && (
|
||||
<TableRow key={`${exec.id}-out`} className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={5} className="bg-[var(--dg-bg-tertiary)] p-0">
|
||||
<pre className="p-4 text-xs text-[var(--dg-text-primary)] font-mono whitespace-pre-wrap max-h-64 overflow-y-auto">
|
||||
{exec.output}
|
||||
</pre>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{viewingExec && !viewingExec.output && (
|
||||
<p className="text-xs dg-text-muted text-center py-2">该记录无输出日志</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// ScriptCard — 单个脚本卡片(含行操作按钮) // Logan.Li
|
||||
import { FileCode, Eye, Play, Edit, Copy, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { type Script, getTypeBadge } from './script-helpers';
|
||||
|
||||
interface Props {
|
||||
script: Script;
|
||||
selected: boolean;
|
||||
executing: boolean;
|
||||
onToggleSelect: () => void;
|
||||
onView: () => void;
|
||||
onExecute: () => void;
|
||||
onEdit: () => void;
|
||||
onClone: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
// 脚本卡片:复选框 + 名称 + 类型徽章 + 内容预览 + 元信息 + 行操作 // Logan.Li
|
||||
export default function ScriptCard({
|
||||
script: sc, selected, executing, onToggleSelect,
|
||||
onView, onExecute, onEdit, onClone, onDelete,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={onToggleSelect}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
<FileCode className="w-5 h-5 text-purple-500 shrink-0" />
|
||||
<CardTitle className="text-base truncate">{sc.name}</CardTitle>
|
||||
</div>
|
||||
{getTypeBadge(sc.type)}
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">{sc.description || '暂无描述'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="bg-muted/40 rounded-md p-2 mb-3 font-mono text-xs text-muted-foreground overflow-hidden">
|
||||
<pre className="whitespace-pre-wrap line-clamp-3">{sc.content}</pre>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3">
|
||||
<span>执行 {sc.run_count} 次 · v{sc.version}</span>
|
||||
<span>超时 {sc.timeout}s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" title="查看内容" onClick={onView}>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="执行" disabled={executing} onClick={onExecute}>
|
||||
{executing
|
||||
? <Loader2 className="w-4 h-4 animate-spin text-blue-600" />
|
||||
: <Play className="w-4 h-4 text-green-600" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="编辑" onClick={onEdit}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="复制" onClick={onClone}>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="删除" onClick={onDelete}>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// 脚本 CodeMirror 编辑器 — Shell / Python / PowerShell 语法高亮 // Logan.Li
|
||||
import { useMemo } from 'react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { powerShell } from '@codemirror/legacy-modes/mode/powershell';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
scriptType?: string;
|
||||
height?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function langExtension(scriptType?: string) {
|
||||
switch (scriptType) {
|
||||
case 'python':
|
||||
return python();
|
||||
case 'powershell':
|
||||
return StreamLanguage.define(powerShell);
|
||||
case 'shell':
|
||||
case 'bash':
|
||||
case 'command':
|
||||
default:
|
||||
return StreamLanguage.define(shell);
|
||||
}
|
||||
}
|
||||
|
||||
export function ScriptCodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
scriptType = 'shell',
|
||||
height = '360px',
|
||||
readOnly = false,
|
||||
}: Props) {
|
||||
const extensions = useMemo(() => [
|
||||
langExtension(scriptType),
|
||||
EditorView.lineWrapping,
|
||||
...(readOnly ? [EditorView.editable.of(false)] : []),
|
||||
], [scriptType, readOnly]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--dg-border)]">
|
||||
<CodeMirror
|
||||
value={value}
|
||||
height={height}
|
||||
theme={oneDark}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
highlightActiveLine: !readOnly,
|
||||
highlightActiveLineGutter: !readOnly,
|
||||
bracketMatching: true,
|
||||
closeBrackets: !readOnly,
|
||||
indentOnInput: !readOnly,
|
||||
}}
|
||||
style={{ fontSize: '13px' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Script 查看内容弹窗 // Logan.Li
|
||||
import { FileCode, Edit } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { Script } from './script-helpers';
|
||||
import { getTypeBadge } from './script-helpers';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
script: Script | null;
|
||||
onEdit: (sc: Script) => void;
|
||||
}
|
||||
|
||||
const ScriptDetailDialog: React.FC<Props> = ({ open, onOpenChange, script, onEdit }) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileCode className="w-5 h-5 text-purple-500" />
|
||||
{script?.name}
|
||||
{script && getTypeBadge(script.type)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{script?.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[500px] w-full rounded-md border bg-black">
|
||||
<pre className="p-4 font-mono text-sm dg-text-green whitespace-pre-wrap">
|
||||
{script?.content || '(空)'}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
{script && (
|
||||
<Button onClick={() => onEdit(script)}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptDetailDialog;
|
||||
@@ -1,69 +0,0 @@
|
||||
// ScriptExecutionsTable — 脚本执行记录表格 // Logan.Li
|
||||
import { RotateCcw, Terminal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { type ExecutionRecord, getStatusBadge, formatDate } from './script-helpers';
|
||||
|
||||
interface Props {
|
||||
executions: ExecutionRecord[];
|
||||
onRefresh: () => void;
|
||||
onViewLogs: (exec: ExecutionRecord) => void;
|
||||
}
|
||||
|
||||
// 执行记录表格:序号/脚本/状态/开始时间/耗时/查看日志 // Logan.Li
|
||||
export default function ScriptExecutionsTable({ executions, onRefresh, onViewLogs }: Props) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={onRefresh}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">#</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">脚本</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">开始时间</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">耗时</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{executions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
暂无执行记录
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
executions.map((job) => (
|
||||
<tr key={job.id} className="hover:bg-muted/50">
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{job.id}</td>
|
||||
<td className="px-4 py-3 text-sm">{job.task_name}</td>
|
||||
<td className="px-4 py-3">{getStatusBadge(job.status)}</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{formatDate(job.started_at)}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{job.duration ? `${(job.duration / 1000).toFixed(1)}s` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Button variant="ghost" size="sm" title="查看日志" onClick={() => onViewLogs(job)}>
|
||||
<Terminal className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { scriptTypes } from './script-helpers';
|
||||
import { availableScriptTypes } from './script-helpers';
|
||||
import { ScriptCodeEditor } from './ScriptCodeEditor';
|
||||
import type { ScriptRuntimeInfo } from '@/api/task/script';
|
||||
|
||||
export interface ScriptFormData {
|
||||
name: string;
|
||||
@@ -29,19 +30,22 @@ interface Props {
|
||||
setFormData: React.Dispatch<React.SetStateAction<ScriptFormData>>;
|
||||
onTypeChange: (t: string) => void;
|
||||
onSave: () => void;
|
||||
runtime?: ScriptRuntimeInfo | null;
|
||||
}
|
||||
|
||||
const ScriptFormDialog: React.FC<Props> = ({
|
||||
open, onOpenChange, editingId, editingCode, formData, setFormData, onTypeChange, onSave,
|
||||
open, onOpenChange, editingId, editingCode, formData, setFormData, onTypeChange, onSave, runtime,
|
||||
}) => {
|
||||
const types = availableScriptTypes(runtime ?? null);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh]">
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId !== null ? '编辑脚本' : '新建脚本'}</DialogTitle>
|
||||
<DialogDescription>编写运维脚本,支持 Shell / Python / PowerShell</DialogDescription>
|
||||
<DialogDescription>支持 Shell / Python / PowerShell,编辑器带语法高亮</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 overflow-y-auto max-h-[60vh] pr-1">
|
||||
<div className="space-y-4 overflow-y-auto flex-1 min-h-0 pr-1">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>名称</Label>
|
||||
@@ -56,8 +60,10 @@ const ScriptFormDialog: React.FC<Props> = ({
|
||||
<Select value={formData.type} onValueChange={onTypeChange}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{scriptTypes.map(t => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
{types.map(t => (
|
||||
<SelectItem key={t.value} value={t.value} disabled={t.disabled}>
|
||||
{t.label}{t.disabled ? '(环境不可用)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -89,12 +95,12 @@ const ScriptFormDialog: React.FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>脚本内容</Label>
|
||||
<Textarea
|
||||
<Label className="mb-2 block">脚本内容</Label>
|
||||
<ScriptCodeEditor
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
className="min-h-[360px] font-mono text-sm"
|
||||
placeholder="在此编写脚本内容..."
|
||||
onChange={(v) => setFormData({ ...formData, content: v })}
|
||||
scriptType={formData.type}
|
||||
height="360px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,4 +116,4 @@ const ScriptFormDialog: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptFormDialog;
|
||||
export default ScriptFormDialog;
|
||||
@@ -1,39 +0,0 @@
|
||||
// Script 执行日志弹窗 // Logan.Li
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { ExecutionRecord } from './script-helpers';
|
||||
import { getStatusBadge } from './script-helpers';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
exec: ExecutionRecord | null;
|
||||
}
|
||||
|
||||
const ScriptLogsDialog: React.FC<Props> = ({ open, onOpenChange, exec }) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>执行日志</DialogTitle>
|
||||
<DialogDescription>
|
||||
{exec?.task_name} · {getStatusBadge(exec?.status || '')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[480px] w-full rounded-md border bg-black">
|
||||
<pre className="p-4 font-mono text-sm dg-text-green whitespace-pre-wrap">
|
||||
{exec?.output || '暂无日志输出...'}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptLogsDialog;
|
||||
@@ -1,4 +1,4 @@
|
||||
// ScriptManager — 脚本库与执行记录主组件 // Logan.Li
|
||||
// ScriptManager — 脚本库主组件 // Logan.Li
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Search, RotateCcw, Trash2, FileCode } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -9,16 +9,16 @@ import {
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { scriptApi, taskExecutionApi } from '@/api';
|
||||
import { scriptApi } from '@/api';
|
||||
import type { ScriptRuntimeInfo } from '@/api/task/script';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import {
|
||||
type Script, type ExecutionRecord, mapScript, scriptTypes, genCode,
|
||||
type Script, mapScript, scriptTypes, genCode, availableScriptTypes,
|
||||
} from './script-helpers';
|
||||
import { ScriptRuntimeBanner } from './ScriptRuntimeBanner';
|
||||
import type { ScriptFormData } from './ScriptFormDialog';
|
||||
import ScriptFormDialog from './ScriptFormDialog';
|
||||
import ScriptDetailDialog from './ScriptDetailDialog';
|
||||
import ScriptLogsDialog from './ScriptLogsDialog';
|
||||
import ScriptCard from './ScriptCard';
|
||||
import ScriptExecutionsTable from './ScriptExecutionsTable';
|
||||
import { ScriptTable } from './ScriptTable';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
@@ -26,24 +26,20 @@ import type { JsonData } from '@/types/common';
|
||||
|
||||
export function ScriptManager() {
|
||||
const _confirm = useConfirm();
|
||||
const { hasAnyPermission, isAdmin } = usePermission();
|
||||
const canWrite = isAdmin || hasAnyPermission(['auto:script', 'auto:cron', 'auto:ansible']);
|
||||
const [scripts, setScripts] = useState<Script[]>([]);
|
||||
const [executions, setExecutions] = useState<ExecutionRecord[]>([]);
|
||||
const [runtime, setRuntime] = useState<ScriptRuntimeInfo | null>(null);
|
||||
const [runtimeLoading, setRuntimeLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [activeTab, setActiveTab] = useState('scripts');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
// 弹窗状态
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [selectedScript, setSelectedScript] = useState<Script | null>(null);
|
||||
const [selectedExec, setSelectedExec] = useState<ExecutionRecord | null>(null);
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<ScriptFormData>({
|
||||
name: '', description: '', content: '', type: 'shell', timeout: 300,
|
||||
});
|
||||
@@ -61,26 +57,30 @@ export function ScriptManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchExecutions = async () => {
|
||||
const fetchRuntime = async () => {
|
||||
setRuntimeLoading(true);
|
||||
try {
|
||||
const res = await taskExecutionApi.getExecutions({ task_type: 'script', page_size: 50 });
|
||||
const items = (res as JsonData)?.list || [];
|
||||
setExecutions(items);
|
||||
const rt = await scriptApi.getRuntime();
|
||||
setRuntime(rt);
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
setRuntimeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchScripts();
|
||||
fetchExecutions();
|
||||
fetchRuntime();
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
const types = availableScriptTypes(runtime);
|
||||
const first = types.find((t) => !t.disabled) ?? types[0] ?? scriptTypes[0];
|
||||
setEditingId(null);
|
||||
setFormData({
|
||||
name: '', description: '', content: scriptTypes[0].sample,
|
||||
type: 'shell', timeout: 300,
|
||||
name: '', description: '', content: first.sample,
|
||||
type: first.value, timeout: 300,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
};
|
||||
@@ -96,7 +96,6 @@ export function ScriptManager() {
|
||||
timeout: sc.timeout,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
setDetailOpen(false);
|
||||
};
|
||||
|
||||
const handleTypeChange = (t: string) => {
|
||||
@@ -134,8 +133,8 @@ export function ScriptManager() {
|
||||
}
|
||||
setEditorOpen(false);
|
||||
fetchScripts();
|
||||
} catch {
|
||||
toast.error(editingId !== null ? '更新失败' : '创建失败');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : (editingId !== null ? '更新失败' : '创建失败'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,8 +150,8 @@ export function ScriptManager() {
|
||||
});
|
||||
toast.success('复制成功');
|
||||
fetchScripts();
|
||||
} catch {
|
||||
toast.error('复制失败');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,43 +167,15 @@ export function ScriptManager() {
|
||||
await scriptApi.deleteScript(sc.id);
|
||||
toast.success('删除成功');
|
||||
fetchScripts();
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async (sc: Script) => {
|
||||
const ok = await _confirm({
|
||||
title: '执行脚本',
|
||||
description: `将在 AIOps 主机上本地执行「${sc.name}」(${sc.type})。`,
|
||||
confirmText: '开始执行',
|
||||
});
|
||||
if (!ok) return;
|
||||
setExecuting(true);
|
||||
try {
|
||||
const res = await scriptApi.executeScript(sc.id);
|
||||
const exec = (res as JsonData)?.data ?? res;
|
||||
if (exec?.status === 'success') {
|
||||
toast.success(`执行成功,耗时 ${exec.duration ?? 0}ms`);
|
||||
} else {
|
||||
toast.error(`执行失败,耗时 ${exec?.duration ?? 0}ms`, {
|
||||
description: ((exec?.output || '') as string).slice(0, 120),
|
||||
});
|
||||
}
|
||||
fetchScripts();
|
||||
fetchExecutions();
|
||||
} catch {
|
||||
toast.error('执行请求失败');
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredScripts = scripts.filter(sc => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchSearch = sc.name.toLowerCase().includes(q) ||
|
||||
sc.description.toLowerCase().includes(q) ||
|
||||
sc.code.toLowerCase().includes(q);
|
||||
sc.description.toLowerCase().includes(q);
|
||||
const matchType = typeFilter === 'all' || sc.type === typeFilter;
|
||||
return matchSearch && matchType;
|
||||
});
|
||||
@@ -233,24 +204,18 @@ export function ScriptManager() {
|
||||
toast.success(`已删除 ${selectedIds.length} 个脚本`);
|
||||
setSelectedIds([]);
|
||||
fetchScripts();
|
||||
} catch {
|
||||
toast.error('批量删除失败');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '批量删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 页面级 Tab 配置 // Logan.Li
|
||||
const pageTabs = [
|
||||
{ id: 'scripts', label: '脚本库' },
|
||||
{ id: 'jobs', label: '执行记录' },
|
||||
];
|
||||
|
||||
const scriptsToolbar = (
|
||||
const toolbar = (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 dg-text-muted" />
|
||||
<Input
|
||||
placeholder="搜索名称、编码、描述..."
|
||||
placeholder="搜索名称、描述..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 dg-input"
|
||||
@@ -276,103 +241,81 @@ export function ScriptManager() {
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="脚本库"
|
||||
subtitle="脚本管理与执行记录"
|
||||
subtitle="脚本编辑与管理"
|
||||
icon={<FileCode className="h-5 w-5" />}
|
||||
tabs={pageTabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => {
|
||||
setActiveTab(id);
|
||||
if (id === 'jobs') fetchExecutions();
|
||||
}}
|
||||
toolbar={activeTab === 'scripts' ? scriptsToolbar : undefined}
|
||||
loading={activeTab === 'scripts' && loading}
|
||||
actions={activeTab === 'scripts' ? (
|
||||
toolbar={toolbar}
|
||||
loading={loading}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={fetchScripts}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建脚本
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建脚本
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={fetchExecutions}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{activeTab === 'scripts' ? (
|
||||
<>
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-2.5 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
<span className="text-sm dg-text-secondary">
|
||||
已选择 {selectedIds.length} 项 / 共 {filteredScripts.length} 项
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => setSelectedIds([])}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="dg-btn-danger dg-btn-sm"
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredScripts.length === 0 ? (
|
||||
<PageEmpty
|
||||
title={scripts.length === 0 ? '暂无脚本' : '未匹配到符合条件的脚本'}
|
||||
description={scripts.length === 0 ? '点击「新建脚本」创建第一个' : undefined}
|
||||
action={scripts.length === 0 ? (
|
||||
<Button className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建脚本
|
||||
</Button>
|
||||
) : undefined}
|
||||
<div className="mb-4">
|
||||
<ScriptRuntimeBanner runtime={runtime} loading={runtimeLoading} />
|
||||
</div>
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-2.5 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
) : (
|
||||
<ContentPanel>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredScripts.map((sc) => (
|
||||
<ScriptCard
|
||||
key={sc.id}
|
||||
script={sc}
|
||||
selected={selectedIds.includes(sc.id)}
|
||||
executing={executing}
|
||||
onToggleSelect={() => toggleSelect(sc.id)}
|
||||
onView={() => { setSelectedScript(sc); setDetailOpen(true); }}
|
||||
onExecute={() => handleExecute(sc)}
|
||||
onEdit={() => openEdit(sc)}
|
||||
onClone={() => handleClone(sc)}
|
||||
onDelete={() => handleDelete(sc)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ContentPanel>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ScriptExecutionsTable
|
||||
executions={executions}
|
||||
onRefresh={fetchExecutions}
|
||||
onViewLogs={(job) => { setSelectedExec(job); setLogsOpen(true); }}
|
||||
<span className="text-sm dg-text-secondary">
|
||||
已选择 {selectedIds.length} 项 / 共 {filteredScripts.length} 项
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => setSelectedIds([])}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="dg-btn-danger dg-btn-sm"
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredScripts.length === 0 ? (
|
||||
<PageEmpty
|
||||
title={scripts.length === 0 ? '暂无脚本' : '未匹配到符合条件的脚本'}
|
||||
description={scripts.length === 0 ? '点击「新建脚本」创建第一个' : undefined}
|
||||
action={scripts.length === 0 && canWrite ? (
|
||||
<Button className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建脚本
|
||||
</Button>
|
||||
) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<ContentPanel>
|
||||
<ScriptTable
|
||||
scripts={filteredScripts}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={toggleSelect}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
allSelected={allSelected}
|
||||
onEdit={openEdit}
|
||||
onClone={handleClone}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</ContentPanel>
|
||||
)}
|
||||
</PageShell>
|
||||
|
||||
@@ -385,19 +328,7 @@ export function ScriptManager() {
|
||||
setFormData={setFormData}
|
||||
onTypeChange={handleTypeChange}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
<ScriptDetailDialog
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
script={selectedScript}
|
||||
onEdit={openEdit}
|
||||
/>
|
||||
|
||||
<ScriptLogsDialog
|
||||
open={logsOpen}
|
||||
onOpenChange={setLogsOpen}
|
||||
exec={selectedExec}
|
||||
runtime={runtime}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// 脚本运行时环境预检横幅 — 展示 Shell/Python/PowerShell 可用性 // Logan.Li
|
||||
import { Terminal, AlertTriangle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { ScriptRuntimeInfo } from '@/api/task/script';
|
||||
|
||||
interface Props {
|
||||
runtime: ScriptRuntimeInfo | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ScriptRuntimeBanner({ runtime, loading }: Props) {
|
||||
if (loading || !runtime) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-4 py-3 text-sm dg-text-muted">
|
||||
正在检测本机脚本运行环境…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ key: 'shell', label: 'Shell', ok: runtime.shell, path: runtime.shell_path },
|
||||
{ key: 'python', label: 'Python', ok: runtime.python, path: runtime.python_path },
|
||||
{ key: 'powershell', label: 'PowerShell', ok: runtime.powershell, path: runtime.powershell_path },
|
||||
];
|
||||
|
||||
const allOk = items.every((i) => i.key === 'powershell' ? true : i.ok); // PowerShell 可选
|
||||
const coreOk = runtime.shell;
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border px-4 py-3 flex flex-wrap items-center gap-3 ${
|
||||
coreOk ? 'border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]' : 'border-red-500/40 bg-red-500/10'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-primary)]">
|
||||
<Terminal className="h-4 w-4 dg-text-blue" />
|
||||
<span className="font-medium">执行环境</span>
|
||||
<span className="text-xs dg-text-muted">({runtime.os}/{runtime.arch}{runtime.embed_mode ? ' · embed' : ''})</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{items.map((item) => (
|
||||
<Badge
|
||||
key={item.key}
|
||||
variant="outline"
|
||||
className={item.ok
|
||||
? 'border-emerald-500/40 dg-text-green bg-emerald-500/10'
|
||||
: 'border-[var(--dg-border)] dg-text-muted bg-[var(--dg-bg-tertiary)]'}
|
||||
title={item.path || (item.ok ? '可用' : '未安装')}
|
||||
>
|
||||
{item.label}{item.ok ? ' ✓' : ' ✗'}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{!coreOk && (
|
||||
<p className="text-xs dg-text-red flex items-center gap-1 w-full">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
未检测到 Shell 解释器,脚本无法在本机执行。Docker 部署请确认镜像已安装 bash。
|
||||
</p>
|
||||
)}
|
||||
{coreOk && !runtime.python && (
|
||||
<p className="text-xs dg-text-muted w-full">Python 未安装,Python 类型脚本将无法执行。</p>
|
||||
)}
|
||||
{!allOk && coreOk && (
|
||||
<p className="text-xs dg-text-muted w-full">部分解释器不可用,对应类型的脚本创建后无法执行。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 判断某脚本类型在当前环境是否可执行 // Logan.Li */
|
||||
export function canRunScriptType(runtime: ScriptRuntimeInfo | null, type: string): boolean {
|
||||
if (!runtime) return true;
|
||||
switch (type) {
|
||||
case 'python': return runtime.python;
|
||||
case 'powershell': return runtime.powershell;
|
||||
default: return runtime.shell;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// ScriptTable — 脚本库紧凑表格(名称/类型/描述/超时/操作) // Logan.Li
|
||||
import { Edit, Copy, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { type Script, getTypeBadge } from './script-helpers';
|
||||
|
||||
interface Props {
|
||||
scripts: Script[];
|
||||
selectedIds: number[];
|
||||
onToggleSelect: (id: number) => void;
|
||||
onToggleSelectAll: () => void;
|
||||
allSelected: boolean;
|
||||
onEdit: (sc: Script) => void;
|
||||
onClone: (sc: Script) => void;
|
||||
onDelete: (sc: Script) => void;
|
||||
}
|
||||
|
||||
export function ScriptTable({
|
||||
scripts, selectedIds,
|
||||
onToggleSelect, onToggleSelectAll, allSelected,
|
||||
onEdit, onClone, onDelete,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--dg-border)] overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] hover:bg-[var(--dg-bg-secondary)]">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs">名称</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[100px]">类型</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs hidden md:table-cell">描述</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[72px] text-right">超时</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[120px] text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{scripts.map((sc) => (
|
||||
<TableRow key={sc.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]/50">
|
||||
<TableCell className="py-2">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(sc.id)}
|
||||
onCheckedChange={() => onToggleSelect(sc.id)}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{sc.name}</p>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">{getTypeBadge(sc.type)}</TableCell>
|
||||
<TableCell className="py-2 hidden md:table-cell max-w-[320px]">
|
||||
<span className="text-xs dg-text-secondary line-clamp-1">{sc.description || '—'}</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 text-right text-xs dg-text-muted tabular-nums">
|
||||
{sc.timeout}s
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" title="编辑" onClick={() => onEdit(sc)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" title="克隆" onClick={() => onClone(sc)}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" title="删除" onClick={() => onDelete(sc)}>
|
||||
<Trash2 className="h-3.5 w-3.5 dg-text-red" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// 内置系统任务面板 — 表格式紧凑布局 // Logan.Li
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import { systemTaskApi, type SystemTaskItem } from '@/api';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { formatRelativeTime } from './cronJobHelpers';
|
||||
import {
|
||||
ServerCog, Play, Loader2, Shield, Trash2, Clock, CheckCircle, XCircle, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const categoryLabel: Record<string, string> = {
|
||||
monitor: '监控',
|
||||
cleanup: '清理',
|
||||
};
|
||||
|
||||
function statusBadge(status?: string) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10 gap-1 h-5 text-[11px] px-1.5">
|
||||
<CheckCircle className="h-2.5 w-2.5" />成功
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10 gap-1 h-5 text-[11px] px-1.5">
|
||||
<XCircle className="h-2.5 w-2.5" />失败
|
||||
</Badge>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10 gap-1 h-5 text-[11px] px-1.5">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />运行中
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-muted gap-1 h-5 text-[11px] px-1.5">
|
||||
<AlertCircle className="h-2.5 w-2.5" />待执行
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
refreshing?: boolean;
|
||||
}
|
||||
|
||||
export function SystemTasksPanel({ refreshing }: Props) {
|
||||
const _confirm = useConfirm();
|
||||
const { hasAnyPermission, isAdmin } = usePermission();
|
||||
const canRun = isAdmin || hasAnyPermission(['auto:script', 'auto:cron', 'auto:ansible']);
|
||||
const [tasks, setTasks] = useState<SystemTaskItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [runningCode, setRunningCode] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await systemTaskApi.list();
|
||||
setTasks(res.list || []);
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载系统任务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load, refreshing]);
|
||||
|
||||
const handleRun = async (task: SystemTaskItem) => {
|
||||
const ok = await _confirm({
|
||||
title: '立即执行',
|
||||
description: `确定手动执行系统任务「${task.name}」吗?`,
|
||||
confirmText: '执行',
|
||||
});
|
||||
if (!ok) return;
|
||||
setRunningCode(task.code);
|
||||
try {
|
||||
const res = await systemTaskApi.run(task.code);
|
||||
toast.success(res.last_message || '执行完成');
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '执行失败');
|
||||
} finally {
|
||||
setRunningCode(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin dg-text-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return <PageEmpty title="暂无系统任务" />;
|
||||
}
|
||||
|
||||
const failedCount = tasks.filter((t) => t.last_status === 'failed').length;
|
||||
|
||||
return (
|
||||
<ContentPanel>
|
||||
<div className="flex items-center justify-between gap-3 mb-3 px-0.5">
|
||||
<p className="text-xs dg-text-muted">
|
||||
进程内置调度,不可编辑;清理类任务支持手动触发
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-[11px] shrink-0">
|
||||
<span className="dg-text-muted">{tasks.length} 个任务</span>
|
||||
{failedCount > 0 && (
|
||||
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10 h-5 text-[10px]">
|
||||
{failedCount} 个失败
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[var(--dg-border)] overflow-hidden">
|
||||
{/* 表头 */}
|
||||
<div className="hidden md:grid md:grid-cols-[minmax(0,1.2fr)_minmax(0,1.6fr)_minmax(0,1fr)_72px] gap-3 px-3 py-2 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] text-[11px] dg-text-muted">
|
||||
<span>任务</span>
|
||||
<span>调度 / 说明</span>
|
||||
<span>最近执行</span>
|
||||
<span className="text-right">操作</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[var(--dg-border)]">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.code}
|
||||
className="grid grid-cols-1 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1.6fr)_minmax(0,1fr)_72px] gap-2 md:gap-3 px-3 py-2 items-center hover:bg-[var(--dg-bg-hover)]/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ServerCog className="h-3.5 w-3.5 dg-text-cyan shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{task.name}</span>
|
||||
<Badge variant="outline" className="border-cyan-500/30 text-cyan-500 bg-cyan-500/10 h-5 text-[10px] px-1">
|
||||
内置
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary h-5 text-[10px] px-1">
|
||||
{categoryLabel[task.category] || task.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-0.5">{statusBadge(task.last_status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 text-xs dg-text-secondary pl-5 md:pl-0">
|
||||
<p className="truncate" title={task.description}>{task.description}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 mt-0.5 dg-text-muted text-[11px]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{task.schedule}
|
||||
</span>
|
||||
{task.category === 'cleanup' && task.code === 'audit_log_cleanup' && task.extra?.retention_days != null && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
保留 {String(task.extra.retention_days)} 天
|
||||
</span>
|
||||
)}
|
||||
{task.code === 'alert_engine' && task.extra && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
{(task.extra as { is_leader?: boolean }).is_leader ? 'Leader' : 'Follower'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 text-[11px] dg-text-muted pl-5 md:pl-0">
|
||||
<div>{formatRelativeTime(task.last_run_at ?? undefined)}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{task.last_duration_ms != null && task.last_duration_ms > 0 && (
|
||||
<span>{task.last_duration_ms}ms</span>
|
||||
)}
|
||||
<span>{task.run_count ?? 0} 次</span>
|
||||
</div>
|
||||
{task.last_message && (
|
||||
<p className="truncate font-mono mt-0.5 max-w-full" title={task.last_message}>
|
||||
{task.last_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start md:justify-end pl-5 md:pl-0">
|
||||
{task.runnable && canRun ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-emerald-600 dg-text-green hover:bg-emerald-600/20 h-7 px-2 text-xs"
|
||||
disabled={runningCode === task.code}
|
||||
onClick={() => handleRun(task)}
|
||||
>
|
||||
{runningCode === task.code ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-[11px] dg-text-muted">自动</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ContentPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// 执行输出面板 — plain 主题纯色 / terminal 终端风格 // Logan.Li
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
output?: string;
|
||||
status?: string;
|
||||
durationMs?: number;
|
||||
startedAt?: string | null;
|
||||
emptyText?: string;
|
||||
className?: string;
|
||||
maxHeight?: string;
|
||||
/** plain=主题纯色块,适配明暗切换;terminal=经典黑框终端 // Logan.Li */
|
||||
variant?: 'plain' | 'terminal';
|
||||
}
|
||||
|
||||
function ansiToHtml(text: string): string {
|
||||
const escaped = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return escaped
|
||||
.replace(/\x1b\[31m/g, '<span class="text-[var(--dg-red)]">')
|
||||
.replace(/\x1b\[32m/g, '<span class="text-[var(--dg-green)]">')
|
||||
.replace(/\x1b\[33m/g, '<span class="text-[var(--dg-yellow)]">')
|
||||
.replace(/\x1b\[0m/g, '</span>');
|
||||
}
|
||||
|
||||
export function TerminalOutput({
|
||||
output,
|
||||
status,
|
||||
durationMs,
|
||||
startedAt,
|
||||
emptyText = '(无输出)',
|
||||
className,
|
||||
maxHeight = 'min(28rem, 55vh)',
|
||||
variant = 'plain',
|
||||
}: Props) {
|
||||
const lines = useMemo(() => (output || '').split('\n'), [output]);
|
||||
const hasAnsi = (output || '').includes('\x1b[');
|
||||
|
||||
if (variant === 'plain') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{(startedAt || durationMs != null || status) && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-2 border-b border-[var(--dg-border)] text-[11px] text-[var(--dg-text-muted)]">
|
||||
{startedAt && (
|
||||
<span>{new Date(startedAt).toLocaleString('zh-CN', { hour12: false })}</span>
|
||||
)}
|
||||
{durationMs != null && durationMs > 0 && (
|
||||
<span>耗时 {durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(2)}s`}</span>
|
||||
)}
|
||||
{status && (
|
||||
<span className={status === 'success' ? 'text-[var(--dg-green)]' : status === 'failed' ? 'text-[var(--dg-red)]' : 'text-[var(--dg-yellow)]'}>
|
||||
{status === 'success' ? '成功' : status === 'failed' ? '失败' : status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="px-3 py-2.5 font-mono text-xs leading-relaxed text-[var(--dg-text-primary)] overflow-auto whitespace-pre-wrap break-words"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{!output?.trim() ? (
|
||||
<span className="text-[var(--dg-text-muted)]">{emptyText}</span>
|
||||
) : hasAnsi ? (
|
||||
<pre
|
||||
className="m-0 whitespace-pre-wrap break-words"
|
||||
dangerouslySetInnerHTML={{ __html: ansiToHtml(output) }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="m-0 whitespace-pre-wrap break-words">{output}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg overflow-hidden border border-[#30363d] shadow-inner', className)}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-[#161b22] border-b border-[#30363d]">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#ff5f57]" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#febc2e]" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#28c840]" />
|
||||
<span className="ml-1 text-[11px] font-mono text-[#8b949e]">aiops — bash — 80×24</span>
|
||||
<div className="ml-auto flex items-center gap-3 text-[11px] font-mono text-[#8b949e]">
|
||||
{startedAt && (
|
||||
<span>{new Date(startedAt).toLocaleString('zh-CN', { hour12: false })}</span>
|
||||
)}
|
||||
{durationMs != null && durationMs > 0 && (
|
||||
<span>{durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(2)}s`}</span>
|
||||
)}
|
||||
{status && (
|
||||
<span className={status === 'success' ? 'text-emerald-400' : status === 'failed' ? 'text-red-400' : 'text-amber-400'}>
|
||||
exit {status === 'success' ? 0 : status === 'failed' ? 1 : '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="bg-[#0d1117] px-4 py-3 font-mono text-[13px] leading-[1.55] overflow-auto"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{!output?.trim() ? (
|
||||
<span className="text-[#484f58]">{emptyText}</span>
|
||||
) : hasAnsi ? (
|
||||
<pre
|
||||
className="whitespace-pre-wrap break-words text-[#c9d1d9] m-0"
|
||||
dangerouslySetInnerHTML={{ __html: ansiToHtml(output) }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words text-[#c9d1d9] m-0">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex gap-3 hover:bg-white/[0.03] -mx-2 px-2 rounded-sm">
|
||||
<span className="select-none text-[#484f58] w-8 text-right shrink-0 tabular-nums">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'flex-1 min-w-0',
|
||||
/error|failed|fatal|denied/i.test(line) && 'text-red-400',
|
||||
/warn|warning/i.test(line) && 'text-amber-400',
|
||||
/success|ok|done/i.test(line) && 'text-emerald-400',
|
||||
)}>
|
||||
{line || ' '}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,40 +2,87 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
// 获取执行状态徽章(主列表+执行历史共用) // Logan.Li
|
||||
export function getStatusBadge(status: string) {
|
||||
export function getStatusBadge(status?: string) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10 h-5 text-[11px] px-1.5 gap-1">
|
||||
<CheckCircle className="h-2.5 w-2.5" />
|
||||
成功
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10 h-5 text-[11px] px-1.5 gap-1">
|
||||
<XCircle className="h-2.5 w-2.5" />
|
||||
失败
|
||||
</Badge>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10 animate-pulse">
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10 h-5 text-[11px] px-1.5 gap-1 animate-pulse">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||
执行中
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
从未执行
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] h-5 text-[11px] px-1.5">
|
||||
未执行
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 启用状态徽章 // Logan.Li
|
||||
export function getEnabledBadge(enabled: boolean) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
enabled
|
||||
? 'border-emerald-500/30 dg-text-green bg-emerald-500/10 h-5 text-[11px] px-1.5'
|
||||
: 'border-[var(--dg-border)] text-[var(--dg-text-muted)] h-5 text-[11px] px-1.5'
|
||||
}
|
||||
>
|
||||
{enabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// 定时任务类型徽章 // Logan.Li
|
||||
export function getJobTypeBadge(type?: string) {
|
||||
const label = getJobTypeLabel(type);
|
||||
const colorMap: Record<string, string> = {
|
||||
shell: 'border-blue-500/30 dg-text-blue bg-blue-500/10',
|
||||
python: 'border-amber-500/30 dg-text-yellow bg-amber-500/10',
|
||||
powershell: 'border-purple-500/30 dg-text-purple bg-purple-500/10',
|
||||
command: 'border-cyan-500/30 dg-text-cyan bg-cyan-500/10',
|
||||
};
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`h-5 text-[11px] px-1.5 ${colorMap[type || ''] || 'border-[var(--dg-border)] dg-text-secondary'}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// 定时任务类型中文标签 // Logan.Li
|
||||
export function getJobTypeLabel(type?: string) {
|
||||
switch (type) {
|
||||
case 'shell': return 'Shell';
|
||||
case 'python': return 'Python';
|
||||
case 'powershell': return 'PowerShell';
|
||||
case 'command': return '命令';
|
||||
default: return type || '未知';
|
||||
}
|
||||
}
|
||||
|
||||
// Cron 表达式转中文可读描述 // Logan.Li
|
||||
export function parseCron(schedule: string) {
|
||||
export function parseCron(schedule?: string) {
|
||||
if (!schedule) return '—';
|
||||
const patterns: Record<string, string> = {
|
||||
'0 2 * * *': '每天 02:00',
|
||||
'0 3 * * 0': '每周日 03:00',
|
||||
@@ -45,3 +92,57 @@ export function parseCron(schedule: string) {
|
||||
};
|
||||
return patterns[schedule] || schedule;
|
||||
}
|
||||
|
||||
// 计算成功率(0-100),无数据返回 null // Logan.Li
|
||||
export function computeSuccessRate(success = 0, fail = 0): number | null {
|
||||
const total = success + fail;
|
||||
if (total <= 0) return null;
|
||||
return Math.round((success / total) * 100);
|
||||
}
|
||||
|
||||
// 相对时间展示(列表上次执行列) // Logan.Li
|
||||
export function formatRelativeTime(dateStr?: string): string {
|
||||
if (!dateStr) return '—';
|
||||
const d = new Date(dateStr);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
const diff = Date.now() - d.getTime();
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 1) return '刚刚';
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days} 天前`;
|
||||
return d.toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// 耗时格式化 // Logan.Li
|
||||
export function formatDuration(ms?: number): string {
|
||||
if (ms == null || ms <= 0) return '—';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
|
||||
}
|
||||
|
||||
// 成功率迷你进度条 // Logan.Li
|
||||
export function SuccessRateBar({ success = 0, fail = 0 }: { success?: number; fail?: number }) {
|
||||
const rate = computeSuccessRate(success, fail);
|
||||
if (rate === null) {
|
||||
return <span className="text-[11px] dg-text-muted">—</span>;
|
||||
}
|
||||
const tone = rate >= 90 ? 'bg-emerald-500' : rate >= 70 ? 'bg-amber-500' : 'bg-red-500';
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-[72px]">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-border)] overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${tone}`} style={{ width: `${rate}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] dg-text-muted tabular-nums w-7 text-right">{rate}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// ScriptManager 共享类型与工具函数 // Logan.Li
|
||||
import React from 'react';
|
||||
import { Clock, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
@@ -11,26 +10,9 @@ export interface Script {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
type: string; // shell | python | powershell | yaml
|
||||
type: string; // shell | python | powershell
|
||||
content: string;
|
||||
timeout: number;
|
||||
version: number;
|
||||
run_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ExecutionRecord {
|
||||
id: number;
|
||||
task_type: string;
|
||||
task_id: number;
|
||||
task_name: string;
|
||||
status: string;
|
||||
output: string;
|
||||
duration: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 将后端返回的脚本原始对象映射为前端结构
|
||||
@@ -43,18 +25,34 @@ export function mapScript(raw: JsonData): Script {
|
||||
type: raw.type || 'shell',
|
||||
content: raw.content || '',
|
||||
timeout: raw.timeout || 300,
|
||||
version: raw.version || 1,
|
||||
run_count: raw.run_count || 0,
|
||||
created_at: raw.created_at,
|
||||
updated_at: raw.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export const scriptTypes = [
|
||||
{ value: 'shell', label: 'Shell', sample: '#!/bin/bash\n# 系统巡检脚本\necho "===== 系统信息 ====="\nuname -a\necho ""\necho "===== 磁盘使用 ====="\ndf -h\necho ""\necho "===== 内存使用 ====="\nfree -h\n' },
|
||||
{ value: 'python', label: 'Python', sample: '#!/usr/bin/env python3\n# 主机信息采集\nimport platform, socket\nprint("主机名:", socket.gethostname())\nprint("系统:", platform.platform())\nprint("Python:", platform.python_version())\n' },
|
||||
{ value: 'powershell', label: 'PowerShell', sample: '# 主机信息采集\nWrite-Host "主机名: $env:COMPUTERNAME"\nWrite-Host "系统: $([System.Environment]::OSVersion.VersionString)"\n' },
|
||||
];
|
||||
|
||||
/** 脚本类型是否可在当前环境执行 // Logan.Li */
|
||||
export function isScriptTypeAvailable(
|
||||
runtime: { shell?: boolean; python?: boolean; powershell?: boolean } | null,
|
||||
type: string,
|
||||
): boolean {
|
||||
if (!runtime) return true;
|
||||
if (type === 'python') return !!runtime.python;
|
||||
if (type === 'powershell') return !!runtime.powershell;
|
||||
return runtime.shell !== false;
|
||||
}
|
||||
|
||||
/** 表单下拉:始终展示全部类型(含 PowerShell),不可用项由 UI 禁用 // Logan.Li */
|
||||
export function availableScriptTypes(runtime: { shell?: boolean; python?: boolean; powershell?: boolean } | null) {
|
||||
return scriptTypes.map((t) => ({
|
||||
...t,
|
||||
disabled: !isScriptTypeAvailable(runtime, t.value),
|
||||
}));
|
||||
}
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
shell: 'border-green-500/30 dg-text-green bg-green-500/10',
|
||||
bash: 'border-green-500/30 dg-text-green bg-green-500/10',
|
||||
@@ -72,25 +70,4 @@ export const getTypeBadge = (type: string) => (
|
||||
// 生成唯一脚本编码
|
||||
export const genCode = () => `sc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// 执行状态 Badge
|
||||
export function getStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />等待中</Badge>;
|
||||
case 'running':
|
||||
return <Badge variant="default" className="bg-blue-500 gap-1"><Loader2 className="w-3 h-3 animate-spin" />执行中</Badge>;
|
||||
case 'success':
|
||||
return <Badge variant="default" className="bg-green-600 gap-1"><CheckCircle className="w-3 h-3" />成功</Badge>;
|
||||
case 'failed':
|
||||
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />失败</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status || '未知'}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
// 日期格式化
|
||||
export const formatDate = (s: string | null) => {
|
||||
if (!s) return '-';
|
||||
const d = new Date(s);
|
||||
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleString('zh-CN', { hour12: false });
|
||||
};
|
||||
@@ -14,19 +14,23 @@ function Checkbox({
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer size-4 shrink-0 rounded-[4px] border shadow-xs outline-none transition-colors",
|
||||
"border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]",
|
||||
"data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600 data-[state=checked]:text-white",
|
||||
"focus-visible:ring-2 focus-visible:ring-[var(--dg-blue)]/40",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
className="flex items-center justify-center text-white"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
<CheckIcon className="size-3.5 stroke-[3]" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox }
|
||||
@@ -13,7 +13,12 @@ function Switch({
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border transition-all outline-none",
|
||||
"data-[state=checked]:bg-primary data-[state=checked]:border-primary",
|
||||
"data-[state=unchecked]:bg-[var(--dg-bg-tertiary)] data-[state=unchecked]:border-[var(--dg-border-hover)]",
|
||||
"data-[state=unchecked]:shadow-[inset_0_1px_2px_rgba(0,0,0,0.18)] dark:data-[state=unchecked]:shadow-[inset_0_1px_3px_rgba(0,0,0,0.45)]",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -21,7 +26,9 @@ function Switch({
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-[var(--dg-bg-primary)] pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
"pointer-events-none block size-4 rounded-full ring-0 transition-transform shadow-sm",
|
||||
"bg-[var(--dg-bg-primary)] data-[state=checked]:translate-x-[calc(100%-2px)]",
|
||||
"data-[state=unchecked]:translate-x-0 data-[state=unchecked]:bg-[var(--dg-text-secondary)]",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
|
||||
@@ -11,9 +11,12 @@ export { usePipelines, usePipelineRuns, usePipelineMutations } from './usePipeli
|
||||
export {
|
||||
useAlertRules,
|
||||
useAgentAlertRules,
|
||||
useUnifiedAlertRules,
|
||||
useMetricCatalog,
|
||||
useAlerts,
|
||||
useAlertMutations,
|
||||
type AlertListResult,
|
||||
} from './useAlerts';
|
||||
export { useAuditLogs } from './useAuditLogs';
|
||||
export { useSessionCommands, type SessionCommand } from './useSessionCommands';
|
||||
export { useSessionCommands, type SessionCommand } from './useSessionCommands';
|
||||
export { useQueryRefresh } from '../useQueryRefresh';
|
||||
@@ -4,7 +4,7 @@ export const queryKeys = {
|
||||
terminalSessions: (params?: Record<string, unknown>) => ['terminal-sessions', params] as const,
|
||||
hostMonitor: {
|
||||
hosts: () => ['host-monitor', 'hosts'] as const,
|
||||
history: (hostname: string, hours: number) => ['host-monitor', 'history', hostname, hours] as const,
|
||||
history: (hostname: string, rangeKey: string) => ['host-monitor', 'history', hostname, rangeKey] as const,
|
||||
probes: (hostname: string) => ['host-monitor', 'probes', hostname] as const,
|
||||
portPolicy: (hostname: string) => ['host-monitor', 'port-policy', hostname] as const,
|
||||
},
|
||||
@@ -14,6 +14,8 @@ export const queryKeys = {
|
||||
alerts: (params?: Record<string, unknown>) => ['alerts', params] as const,
|
||||
alertRules: () => ['alert-rules'] as const,
|
||||
agentAlertRules: () => ['agent-alert-rules'] as const,
|
||||
metricCatalog: () => ['metric-catalog'] as const,
|
||||
unifiedAlertRules: (category?: string) => ['unified-alert-rules', category] as const,
|
||||
auditLogs: (params?: Record<string, unknown>) => ['audit-logs', params] as const,
|
||||
sessionCommands: (params?: Record<string, unknown>) => ['session-commands', params] as const,
|
||||
k8sClusters: () => ['k8s-clusters'] as const,
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
// 告警规则 / 告警事件 — TanStack Query // Logan.Li
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { alertApi, alertCenterApi, agentApi } from '@/api';
|
||||
import { alertApi, alertCenterApi, agentApi, alertMetricsApi } from '@/api';
|
||||
import { queryKeys } from './queryKeys';
|
||||
import { unwrapList, unwrapTotal } from './unwrap';
|
||||
import { mapAlert, type Alert, type AlertRule } from '@/components/monitor/alert-center/alert-center-helpers';
|
||||
import { mapAlert, type Alert } from '@/components/monitor/alert-center/alert-center-helpers';
|
||||
import type { MetricCatalogCategory, UnifiedAlertRule } from '@/api';
|
||||
|
||||
type AlertListParams = {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: string;
|
||||
severity?: string;
|
||||
source_type?: string;
|
||||
};
|
||||
|
||||
export type AlertListResult = {
|
||||
@@ -19,15 +21,46 @@ export type AlertListResult = {
|
||||
stats: { firing: number; critical: number; warning: number; resolved: number };
|
||||
};
|
||||
|
||||
function buildAlertStats(list: Alert[]) {
|
||||
function buildAlertStats(stats?: Record<string, number>, list?: Alert[]): AlertListResult['stats'] {
|
||||
if (stats) {
|
||||
return {
|
||||
firing: stats.firing ?? 0,
|
||||
critical: stats.critical ?? 0,
|
||||
warning: stats.warning ?? 0,
|
||||
resolved: stats.resolved ?? 0,
|
||||
};
|
||||
}
|
||||
const items = list || [];
|
||||
return {
|
||||
firing: list.filter((a) => a.status === 'firing').length,
|
||||
critical: list.filter((a) => a.severity === 'critical' && a.status === 'firing').length,
|
||||
warning: list.filter((a) => a.severity === 'warning' && a.status === 'firing').length,
|
||||
resolved: list.filter((a) => a.status === 'resolved').length,
|
||||
firing: items.filter((a) => a.status === 'firing').length,
|
||||
critical: items.filter((a) => a.severity === 'critical' && a.status === 'firing').length,
|
||||
warning: items.filter((a) => a.severity === 'warning' && a.status === 'firing').length,
|
||||
resolved: items.filter((a) => a.status === 'resolved').length,
|
||||
};
|
||||
}
|
||||
|
||||
export function useMetricCatalog() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.metricCatalog(),
|
||||
queryFn: async () => {
|
||||
const data = await alertMetricsApi.getCatalog();
|
||||
return (data as { categories?: MetricCatalogCategory[] })?.categories || [];
|
||||
},
|
||||
staleTime: 300_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnifiedAlertRules(category = 'all') {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.unifiedAlertRules(category),
|
||||
queryFn: async () => {
|
||||
const data = await alertMetricsApi.getUnifiedRules(category);
|
||||
return (data as { list?: UnifiedAlertRule[] })?.list || [];
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertRules() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.alertRules(),
|
||||
@@ -39,44 +72,29 @@ export function useAlertRules() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Agent 主机告警规则(告警中心「规则」Tab) // Logan.Li */
|
||||
/** @deprecated 使用 useUnifiedAlertRules */
|
||||
export function useAgentAlertRules() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.agentAlertRules(),
|
||||
queryFn: async () => {
|
||||
const res = await agentApi.listRules();
|
||||
const raw = unwrapList<Record<string, unknown>>(res);
|
||||
return raw.map((r) => ({
|
||||
id: r.id as number,
|
||||
name: String(r.name || ''),
|
||||
description: `${r.metric} ${r.operator} ${r.threshold}%`,
|
||||
enabled: Boolean(r.enabled),
|
||||
severity: 'warning' as const,
|
||||
type: String(r.metric || ''),
|
||||
hostname: String(r.hostname || '全部主机'),
|
||||
threshold: Number(r.threshold || 0),
|
||||
})) as AlertRule[];
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
return useUnifiedAlertRules('host');
|
||||
}
|
||||
|
||||
export function useAlerts(params: AlertListParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.alerts(params),
|
||||
queryFn: async (): Promise<AlertListResult> => {
|
||||
const resp = await alertCenterApi.getAlerts({
|
||||
const resp = await alertMetricsApi.getUnifiedAlerts({
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
severity: params.severity !== 'all' ? params.severity : undefined,
|
||||
status: params.status !== 'all' ? params.status : undefined,
|
||||
source_type: params.source_type !== 'all' ? params.source_type : undefined,
|
||||
});
|
||||
const raw = unwrapList<Record<string, unknown>>(resp);
|
||||
const list = raw.map((item) => mapAlert(item as Parameters<typeof mapAlert>[0]));
|
||||
const list = raw.map((item) => mapAlert(item));
|
||||
const statsRaw = (resp as { stats?: Record<string, number> })?.stats;
|
||||
return {
|
||||
list,
|
||||
total: unwrapTotal(resp),
|
||||
stats: buildAlertStats(list),
|
||||
stats: buildAlertStats(statsRaw, list),
|
||||
};
|
||||
},
|
||||
staleTime: 15_000,
|
||||
@@ -86,8 +104,10 @@ export function useAlerts(params: AlertListParams = {}) {
|
||||
|
||||
export function useAlertMutations() {
|
||||
const qc = useQueryClient();
|
||||
const invalidateRules = () => qc.invalidateQueries({ queryKey: queryKeys.alertRules() });
|
||||
const invalidateAgentRules = () => qc.invalidateQueries({ queryKey: queryKeys.agentAlertRules() });
|
||||
const invalidateRules = () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.alertRules() });
|
||||
qc.invalidateQueries({ queryKey: ['unified-alert-rules'] });
|
||||
};
|
||||
const invalidateAlerts = () => qc.invalidateQueries({ queryKey: ['alerts'] });
|
||||
|
||||
const toggleRuleMutation = useMutation({
|
||||
@@ -103,27 +123,55 @@ export function useAlertMutations() {
|
||||
const toggleAgentRuleMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
|
||||
agentApi.updateRule(id, { enabled }),
|
||||
onSuccess: invalidateAgentRules,
|
||||
onSuccess: invalidateRules,
|
||||
});
|
||||
|
||||
const deleteAgentRuleMutation = useMutation({
|
||||
mutationFn: (id: number) => agentApi.deleteRule(id),
|
||||
onSuccess: invalidateAgentRules,
|
||||
onSuccess: invalidateRules,
|
||||
});
|
||||
|
||||
const deleteMonitorRuleMutation = useMutation({
|
||||
mutationFn: (id: number) => alertMetricsApi.deleteMonitorRule(id),
|
||||
onSuccess: invalidateRules,
|
||||
});
|
||||
|
||||
const toggleMonitorRuleMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
|
||||
alertMetricsApi.updateMonitorRule(id, { enabled }),
|
||||
onSuccess: invalidateRules,
|
||||
});
|
||||
|
||||
const ackMutation = useMutation({
|
||||
mutationFn: (id: number | string) => alertCenterApi.acknowledge(id),
|
||||
mutationFn: (id: string) => {
|
||||
const [source, rawId] = id.includes('-') ? id.split('-') : ['event', id];
|
||||
if (source === 'agent' || source === 'site' || source === 'event') {
|
||||
return alertMetricsApi.resolveUnified(source, Number(rawId));
|
||||
}
|
||||
return alertCenterApi.acknowledge(id);
|
||||
},
|
||||
onSuccess: invalidateAlerts,
|
||||
});
|
||||
|
||||
const resolveMutation = useMutation({
|
||||
mutationFn: (id: number | string) => alertCenterApi.resolve(id),
|
||||
mutationFn: (id: string) => {
|
||||
const [source, rawId] = id.includes('-') ? id.split('-') : ['event', id];
|
||||
if (source === 'agent' || source === 'site' || source === 'event') {
|
||||
return alertMetricsApi.resolveUnified(source, Number(rawId));
|
||||
}
|
||||
return alertCenterApi.resolve(id);
|
||||
},
|
||||
onSuccess: invalidateAlerts,
|
||||
});
|
||||
|
||||
const quickSetupMutation = useMutation({
|
||||
mutationFn: (scope: string) => alertCenterApi.quickSetup(scope),
|
||||
onSuccess: invalidateAgentRules,
|
||||
onSuccess: invalidateRules,
|
||||
});
|
||||
|
||||
const monitorQuickSetupMutation = useMutation({
|
||||
mutationFn: (category: string) => alertMetricsApi.monitorQuickSetup(category),
|
||||
onSuccess: invalidateRules,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -131,11 +179,13 @@ export function useAlertMutations() {
|
||||
deleteRuleMutation,
|
||||
toggleAgentRuleMutation,
|
||||
deleteAgentRuleMutation,
|
||||
deleteMonitorRuleMutation,
|
||||
toggleMonitorRuleMutation,
|
||||
ackMutation,
|
||||
resolveMutation,
|
||||
quickSetupMutation,
|
||||
monitorQuickSetupMutation,
|
||||
invalidateRules,
|
||||
invalidateAgentRules,
|
||||
invalidateAlerts,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { agentApi } from '@/api';
|
||||
import { queryKeys } from './queryKeys';
|
||||
import { unwrapList } from './unwrap';
|
||||
import type { HostMonitorTimeRange } from '@/pages/monitor/host-monitor/types';
|
||||
import { buildHistoryQuery, rangeQueryKey } from '@/pages/monitor/host-monitor/timeRange';
|
||||
|
||||
const HOST_REFRESH_MS = 30_000;
|
||||
|
||||
export function useHostList() {
|
||||
return useQuery({
|
||||
@@ -12,20 +16,23 @@ export function useHostList() {
|
||||
const data = await agentApi.getLatest();
|
||||
return unwrapList<Record<string, unknown>>(data);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
staleTime: HOST_REFRESH_MS,
|
||||
refetchInterval: HOST_REFRESH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function useHostHistory(hostname: string, hours: number) {
|
||||
export function useHostHistory(hostname: string, range: HostMonitorTimeRange) {
|
||||
const rangeKey = rangeQueryKey(range);
|
||||
return useQuery({
|
||||
queryKey: queryKeys.hostMonitor.history(hostname, hours),
|
||||
queryKey: queryKeys.hostMonitor.history(hostname, rangeKey),
|
||||
queryFn: async () => {
|
||||
if (!hostname) return [];
|
||||
const data = await agentApi.getHistory(hostname, hours);
|
||||
const data = await agentApi.getHistory(hostname, buildHistoryQuery(range));
|
||||
return unwrapList<Record<string, unknown>>(data);
|
||||
},
|
||||
enabled: !!hostname,
|
||||
staleTime: 30_000,
|
||||
staleTime: HOST_REFRESH_MS,
|
||||
refetchInterval: HOST_REFRESH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// useQuery 手动刷新辅助 — isLoading 仅首屏为 true,刷新需用 isFetching // Logan.Li
|
||||
import { useCallback } from 'react';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
type QueryRefreshSlice = Pick<UseQueryResult<unknown>, 'refetch' | 'isFetching' | 'isLoading'>;
|
||||
|
||||
/** 提取刷新中状态与刷新函数,供页面刷新按钮绑定 // Logan.Li */
|
||||
export function useQueryRefresh<T extends QueryRefreshSlice>(query: T) {
|
||||
const refresh = useCallback(() => query.refetch(), [query]);
|
||||
return {
|
||||
refresh,
|
||||
refreshing: query.isFetching,
|
||||
loading: query.isLoading,
|
||||
};
|
||||
}
|
||||
@@ -195,12 +195,28 @@
|
||||
"stat": {
|
||||
"servers": "Servers",
|
||||
"running": "Running",
|
||||
"cronJobs": "Cron Jobs",
|
||||
"siteProbes": "Site Probes",
|
||||
"kubernetes": "Kubernetes",
|
||||
"k8sClusters": "K8s Clusters",
|
||||
"healthyClusters": "Healthy",
|
||||
"alerts": "Alerts",
|
||||
"critical": "Critical",
|
||||
"monthlyCost": "Monthly Cost"
|
||||
},
|
||||
"ops": {
|
||||
"title": "Ops Overview",
|
||||
"description": "Assets, monitoring & task shortcuts",
|
||||
"monitorScreen": "Monitor Screen",
|
||||
"servers": "Servers",
|
||||
"domains": "Domains",
|
||||
"hostMonitor": "Host Monitor",
|
||||
"cronJobs": "Cron Jobs",
|
||||
"siteProbes": "Site Probes",
|
||||
"middleware": "Middleware",
|
||||
"todayExecutions": "Executions Today",
|
||||
"firingAlerts": "Firing Alerts"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Alert Messages",
|
||||
"description": "Recently triggered alert events",
|
||||
@@ -250,6 +266,8 @@
|
||||
"error": "Error",
|
||||
"nodes": "Nodes",
|
||||
"pods": "Pods",
|
||||
"ingress": "Ingress",
|
||||
"deployments": "Deployments",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory"
|
||||
},
|
||||
|
||||
@@ -195,12 +195,28 @@
|
||||
"stat": {
|
||||
"servers": "服务器",
|
||||
"running": "运行中",
|
||||
"cronJobs": "定时任务",
|
||||
"siteProbes": "站点拨测",
|
||||
"kubernetes": "Kubernetes",
|
||||
"k8sClusters": "K8s 集群",
|
||||
"healthyClusters": "健康集群",
|
||||
"alerts": "告警",
|
||||
"critical": "严重",
|
||||
"monthlyCost": "本月成本"
|
||||
},
|
||||
"ops": {
|
||||
"title": "运维概览",
|
||||
"description": "资产、监控与任务快捷入口",
|
||||
"monitorScreen": "监控大屏",
|
||||
"servers": "服务器",
|
||||
"domains": "域名",
|
||||
"hostMonitor": "主机监控",
|
||||
"cronJobs": "定时任务",
|
||||
"siteProbes": "站点拨测",
|
||||
"middleware": "中间件",
|
||||
"todayExecutions": "今日执行",
|
||||
"firingAlerts": "活跃告警"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "告警消息",
|
||||
"description": "最近触发的告警事件",
|
||||
@@ -250,6 +266,8 @@
|
||||
"error": "异常",
|
||||
"nodes": "节点",
|
||||
"pods": "Pod",
|
||||
"ingress": "Ingress",
|
||||
"deployments": "Deployment",
|
||||
"cpu": "CPU",
|
||||
"memory": "内存"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { approvalWorkflowApi, userApi } from '@/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
@@ -66,7 +69,30 @@ const STEP_TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
execute: Play,
|
||||
};
|
||||
|
||||
/** 解析审批人 ID 列表 // Logan.Li */
|
||||
function parseApproverIds(ids: string | null): number[] {
|
||||
if (!ids) return [];
|
||||
try {
|
||||
const p = JSON.parse(ids);
|
||||
return Array.isArray(p) ? p : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** 查找 admin 用户 ID,用于新建流程默认审批/执行人 // Logan.Li */
|
||||
function resolveAdminId(users: UserItem[]): number[] {
|
||||
const admin = users.find((u) => u.username === 'admin');
|
||||
return admin ? [admin.id] : [];
|
||||
}
|
||||
|
||||
/** 新建流程默认步骤:提交 → admin 审批 → admin 执行 // Logan.Li */
|
||||
function buildDefaultSteps(users: UserItem[], t: (key: string) => string): StepDraft[] {
|
||||
const adminIds = resolveAdminId(users);
|
||||
return [
|
||||
{ name: t('approval.detail.submitNode'), step_type: 'submit', approver_ids: [] },
|
||||
{ name: t('approval.step.auditShort'), step_type: 'audit', approver_ids: adminIds },
|
||||
{ name: t('approval.step.executeShort'), step_type: 'execute', approver_ids: adminIds },
|
||||
];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* UserSelectInline */
|
||||
@@ -210,25 +236,13 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
step_type: (s.step_type === 'serial' || s.step_type === 'parallel')
|
||||
? ('audit' as const)
|
||||
: (s.step_type as 'submit' | 'audit' | 'execute'),
|
||||
approver_ids: parseIds(s.approver_ids),
|
||||
approver_ids: parseApproverIds(s.approver_ids),
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setSteps([
|
||||
{ name: t('approval.detail.submitNode'), step_type: 'submit', approver_ids: [] },
|
||||
{ name: t('approval.step.auditShort'), step_type: 'audit', approver_ids: [] },
|
||||
]);
|
||||
setSteps(buildDefaultSteps(users, t));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workflow]);
|
||||
|
||||
const parseIds = (ids: string | null): number[] => {
|
||||
if (!ids) return [];
|
||||
try {
|
||||
const p = JSON.parse(ids);
|
||||
return Array.isArray(p) ? p : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
}, [workflow, users, t]);
|
||||
|
||||
const addStep = (type: 'submit' | 'audit' | 'execute') => {
|
||||
const labels: Record<string, string> = {
|
||||
@@ -322,19 +336,18 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
const inputCls = 'w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors text-[var(--dg-text-primary)]';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--dg-border)]">
|
||||
<h2 className="text-base font-semibold text-[var(--dg-text-primary)]">
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="dg-card sm:max-w-2xl max-h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-5 py-4 border-b border-[var(--dg-border)] shrink-0">
|
||||
<DialogTitle className="text-base font-semibold text-[var(--dg-text-primary)]">
|
||||
{isEdit ? t('approval.editor.editTitle') : t('approval.editor.newTitle')}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{isEdit ? t('approval.editor.editTitle') : t('approval.editor.newTitle')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-5 pb-5 space-y-4 pt-4">
|
||||
<div className="px-5 pb-5 space-y-4 pt-4 overflow-y-auto flex-1 min-h-0">
|
||||
{error && <div className="px-3 py-2 text-sm dg-text-red bg-red-500/10 rounded-lg">{error}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -431,17 +444,17 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-[var(--dg-border)]">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">
|
||||
<DialogFooter className="px-5 py-4 border-t border-[var(--dg-border)] shrink-0">
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
</Button>
|
||||
<Button className="dg-btn-primary" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin mr-1" />}
|
||||
{isEdit ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -491,10 +504,14 @@ export default function ApprovalWorkflowPage() {
|
||||
const res: any = await userApi.getUsers();
|
||||
const data = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : []);
|
||||
// Normalize: extract role code from roles array
|
||||
const normalized = data.map((u: any) => ({
|
||||
...u,
|
||||
role: u.role || (Array.isArray(u.roles) && u.roles.length > 0 ? u.roles[0].code : ''),
|
||||
}));
|
||||
const normalized = data.map((u: any) => {
|
||||
let role = u.role ?? '';
|
||||
if (!role && Array.isArray(u.roles) && u.roles.length > 0) {
|
||||
const first = u.roles[0];
|
||||
role = typeof first === 'string' ? first : (first?.code ?? first?.name ?? '');
|
||||
}
|
||||
return { ...u, role };
|
||||
});
|
||||
setUsers(normalized);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
@@ -547,6 +564,7 @@ export default function ApprovalWorkflowPage() {
|
||||
<>
|
||||
<PageShell
|
||||
variant="list"
|
||||
maxWidth="xl"
|
||||
title={t('approval.workflowTitle')}
|
||||
subtitle={t('approval.workflowSubtitle')}
|
||||
icon={<GitBranch className="h-5 w-5" />}
|
||||
@@ -577,13 +595,13 @@ export default function ApprovalWorkflowPage() {
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
{items.map((wf) => {
|
||||
const isExpanded = expandedId === wf.id;
|
||||
return (
|
||||
<Card key={wf.id} className={`dg-card overflow-hidden ${!wf.is_active ? 'opacity-50' : ''}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<Card key={wf.id} className={`dg-card overflow-hidden ${!wf.is_active ? 'opacity-60' : ''}`}>
|
||||
<CardContent className="p-4 sm:p-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{wf.name}</h3>
|
||||
@@ -610,7 +628,7 @@ export default function ApprovalWorkflowPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-4">
|
||||
<div className="flex items-center gap-1 flex-shrink-0 sm:ml-4 self-end sm:self-start">
|
||||
<button onClick={() => setExpandedId(isExpanded ? null : wf.id)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] transition-colors">
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
@@ -640,7 +658,7 @@ export default function ApprovalWorkflowPage() {
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-[var(--dg-border)] space-y-3">
|
||||
{wf.steps?.map((s, i) => {
|
||||
const ids = (() => { try { const p = JSON.parse(s.approver_ids || '[]'); return Array.isArray(p) ? p : []; } catch { return []; } })();
|
||||
const ids = parseApproverIds(s.approver_ids);
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||
<div className="w-6 h-6 rounded-full bg-[var(--dg-bg-tertiary)] flex items-center justify-center text-xs font-medium text-[var(--dg-text-secondary)] flex-shrink-0 mt-0.5">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
|
||||
// v2: 虚拟滚动渲染,支持万级日志流畅查看
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -66,7 +67,7 @@ export function AuditOperationsTab() {
|
||||
...(actionFilter ? { action: actionFilter } : {}),
|
||||
}), [moduleFilter, actionFilter]);
|
||||
|
||||
const { data: auditData, isLoading: loading, refetch: refetchLogs } = useAuditLogs(auditParams);
|
||||
const { data: auditData, isLoading: loading, isFetching, refetch: refetchLogs } = useAuditLogs(auditParams);
|
||||
const logs = useMemo(
|
||||
() => (auditData?.list || []).map((raw) => normalizeLog(raw)),
|
||||
[auditData],
|
||||
@@ -219,8 +220,8 @@ export function AuditOperationsTab() {
|
||||
toolbar={toolbar}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchLogs()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchLogs()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />{t('common.refresh')}
|
||||
</Button>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={handleExport}>
|
||||
<Download className="h-4 w-4 mr-2" />{t('common.export')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 终端审计 — 在线会话子面板 // Logan.Li
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -108,7 +109,7 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { killMutation } = useTerminalSessionMutations();
|
||||
const { data, isLoading, refetch } = useTerminalSessions({
|
||||
const { data, isLoading, isFetching, refetch } = useTerminalSessions({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
status: 'active',
|
||||
@@ -177,8 +178,9 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
|
||||
variant="outline"
|
||||
className="dg-btn-secondary"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 终端审计 — 历史会话子面板(筛选 / 分页 / 回放 / 导出,只读无删除) // Logan.Li
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -46,7 +47,7 @@ export function HistorySessionsPanel() {
|
||||
...(applied.hasRecordOnly ? { has_record: '1' } : {}),
|
||||
}), [page, applied]);
|
||||
|
||||
const { data, isLoading, refetch } = useTerminalSessions(queryParams);
|
||||
const { data, isLoading, isFetching, refetch } = useTerminalSessions(queryParams);
|
||||
const sessions = data?.list ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
@@ -127,8 +128,8 @@ export function HistorySessionsPanel() {
|
||||
{t('audit.terminal.hasRecordOnly')}
|
||||
</label>
|
||||
<Button size="sm" className="h-9" onClick={applyFilters}>{t('audit.terminal.applyFilter')}</Button>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />{t('common.refresh')}
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-1', isFetching && 'animate-spin')} />{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function SessionCommandsPanel() {
|
||||
...(applied.endDate ? { end_date: applied.endDate } : {}),
|
||||
}), [applied]);
|
||||
|
||||
const { data, isLoading, refetch } = useSessionCommands(queryParams);
|
||||
const { data, isLoading, isFetching, refetch } = useSessionCommands(queryParams);
|
||||
const commands = data?.list ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
@@ -153,8 +153,9 @@ export function SessionCommandsPanel() {
|
||||
variant="outline"
|
||||
className="dg-btn-secondary h-9"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 主机监控详情 — 阿里云云监控风格 + v2.0 新增指标
|
||||
// 进程TOP / Swap / FD / 上下文切换 // Logan.Li
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { agentApi } from '@/api';
|
||||
@@ -17,16 +17,19 @@ import {
|
||||
Cpu, HardDrive, Network, Activity, Server,
|
||||
Info, Monitor, Disc, Clock, List, HeartPulse, RefreshCw, XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { TIME_RANGES, type AggType, type HostRecord } from './host-monitor/types';
|
||||
import { parseJSON, formatBPS, buildMetricsFromHistory, getCurrentVal } from './host-monitor/utils';
|
||||
import { ZoomableAreaChart, type ZoomableChartHandle } from './host-monitor/ZoomableAreaChart';
|
||||
import { ChartToolbar } from './host-monitor/ChartToolbar';
|
||||
import { MetricIconBox } from './host-monitor/MetricIconBox';
|
||||
import { DEFAULT_TIME_RANGE, type AggType, type HostRecord, type HostMonitorTimeRange } from './host-monitor/types';
|
||||
import { HostTimeRangePicker } from './host-monitor/HostTimeRangePicker';
|
||||
import { getRangeSpanMinutes } from './host-monitor/timeRange';
|
||||
import { parseJSON, formatBPS, buildMetricsFromHistory, getCurrentVal, filterHistoryByRange, getActualDataSpan } from './host-monitor/utils';
|
||||
import { MetricChartCard } from './host-monitor/MetricChartCard';
|
||||
import { DualLineChart } from './host-monitor/DualLineChart';
|
||||
import { HealthProbeSection } from './host-monitor/HealthProbeSection';
|
||||
import { PortPolicyDialog } from './host-monitor/PortPolicyDialog';
|
||||
import { SystemInfoSection } from './host-monitor/SystemInfoSection';
|
||||
import { HostCoreMetricsStrip } from './host-monitor/HostCoreMetricsStrip';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
@@ -35,16 +38,18 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
const urlHost = searchParams.get('host') || '';
|
||||
const [selectedHost, setSelectedHost] = useState(urlHost);
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
const [rangeHours, setRangeHours] = useState(1);
|
||||
const [timeRange, setTimeRange] = useState<HostMonitorTimeRange>(DEFAULT_TIME_RANGE);
|
||||
const [aggType, setAggType] = useState<AggType>('avg');
|
||||
|
||||
const [portPolicyMode, setPortPolicyMode] = useState('whitelist');
|
||||
const [portWhitelist, setPortWhitelist] = useState<number[]>([]);
|
||||
const [portPolicyDialogOpen, setPortPolicyDialogOpen] = useState(false);
|
||||
const [portPolicySaving, setPortPolicySaving] = useState(false);
|
||||
const fdChartRef = useRef<ZoomableChartHandle>(null);
|
||||
const [fdChartZoomed, setFdChartZoomed] = useState(false);
|
||||
|
||||
const { data: hosts = [], isLoading: hostsLoading } = useHostList();
|
||||
const { data: history = [], isLoading: historyLoading } = useHostHistory(selectedHost, rangeHours);
|
||||
const { data: rawHistory = [], isLoading: historyLoading } = useHostHistory(selectedHost, timeRange);
|
||||
const { data: portPolicy, refetch: refetchPortPolicy } = usePortPolicy(selectedHost);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -74,7 +79,19 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
} catch { } finally { setPortPolicySaving(false); }
|
||||
};
|
||||
|
||||
const timeConfig = TIME_RANGES.find(tr => tr.hours === rangeHours) || TIME_RANGES[0];
|
||||
const history = useMemo(
|
||||
() => filterHistoryByRange(rawHistory, timeRange),
|
||||
[rawHistory, timeRange],
|
||||
);
|
||||
const spanMinutes = getRangeSpanMinutes(timeRange);
|
||||
const timeConfig = useMemo(
|
||||
() => ({ buckets: timeRange.buckets, spanMinutes }),
|
||||
[timeRange.buckets, spanMinutes],
|
||||
);
|
||||
const actualDataSpan = useMemo(
|
||||
() => getActualDataSpan(history, timeRange),
|
||||
[history, timeRange],
|
||||
);
|
||||
const loading = hostsLoading || historyLoading;
|
||||
const serverInfo = (hosts.find((h) => h.hostname === selectedHost) || null) as HostRecord | null;
|
||||
const latest = useMemo((): HostRecord => {
|
||||
@@ -97,6 +114,11 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
const currentVal = (key: string, fallback = '--') =>
|
||||
getCurrentVal(latest, netInfo, diskIOInfo, swapInfo, fdInfo, key, fallback);
|
||||
|
||||
const pctRefLines = [
|
||||
{ value: 80, label: '80%', color: 'var(--dg-yellow)' },
|
||||
{ value: 90, label: '90%', color: 'var(--dg-red)' },
|
||||
];
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex items-center justify-between flex-wrap gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -127,12 +149,11 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
<SelectItem value="min">最小值</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1">
|
||||
{TIME_RANGES.map(tr => (
|
||||
<Button key={tr.hours} variant={rangeHours === tr.hours ? 'default' : 'outline'} size="sm"
|
||||
className={`h-7 text-xs px-2.5 ${rangeHours === tr.hours ? 'dg-btn-primary' : 'dg-btn-secondary'}`}
|
||||
onClick={() => setRangeHours(tr.hours)}>{tr.label}</Button>
|
||||
))}
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<HostTimeRangePicker value={timeRange} onChange={setTimeRange} />
|
||||
{actualDataSpan && (
|
||||
<span className="text-[10px] dg-text-muted tabular-nums">数据 {actualDataSpan}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,63 +163,64 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="h-8 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] gap-0 overflow-x-auto">
|
||||
{[
|
||||
{ id: 'basic', label: '基础监控', icon: <Monitor className="w-3 h-3" /> },
|
||||
{ id: 'disk', label: '磁盘监控', icon: <HardDrive className="w-3 h-3" /> },
|
||||
{ id: 'network', label: '网络监控', icon: <Network className="w-3 h-3" /> },
|
||||
{ id: 'ports', label: '端口监控', icon: <Server className="w-3 h-3" /> },
|
||||
{ id: 'process', label: '进程监控', icon: <List className="w-3 h-3" /> },
|
||||
{ id: 'system', label: '系统信息', icon: <Info className="w-3 h-3" /> },
|
||||
{ id: 'health_probes', label: '健康探针', icon: <HeartPulse className="w-3 h-3" /> },
|
||||
{ id: 'basic', label: '基础监控', icon: <Monitor className="w-3.5 h-3.5" /> },
|
||||
{ id: 'disk', label: '磁盘监控', icon: <HardDrive className="w-3.5 h-3.5" /> },
|
||||
{ id: 'network', label: '网络监控', icon: <Network className="w-3.5 h-3.5" /> },
|
||||
{ id: 'ports', label: '端口监控', icon: <Server className="w-3.5 h-3.5" /> },
|
||||
{ id: 'process', label: '进程监控', icon: <List className="w-3.5 h-3.5" /> },
|
||||
{ id: 'system', label: '系统信息', icon: <Info className="w-3.5 h-3.5" /> },
|
||||
{ id: 'health_probes', label: '健康探针', icon: <HeartPulse className="w-3.5 h-3.5" /> },
|
||||
].map(tab => (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="text-[11px] h-7 gap-1 data-[state=active]:bg-[var(--dg-bg-primary)]">
|
||||
{tab.icon}{tab.label}
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="text-[11px] h-7 gap-1.5 px-3 data-[state=active]:bg-[var(--dg-bg-primary)]">
|
||||
<span className="shrink-0 opacity-80">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<MetricChartCard title="CPU 使用率(%)" value={currentVal('cpu')} icon={<Cpu className="h-4 w-4 dg-text-blue" />}
|
||||
data={metrics.cpu} color="#3b82f6" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="CPU IO等待(%)" value={latest?.cpu_iowait != null ? `${(latest.cpu_iowait as number).toFixed(1)}%` : '--'} icon={<Clock className="h-4 w-4 dg-text-orange" />}
|
||||
data={metrics.iowait} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="内存使用率(%)" value={currentVal('mem')} icon={<Server className="h-4 w-4 dg-text-green" />}
|
||||
data={metrics.mem} color="#10b981" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="可用内存(GB)" value={latest?.mem_available != null ? `${(latest.mem_available as number).toFixed(1)}GB` : '--'} icon={<Server className="h-4 w-4 dg-text-green" />}
|
||||
<MetricChartCard title="CPU 使用率(%)" value={currentVal('cpu')} icon={<Cpu />} iconTone="blue"
|
||||
data={metrics.cpu} color="#3b82f6" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
|
||||
<MetricChartCard title="CPU IO等待(%)" value={latest?.cpu_iowait != null ? `${(latest.cpu_iowait as number).toFixed(1)}%` : '--'} icon={<Clock />} iconTone="orange"
|
||||
data={metrics.iowait} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
|
||||
<MetricChartCard title="内存使用率(%)" value={currentVal('mem')} icon={<Server />} iconTone="green"
|
||||
data={metrics.mem} color="#10b981" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
|
||||
<MetricChartCard title="可用内存(GB)" value={latest?.mem_available != null ? `${(latest.mem_available as number).toFixed(1)}GB` : '--'} icon={<Server />} iconTone="green"
|
||||
data={metrics.memAvail} color="#34d399" unit="GB" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="Swap 使用率(%)" value={currentVal('swap')} icon={<HardDrive className="h-4 w-4 dg-text-red" />}
|
||||
<MetricChartCard title="Swap 使用率(%)" value={currentVal('swap')} icon={<HardDrive />} iconTone="red"
|
||||
data={metrics.swap} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="系统负载(1m)" value={currentVal('load1')} icon={<Activity className="h-4 w-4 dg-text-purple" />}
|
||||
<MetricChartCard title="系统负载(1m)" value={currentVal('load1')} icon={<Activity />} iconTone="purple"
|
||||
data={metrics.load1} color="#8b5cf6" unit="" domain={[0, 'auto']} aggType={aggType} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="disk" className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<MetricChartCard title="磁盘使用率(%)" value={currentVal('disk')} icon={<HardDrive className="h-4 w-4 dg-text-orange" />}
|
||||
data={metrics.disk} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="磁盘使用率(%)" value={currentVal('disk')} icon={<HardDrive />} iconTone="orange"
|
||||
data={metrics.disk} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
|
||||
<MetricChartCard title="Inode 使用率(%)" value={(() => {
|
||||
const dArr = Array.isArray(parseJSON(latest?.disk_info)) ? parseJSON(latest?.disk_info) : [];
|
||||
const root = dArr.find((d: JsonData) => d.mount === '/') || dArr[0];
|
||||
return root?.inode_usage_pct != null ? `${root.inode_usage_pct.toFixed(1)}%` : '--';
|
||||
})()} icon={<HardDrive className="h-4 w-4 dg-text-purple" />}
|
||||
data={metrics.inode} color="#8b5cf6" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="磁盘读取(KB/s)" value={currentVal('ioR')} icon={<Disc className="h-4 w-4 dg-text-blue" />}
|
||||
})()} icon={<HardDrive />} iconTone="purple"
|
||||
data={metrics.inode} color="#8b5cf6" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
|
||||
<MetricChartCard title="磁盘读取(KB/s)" value={currentVal('ioR')} icon={<Disc />} iconTone="blue"
|
||||
data={metrics.ioRead} color="#3b82f6" unit="KB/s" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="磁盘写入(KB/s)" value={currentVal('ioW')} icon={<Disc className="h-4 w-4 dg-text-green" />}
|
||||
<MetricChartCard title="磁盘写入(KB/s)" value={currentVal('ioW')} icon={<Disc />} iconTone="green"
|
||||
data={metrics.ioWrite} color="#10b981" unit="KB/s" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="磁盘IO繁忙度(%)" value={diskIOInfo?.util != null ? `${diskIOInfo.util.toFixed(1)}%` : '--'} icon={<Disc className="h-4 w-4 dg-text-red" />}
|
||||
data={metrics.ioUtil} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="磁盘IO繁忙度(%)" value={diskIOInfo?.util != null ? `${diskIOInfo.util.toFixed(1)}%` : '--'} icon={<Disc />} iconTone="red"
|
||||
data={metrics.ioUtil} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network" className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<MetricChartCard title="TCP EST" value={currentVal('tcp')} icon={<Network className="h-4 w-4 dg-text-purple" />}
|
||||
<MetricChartCard title="TCP EST" value={currentVal('tcp')} icon={<Network />} iconTone="purple"
|
||||
data={metrics.tcpEst} color="#8b5cf6" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="TCP TIME_WAIT" value={currentVal('tw')} icon={<Clock className="h-4 w-4 dg-text-yellow" />}
|
||||
<MetricChartCard title="TCP TIME_WAIT" value={currentVal('tw')} icon={<Clock />} iconTone="yellow"
|
||||
data={metrics.tcpTW} color="#f59e0b" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="TCP CLOSE_WAIT" value={netInfo?.tcp_close_wait != null ? String(netInfo.tcp_close_wait) : '--'} icon={<Clock className="h-4 w-4 dg-text-red" />}
|
||||
<MetricChartCard title="TCP CLOSE_WAIT" value={netInfo?.tcp_close_wait != null ? String(netInfo.tcp_close_wait) : '--'} icon={<Clock />} iconTone="red"
|
||||
data={metrics.tcpCW} color="#ef4444" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="TCP 总连接" value={netInfo ? String((netInfo.tcp_established||0)+(netInfo.tcp_time_wait||0)+(netInfo.tcp_close_wait||0)) : '--'} icon={<Network className="h-4 w-4 dg-text-cyan" />}
|
||||
<MetricChartCard title="TCP 总连接" value={netInfo ? String((netInfo.tcp_established||0)+(netInfo.tcp_time_wait||0)+(netInfo.tcp_close_wait||0)) : '--'} icon={<Network />} iconTone="cyan"
|
||||
data={metrics.connTotal} color="#22d3ee" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
</div>
|
||||
<DualLineChart
|
||||
@@ -209,9 +231,9 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
aggType={aggType} unit="KB/s"
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
<MetricChartCard title="网卡接收错误" value={netInfo?.net_rx_errors != null ? String(netInfo.net_rx_errors) : '--'} icon={<XCircle className="h-4 w-4 dg-text-red" />}
|
||||
<MetricChartCard title="网卡接收错误" value={netInfo?.net_rx_errors != null ? String(netInfo.net_rx_errors) : '--'} icon={<XCircle />} iconTone="red"
|
||||
data={metrics.rxErr} color="#ef4444" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="网卡发送错误" value={netInfo?.net_tx_errors != null ? String(netInfo.net_tx_errors) : '--'} icon={<XCircle className="h-4 w-4 dg-text-orange" />}
|
||||
<MetricChartCard title="网卡发送错误" value={netInfo?.net_tx_errors != null ? String(netInfo.net_tx_errors) : '--'} icon={<XCircle />} iconTone="orange"
|
||||
data={metrics.txErr} color="#f97316" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -359,9 +381,19 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 dg-text-purple" />FD 文件句柄
|
||||
</h3>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] flex items-center gap-2 leading-8">
|
||||
<MetricIconBox icon={<HardDrive />} tone="purple" />
|
||||
FD 文件句柄
|
||||
</h3>
|
||||
{metrics.fd.length > 0 && (
|
||||
<ChartToolbar
|
||||
zoomed={fdChartZoomed}
|
||||
onExpand={() => fdChartRef.current?.expand()}
|
||||
onResetZoom={() => fdChartRef.current?.resetZoom()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{fdInfo?.allocated != null ? (
|
||||
<>
|
||||
<div className="flex items-center gap-4 mb-3 text-xs">
|
||||
@@ -381,23 +413,20 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
{metrics.fd.length > 0 && (
|
||||
<div className="h-[120px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={metrics.fd}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} unit="%" domain={[0, 'auto']} />
|
||||
<Tooltip contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 6, fontSize: 11 }} />
|
||||
<defs>
|
||||
<linearGradient id="fdGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#a78bfa" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#a78bfa" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area type="monotone" dataKey={aggType} stroke="#a78bfa" strokeWidth={1.5} fill="url(#fdGrad)" name="FD使用率" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ZoomableAreaChart
|
||||
ref={fdChartRef}
|
||||
data={metrics.fd.map((d) => ({ ...d, value: d[aggType] }))}
|
||||
series={[{ dataKey: 'value', color: '#a78bfa', label: 'FD 使用率' }]}
|
||||
yDomain={[0, 'auto']}
|
||||
unit="%"
|
||||
chartId="fd-usage"
|
||||
title="FD 文件句柄使用率"
|
||||
subtitle={fdInfo?.usage_pct != null ? `当前 ${fdInfo.usage_pct.toFixed(1)}%` : undefined}
|
||||
height={160}
|
||||
toolbarPlacement="header"
|
||||
onZoomedChange={setFdChartZoomed}
|
||||
showHint={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
@@ -483,6 +512,14 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{toolbar}
|
||||
{!loading && history.length > 0 && (
|
||||
<HostCoreMetricsStrip
|
||||
latest={latest}
|
||||
netInfo={netInfo}
|
||||
diskIOInfo={diskIOInfo}
|
||||
onJump={setActiveTab}
|
||||
/>
|
||||
)}
|
||||
{loading && !history.length ? <PageLoading /> : monitorBody}
|
||||
{dialogs}
|
||||
</div>
|
||||
|
||||
@@ -1,119 +1,91 @@
|
||||
// 中间件监控 — Redis / MySQL 指标采集(后端直连模式 M15)
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
// 中间件监控 — Redis(单机/集群)/ MySQL 可视化可观测 // Logan.Li
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter,
|
||||
DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Database, Server, Activity, Loader2, Circle, Plus,
|
||||
Trash2, Pencil, Zap, RefreshCw, Settings2,
|
||||
} from 'lucide-react';
|
||||
import { Database, Server, Activity, Loader2, Plus, Zap, RefreshCw } from 'lucide-react';
|
||||
import { middlewareMonitorApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { MwOverviewCharts } from './middleware-monitor/MwOverviewCharts';
|
||||
import { MwTargetChartsPanel } from './middleware-monitor/MwTargetChartsPanel';
|
||||
import { InstanceCard } from './middleware-monitor/InstanceCard';
|
||||
import { MiddlewareDialog } from './middleware-monitor/MiddlewareDialog';
|
||||
import type { MwTarget } from './middleware-monitor/helpers';
|
||||
import { REDIS_CHARTS, MYSQL_CHARTS, evalMwHealth, worstHealthLevel } from './middleware-monitor/helpers';
|
||||
|
||||
interface MwTarget {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
description: string; // 子类型 redis/mysql
|
||||
latest_metrics: Record<string, number>;
|
||||
last_seen_at?: string;
|
||||
}
|
||||
type TabKey = 'redis' | 'mysql';
|
||||
const REFRESH_INTERVAL_MS = 30_000;
|
||||
|
||||
interface MwListResponse {
|
||||
list: MwTarget[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
function fmtMemory(bytes: number): string {
|
||||
if (bytes == null || bytes === 0) return '--';
|
||||
if (bytes > 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
if (bytes > 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
|
||||
if (bytes > 1e3) return `${(bytes / 1e3).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function fmtHitRate(v: number): string {
|
||||
if (v == null || Number.isNaN(v)) return '--';
|
||||
return `${(v * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function fmtVal(v: number, precision = 0): string {
|
||||
if (v == null || Number.isNaN(v)) return '--';
|
||||
return v.toFixed(precision);
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'online': return 'dg-text-green';
|
||||
case 'warning': return 'dg-text-yellow';
|
||||
case 'error':
|
||||
case 'offline': return 'dg-text-red';
|
||||
default: return 'dg-text-muted';
|
||||
}
|
||||
}
|
||||
|
||||
function statusBg(status: string): string {
|
||||
switch (status) {
|
||||
case 'online': return 'bg-emerald-400';
|
||||
case 'warning': return 'bg-amber-400';
|
||||
case 'error':
|
||||
case 'offline': return 'bg-red-400';
|
||||
default: return 'bg-[var(--dg-bg-active)]';
|
||||
}
|
||||
}
|
||||
interface MwListResponse { list: MwTarget[]; total: number }
|
||||
|
||||
export function MiddlewareMonitor() {
|
||||
const confirm = useConfirm();
|
||||
const [targets, setTargets] = useState<MwTarget[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [tab, setTab] = useState<TabKey>('redis');
|
||||
const [selectedRedis, setSelectedRedis] = useState<MwTarget | null>(null);
|
||||
const [selectedMysql, setSelectedMysql] = useState<MwTarget | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<MwTarget | null>(null);
|
||||
const [testing, setTesting] = useState<number | null>(null);
|
||||
const [seeding, setSeeding] = useState(false);
|
||||
const [lastRefreshAt, setLastRefreshAt] = useState<Date | null>(null);
|
||||
const hasLoaded = useRef(false);
|
||||
|
||||
const loadTargets = useCallback(async () => {
|
||||
const syncSelection = useCallback((list: MwTarget[]) => {
|
||||
const redis = list.filter((t) => t.description === 'redis');
|
||||
const mysql = list.filter((t) => t.description === 'mysql');
|
||||
setSelectedRedis((prev) => {
|
||||
if (prev && redis.some((t) => t.id === prev.id)) return redis.find((t) => t.id === prev.id) || null;
|
||||
return redis.find((t) => t.status === 'online') || redis[0] || null;
|
||||
});
|
||||
setSelectedMysql((prev) => {
|
||||
if (prev && mysql.some((t) => t.id === prev.id)) return mysql.find((t) => t.id === prev.id) || null;
|
||||
return mysql.find((t) => t.status === 'online') || mysql[0] || null;
|
||||
});
|
||||
if (redis.length === 0 && mysql.length > 0) setTab('mysql');
|
||||
}, []);
|
||||
|
||||
const loadTargets = useCallback(async (silent = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (!silent) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
setError('');
|
||||
const data = await middlewareMonitorApi.list() as MwListResponse;
|
||||
setTargets(data?.list || []);
|
||||
const list = data?.list || [];
|
||||
setTargets(list);
|
||||
syncSelection(list);
|
||||
setLastRefreshAt(new Date());
|
||||
hasLoaded.current = true;
|
||||
} catch (err) {
|
||||
const msg = (err as { message?: string }).message || '加载失败';
|
||||
setError(msg);
|
||||
setTargets([]);
|
||||
if (!silent) {
|
||||
setError((err as { message?: string }).message || '加载失败');
|
||||
setTargets([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
}, [syncSelection]);
|
||||
|
||||
useEffect(() => { loadTargets(); }, [loadTargets]);
|
||||
|
||||
// 按子类型分组
|
||||
const redisTargets = targets.filter(t => t.description === 'redis');
|
||||
const mysqlTargets = targets.filter(t => t.description === 'mysql');
|
||||
const otherTargets = targets.filter(t => t.description !== 'redis' && t.description !== 'mysql');
|
||||
// 30s 静默刷新,与采集周期对齐 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (!hasLoaded.current || targets.length === 0) return;
|
||||
const timer = setInterval(() => loadTargets(true), REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [targets.length, loadTargets]);
|
||||
|
||||
const redisTarget = redisTargets.find(t => t.status === 'online') || redisTargets[0];
|
||||
const redisMetrics = redisTarget?.latest_metrics || {};
|
||||
const mysqlTarget = mysqlTargets.find(t => t.status === 'online') || mysqlTargets[0];
|
||||
const mysqlMetrics = mysqlTarget?.latest_metrics || {};
|
||||
const redisTargets = targets.filter((t) => t.description === 'redis');
|
||||
const mysqlTargets = targets.filter((t) => t.description === 'mysql');
|
||||
const onlineCount = targets.filter((t) => t.status === 'online').length;
|
||||
const warnCount = targets.filter((t) => worstHealthLevel(evalMwHealth(t)) !== 'ok').length;
|
||||
|
||||
const handleDelete = async (target: MwTarget) => {
|
||||
const ok = await confirm({
|
||||
title: '删除中间件监控',
|
||||
description: `确认删除 "${target.name}"?相关指标历史也将清除。`,
|
||||
confirmText: '删除',
|
||||
});
|
||||
const ok = await confirm({ title: '删除监控', description: `确认删除 "${target.name}"?`, confirmText: '删除' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await middlewareMonitorApi.delete(target.id);
|
||||
@@ -128,12 +100,8 @@ export function MiddlewareMonitor() {
|
||||
setTesting(target.id);
|
||||
try {
|
||||
const res = await middlewareMonitorApi.test(target.id) as { success: boolean; message: string };
|
||||
if (res.success) {
|
||||
toast.success('连接成功', { description: res.message });
|
||||
} else {
|
||||
toast.error('连接失败', { description: res.message });
|
||||
}
|
||||
if (res.success) loadTargets();
|
||||
if (res.success) { toast.success('连接成功', { description: res.message }); loadTargets(true); }
|
||||
else toast.error('连接失败', { description: res.message });
|
||||
} catch (err) {
|
||||
toast.error('测试失败', { description: (err as { message?: string }).message });
|
||||
} finally {
|
||||
@@ -141,450 +109,179 @@ export function MiddlewareMonitor() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeedDefaults = async () => {
|
||||
setSeeding(true);
|
||||
try {
|
||||
const res = await middlewareMonitorApi.seedDefaults() as { created: number; message: string; failed?: string[] };
|
||||
await loadTargets();
|
||||
if (res.failed?.length) toast.error('部分失败', { description: res.message });
|
||||
else if (res.created > 0) toast.success('已添加默认监控', { description: res.message });
|
||||
else toast.success('默认目标已存在');
|
||||
} catch (err) {
|
||||
toast.error('添加失败', { description: (err as { message?: string }).message });
|
||||
} finally {
|
||||
setSeeding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollectNow = async () => {
|
||||
try {
|
||||
const res = await middlewareMonitorApi.collectNow() as { collected: number; message: string };
|
||||
const res = await middlewareMonitorApi.collectNow() as { message: string };
|
||||
toast.success('采集完成', { description: res.message });
|
||||
loadTargets();
|
||||
loadTargets(true);
|
||||
} catch (err) {
|
||||
toast.error('采集失败', { description: (err as { message?: string }).message });
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = (type?: TabKey) => {
|
||||
setEditTarget(null);
|
||||
if (type) setTab(type);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const currentTargets = tab === 'redis' ? redisTargets : mysqlTargets;
|
||||
const selected = tab === 'redis' ? selectedRedis : selectedMysql;
|
||||
const setSelected = tab === 'redis' ? setSelectedRedis : setSelectedMysql;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">中间件监控</h2>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">中间件监控</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||
Redis 单机/集群 · MySQL · 30s 自动刷新
|
||||
{lastRefreshAt && (
|
||||
<span className="ml-2">
|
||||
上次更新 {lastRefreshAt.toLocaleTimeString()}
|
||||
{refreshing && ' · 刷新中...'}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleCollectNow} className="gap-1.5">
|
||||
<Zap className="h-4 w-4" />
|
||||
立即采集
|
||||
<Zap className="h-4 w-4" />立即采集
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={loadTargets} className="gap-1.5">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
<Button variant="outline" size="sm" onClick={() => loadTargets(true)} disabled={refreshing} className="gap-1.5">
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setEditTarget(null); setDialogOpen(true); }} className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加监控
|
||||
{targets.length === 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleSeedDefaults} disabled={seeding} className="gap-1.5">
|
||||
{seeding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Database className="h-4 w-4" />}
|
||||
添加平台默认
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={() => openCreate()} className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />添加监控
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin dg-text-muted mr-2" />
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">正在加载中间件指标...</span>
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-red-500/30">
|
||||
<CardContent className="p-6 text-center">
|
||||
<Activity className="h-8 w-8 dg-text-red mx-auto mb-2" />
|
||||
<p className="text-sm dg-text-red">{error}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">请确认后端服务可用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-red-500/30"><CardContent className="p-6 text-center dg-text-red text-sm">{error}</CardContent></Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && targets.length === 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Database className="h-12 w-12 dg-text-muted mx-auto mb-4" />
|
||||
<Database className="h-12 w-12 mx-auto mb-4 text-[var(--dg-text-muted)]" />
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">暂无中间件监控目标</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">点击「添加监控」配置 Redis / MySQL 连接</p>
|
||||
<Button size="sm" className="mt-4 gap-1.5" onClick={() => { setEditTarget(null); setDialogOpen(true); }}>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加第一个监控
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Redis 概览卡片 */}
|
||||
{redisTargets.length > 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Database className="h-4 w-4 dg-text-red" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">Redis 监控</h3>
|
||||
{redisTarget && (
|
||||
<Badge variant="outline" className="ml-auto text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{redisTarget.name} ({redisTarget.ip}:{redisTarget.port})
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{redisTarget ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<MetricTile label="连接数" value={fmtVal(redisMetrics.redis_connected_clients)} />
|
||||
<MetricTile label="内存" value={fmtMemory(redisMetrics.redis_used_memory)} />
|
||||
<MetricTile label="命中率" value={fmtHitRate(redisMetrics.redis_hit_rate)} />
|
||||
<MetricTile label="QPS" value={fmtVal(redisMetrics.redis_qps, 1)} />
|
||||
<MetricTile label="Key总数" value={fmtVal(redisMetrics.redis_db_size)} />
|
||||
<MetricTile label="阻塞客户端" value={fmtVal(redisMetrics.redis_blocked_clients)} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">无在线 Redis 实例</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* MySQL 概览卡片 */}
|
||||
{mysqlTargets.length > 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Server className="h-4 w-4 dg-text-blue" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">MySQL 监控</h3>
|
||||
{mysqlTarget && (
|
||||
<Badge variant="outline" className="ml-auto text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{mysqlTarget.name} ({mysqlTarget.ip}:{mysqlTarget.port})
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{mysqlTarget ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<MetricTile label="连接数" value={fmtVal(mysqlMetrics.mysql_connections)} />
|
||||
<MetricTile label="活跃线程" value={fmtVal(mysqlMetrics.mysql_threads_running)} />
|
||||
<MetricTile label="QPS" value={fmtVal(mysqlMetrics.mysql_qps, 1)} />
|
||||
<MetricTile label="慢查询" value={fmtVal(mysqlMetrics.mysql_slow_queries)} />
|
||||
<MetricTile label="缓冲池命中率" value={fmtHitRate(mysqlMetrics.mysql_buffer_hit_rate)} />
|
||||
<MetricTile label="发送流量" value={fmtMemory(mysqlMetrics.mysql_bytes_sent)} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">无在线 MySQL 实例</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 中间件实例列表 */}
|
||||
{targets.length > 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Settings2 className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
监控目标列表({targets.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{targets.map(target => (
|
||||
<InstanceCard
|
||||
key={target.id}
|
||||
target={target}
|
||||
onEdit={() => { setEditTarget(target); setDialogOpen(true); }}
|
||||
onDelete={() => handleDelete(target)}
|
||||
onTest={() => handleTest(target)}
|
||||
testing={testing === target.id}
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button size="sm" onClick={handleSeedDefaults} disabled={seeding}>添加平台默认</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openCreate()}>手动添加</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 其他类型 */}
|
||||
{otherTargets.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{otherTargets.map(target => (
|
||||
<Card key={target.id} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Circle className={`h-3 w-3 ${statusColor(target.status)}`} />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] capitalize">{target.description}</h3>
|
||||
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{target.name} ({target.ip})
|
||||
</Badge>
|
||||
</div>
|
||||
{Object.keys(target.latest_metrics || {}).length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{Object.entries(target.latest_metrics).map(([key, val]) => (
|
||||
<MetricTile key={key} label={key} value={fmtVal(val, 1)} />
|
||||
))}
|
||||
{!loading && !error && targets.length > 0 && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{[
|
||||
{ label: '监控实例', value: targets.length, icon: Activity },
|
||||
{ label: '在线', value: onlineCount, icon: Database, color: 'dg-text-green' },
|
||||
{ label: '告警', value: warnCount, icon: Activity, color: warnCount > 0 ? 'dg-text-yellow' : 'dg-text-green' },
|
||||
{ label: 'Redis', value: redisTargets.length, icon: Database, color: 'dg-text-red' },
|
||||
{ label: 'MySQL', value: mysqlTargets.length, icon: Server, color: 'dg-text-blue' },
|
||||
].map((item) => (
|
||||
<Card key={item.label} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 flex items-center gap-3">
|
||||
<item.icon className={`h-4 w-4 ${item.color || 'text-[var(--dg-text-muted)]'}`} />
|
||||
<div>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-lg font-bold ${item.color || ''}`}>{item.value}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">暂无指标数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MwOverviewCharts targets={targets} />
|
||||
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
{ key: 'redis' as TabKey, label: 'Redis', count: redisTargets.length, icon: Database },
|
||||
{ key: 'mysql' as TabKey, label: 'MySQL', count: mysqlTargets.length, icon: Server },
|
||||
]).map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === t.key
|
||||
? 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]'
|
||||
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="h-4 w-4" />{t.label}
|
||||
<span className="text-xs opacity-60">{t.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{currentTargets.length === 0 ? (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-8 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
暂无 {tab === 'redis' ? 'Redis' : 'MySQL'} 实例,
|
||||
<button type="button" className="dg-text-cyan ml-1 underline" onClick={() => openCreate(tab)}>立即添加</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加/编辑弹框 */}
|
||||
<MiddlewareDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
editTarget={editTarget}
|
||||
onSuccess={loadTargets}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 指标小方块 */
|
||||
function MetricTile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{label}</p>
|
||||
<p className="text-lg font-bold text-[var(--dg-text-primary)]">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 实例卡片 */
|
||||
function InstanceCard({
|
||||
target, onEdit, onDelete, onTest, testing,
|
||||
}: {
|
||||
target: MwTarget;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
}) {
|
||||
const metrics = target.latest_metrics || {};
|
||||
const metricCount = Object.keys(metrics).length;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 border border-[var(--dg-border)] hover:border-[var(--dg-border)]/80 transition-colors">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${statusBg(target.status)}`} />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{target.name}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] capitalize ${target.status === 'online' ? 'border-emerald-400/50 dg-text-green' : 'border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
|
||||
>
|
||||
{target.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">
|
||||
{target.ip}:{target.port} · {target.description}
|
||||
</div>
|
||||
{metricCount > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{Object.entries(metrics).slice(0, 4).map(([key, val]) => (
|
||||
<span key={key} className="inline-flex items-center gap-1 text-[10px] bg-[var(--dg-bg-primary)] rounded px-1.5 py-0.5 text-[var(--dg-text-secondary)]">
|
||||
<span className="text-[var(--dg-text-muted)]">{key.replace(/^(redis|mysql)_/, '')}</span>
|
||||
<span className="font-mono font-medium text-[var(--dg-text-primary)]">
|
||||
{typeof val === 'number' ? val.toFixed(1) : val}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{metricCount > 4 && (
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">+{metricCount - 4} 项</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mb-3">暂无指标</p>
|
||||
)}
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={onTest} disabled={testing}>
|
||||
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3" />}
|
||||
测试
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={onEdit}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1 dg-text-red hover:dg-text-red" onClick={onDelete}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 添加/编辑中间件弹框 */
|
||||
function MiddlewareDialog({
|
||||
open, onOpenChange, editTarget, onSuccess,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
editTarget: MwTarget | null;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '', type: 'redis', ip: '', port: 6379,
|
||||
password: '', username: '', database: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editTarget) {
|
||||
setForm({
|
||||
name: editTarget.name || '',
|
||||
type: editTarget.description || 'redis',
|
||||
ip: editTarget.ip || '',
|
||||
port: editTarget.port || 6379,
|
||||
password: '', // 不回显密码
|
||||
username: '',
|
||||
database: '',
|
||||
});
|
||||
} else {
|
||||
setForm({
|
||||
name: '', type: 'redis', ip: '', port: 6379,
|
||||
password: '', username: '', database: '',
|
||||
});
|
||||
}
|
||||
}, [editTarget, open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim() || !form.ip.trim()) {
|
||||
toast.error('请填写名称和 IP');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
ip: form.ip.trim(),
|
||||
port: Number(form.port) || (form.type === 'redis' ? 6379 : 3306),
|
||||
interval: 30,
|
||||
};
|
||||
if (form.password) payload.password = form.password;
|
||||
if (form.type === 'mysql') {
|
||||
if (form.username) payload.username = form.username;
|
||||
if (form.database) payload.database = form.database;
|
||||
}
|
||||
|
||||
if (editTarget) {
|
||||
await middlewareMonitorApi.update(editTarget.id, payload);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
await middlewareMonitorApi.create(payload);
|
||||
toast.success('创建成功,正在采集...');
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
toast.error(editTarget ? '更新失败' : '创建失败', {
|
||||
description: (err as { message?: string }).message,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">
|
||||
{editTarget ? '编辑中间件监控' : '添加中间件监控'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
配置 Redis 或 MySQL 连接信息,平台将自动采集指标
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
{/* 类型选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">中间件类型</Label>
|
||||
<div className="flex gap-2">
|
||||
{(['redis', 'mysql'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setForm(f => ({
|
||||
...f, type: t,
|
||||
port: t === 'redis' ? 6379 : 3306,
|
||||
}))}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
form.type === t
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{t === 'redis' ? 'Redis' : 'MySQL'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 名称 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">名称 *</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder={form.type === 'redis' ? '生产 Redis' : '生产 MySQL'}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
{/* IP + Port */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">IP 地址 *</Label>
|
||||
<Input
|
||||
value={form.ip}
|
||||
onChange={e => setForm(f => ({ ...f, ip: e.target.value }))}
|
||||
placeholder="10.10.10.75"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">端口</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={e => setForm(f => ({ ...f, port: Number(e.target.value) }))}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 密码 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">
|
||||
密码 {editTarget && <span className="text-xs text-[var(--dg-text-muted)]">(留空不修改)</span>}
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder="输入密码"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
{/* MySQL 专属字段 */}
|
||||
{form.type === 'mysql' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">用户名</Label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={e => setForm(f => ({ ...f, username: e.target.value }))}
|
||||
placeholder="root"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col xl:flex-row gap-4 items-start">
|
||||
<div className="w-full xl:w-[min(260px,24%)] xl:shrink-0 space-y-2">
|
||||
{currentTargets.map((t) => (
|
||||
<InstanceCard
|
||||
key={t.id}
|
||||
target={t}
|
||||
selected={selected?.id === t.id}
|
||||
onSelect={() => setSelected(t)}
|
||||
onEdit={() => { setEditTarget(t); setDialogOpen(true); }}
|
||||
onDelete={() => handleDelete(t)}
|
||||
onTest={() => handleTest(t)}
|
||||
testing={testing === t.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">数据库</Label>
|
||||
<Input
|
||||
value={form.database}
|
||||
onChange={e => setForm(f => ({ ...f, database: e.target.value }))}
|
||||
placeholder="information_schema"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
<div className="flex-1 min-w-0 w-full">
|
||||
<MwTargetChartsPanel
|
||||
target={selected}
|
||||
chartDefs={tab === 'redis' ? REDIS_CHARTS : MYSQL_CHARTS}
|
||||
accent={tab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editTarget ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MiddlewareDialog open={dialogOpen} onOpenChange={setDialogOpen} editTarget={editTarget} onSuccess={() => loadTargets(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// 监控中心 — 4个监控标签页的容器 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { HostMonitor } from './HostMonitor';
|
||||
import { MiddlewareMonitor } from './MiddlewareMonitor';
|
||||
@@ -11,13 +12,31 @@ type TabId = 'host' | 'middleware' | 'snmp' | 'site';
|
||||
|
||||
const tabs: { id: TabId; name: string; description: string }[] = [
|
||||
{ id: 'host', name: '主机监控', description: 'CPU / 内存 / 磁盘 / 网络 / 磁盘IO' },
|
||||
{ id: 'middleware', name: '中间件监控', description: 'Redis / MySQL 指标采集' },
|
||||
{ id: 'snmp', name: 'SNMP 监控', description: '网络设备 / 服务器硬件 (OID 模板)' },
|
||||
{ id: 'site', name: '站点拨测', description: 'HTTP / PING / DNS / TCP / WS 拨测' },
|
||||
{ id: 'middleware', name: '中间件', description: 'Redis 单机·集群 / MySQL 指标与趋势' },
|
||||
{ id: 'snmp', name: 'SNMP', description: '网络设备 / BMC 采集与趋势' },
|
||||
{ id: 'site', name: '站点拨测', description: 'HTTP Web · SSL 证书 · TCP / DNS / PING' },
|
||||
];
|
||||
|
||||
const TAB_IDS: TabId[] = ['host', 'middleware', 'snmp', 'site'];
|
||||
|
||||
function parseTab(raw: string | null): TabId {
|
||||
return TAB_IDS.includes(raw as TabId) ? (raw as TabId) : 'host';
|
||||
}
|
||||
|
||||
export function MonitorCenter() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('host');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState<TabId>(() => parseTab(searchParams.get('tab')));
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(parseTab(searchParams.get('tab')));
|
||||
}, [searchParams]);
|
||||
|
||||
const handleTabChange = (id: string) => {
|
||||
const tab = id as TabId;
|
||||
setActiveTab(tab);
|
||||
setSearchParams({ tab }, { replace: true });
|
||||
};
|
||||
|
||||
const current = tabs.find((t) => t.id === activeTab);
|
||||
|
||||
return (
|
||||
@@ -25,10 +44,10 @@ export function MonitorCenter() {
|
||||
variant="list"
|
||||
title="监控中心"
|
||||
subtitle={current?.description ?? '全面的基础设施与应用监控'}
|
||||
icon={<Cpu className="h-5 w-5" />}
|
||||
icon={<Activity className="h-5 w-5" />}
|
||||
tabs={tabs.map((t) => ({ id: t.id, label: t.name }))}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => setActiveTab(id as TabId)}
|
||||
onTabChange={handleTabChange}
|
||||
contentClassName="min-h-[400px]"
|
||||
>
|
||||
{activeTab === 'host' && <HostMonitor embedded />}
|
||||
|
||||
@@ -1,428 +1,394 @@
|
||||
// SNMP 监控 — 网络设备 / 服务器硬件 (OID 模板采集)
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
// SNMP 监控 — 设备管理 + 指标可视化(主区域优先) // Logan.Li
|
||||
import { useState, useEffect, useCallback, type MouseEvent } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Radio, Router, HardDrive, Plus, Trash2 } from 'lucide-react';
|
||||
import { Router, RefreshCw, Plus, Trash2, Zap, Eye, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { snmpApi } from '@/api';
|
||||
import { SnmpOverviewCharts } from './snmp-monitor/SnmpOverviewCharts';
|
||||
import { SnmpDeviceChartsPanel } from './snmp-monitor/SnmpDeviceChartsPanel';
|
||||
import { SnmpTemplatesCollapsible, type SnmpTemplate } from './snmp-monitor/SnmpTemplatesCollapsible';
|
||||
import type { SnmpDevice, SnmpMetric, SnmpOverviewItem } from './snmp-monitor/helpers';
|
||||
|
||||
// ---------- types ----------
|
||||
interface SnmpStats {
|
||||
total: number;
|
||||
online: number;
|
||||
abnormal: number;
|
||||
}
|
||||
interface SnmpStats { total: number; online: number; abnormal: number }
|
||||
|
||||
interface SnmpDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
snmp_version: string;
|
||||
community: string;
|
||||
template_id: number;
|
||||
status?: string; // "online" | "offline" | "abnormal"
|
||||
}
|
||||
|
||||
interface DeviceListResponse {
|
||||
list: SnmpDevice[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---------- initial form state ----------
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
ip: '',
|
||||
port: 161,
|
||||
name: '', ip: '', port: 161,
|
||||
snmp_version: 'v2c' as 'v2c' | 'v3',
|
||||
community: 'public',
|
||||
template_id: '',
|
||||
community: 'public', template_id: '',
|
||||
};
|
||||
|
||||
const keyMetrics = [
|
||||
'h3c_cpu_usage', 'hw_cpu_usage', 'cisco_cpu_5min', 'ruijie_cpu_usage',
|
||||
'inspur_cpu_usage', 'dell_cpu_status', 'h3c_mem_usage', 'hw_mem_usage',
|
||||
'h3c_temperature', 'hw_temperature', 'if_in_octets', 'if_out_octets',
|
||||
];
|
||||
|
||||
export function SNMPMonitor() {
|
||||
// stats
|
||||
const [stats, setStats] = useState<SnmpStats>({ total: 0, online: 0, abnormal: 0 });
|
||||
|
||||
// device list
|
||||
const [devices, setDevices] = useState<SnmpDevice[]>([]);
|
||||
const [overview, setOverview] = useState<SnmpOverviewItem[]>([]);
|
||||
const [templates, setTemplates] = useState<SnmpTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// dialog
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState({ ...emptyForm });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [collectingId, setCollectingId] = useState<number | null>(null);
|
||||
const [selectedDevice, setSelectedDevice] = useState<SnmpDevice | null>(null);
|
||||
const [metricsOpen, setMetricsOpen] = useState(false);
|
||||
const [metricsDevice, setMetricsDevice] = useState<SnmpDevice | null>(null);
|
||||
const [metrics, setMetrics] = useState<SnmpMetric[]>([]);
|
||||
const [metricsLoading, setMetricsLoading] = useState(false);
|
||||
|
||||
// ---------- data fetching ----------
|
||||
const loadStats = useCallback(async () => {
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
const data = await snmpApi.getStats() as SnmpStats;
|
||||
setStats(data);
|
||||
} catch {
|
||||
// backend unavailable — leave defaults
|
||||
}
|
||||
const res = await snmpApi.getTemplates() as { list?: SnmpTemplate[] };
|
||||
setTemplates(res?.list || []);
|
||||
} catch { setTemplates([]); }
|
||||
}, []);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try { setStats(await snmpApi.getStats() as SnmpStats); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
try {
|
||||
const data = await snmpApi.getOverviewMetrics() as { list?: SnmpOverviewItem[] };
|
||||
setOverview(data?.list || []);
|
||||
} catch { setOverview([]); }
|
||||
}, []);
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try {
|
||||
const data = await snmpApi.getDevices() as DeviceListResponse;
|
||||
setDevices(data.list ?? []);
|
||||
const data = await snmpApi.getDevices() as { list?: SnmpDevice[] };
|
||||
const list = data.list ?? [];
|
||||
setDevices(list);
|
||||
setSelectedDevice((prev) => {
|
||||
if (prev && list.some((d) => d.id === prev.id)) return list.find((d) => d.id === prev.id) || null;
|
||||
return list.find((d) => d.status === 'online') || list[0] || null;
|
||||
});
|
||||
} catch {
|
||||
setDevices([]);
|
||||
setSelectedDevice(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
loadStats();
|
||||
loadDevices();
|
||||
}, [loadStats, loadDevices]);
|
||||
loadStats(); loadDevices(); loadTemplates(); loadOverview();
|
||||
}, [loadStats, loadDevices, loadTemplates, loadOverview]);
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
// 30s 静默刷新,与采集周期对齐 // Logan.Li
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
if (loading && devices.length === 0) return;
|
||||
const timer = setInterval(refresh, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh, loading, devices.length]);
|
||||
|
||||
const templateName = (id: number) => templates.find((t) => t.id === id)?.name || `#${id}`;
|
||||
|
||||
// ---------- CRUD ----------
|
||||
const handleAdd = async () => {
|
||||
if (!form.name.trim() || !form.ip.trim()) return;
|
||||
if (!form.name.trim() || !form.ip.trim() || !form.template_id) {
|
||||
toast.error('请填写设备名称、IP 并选择 OID 模板');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const tpl = templates.find((t) => String(t.id) === form.template_id);
|
||||
await snmpApi.createDevice({
|
||||
name: form.name.trim(),
|
||||
ip: form.ip.trim(),
|
||||
port: Number(form.port),
|
||||
snmp_version: form.snmp_version,
|
||||
name: form.name.trim(), ip: form.ip.trim(),
|
||||
port: Number(form.port), snmp_version: form.snmp_version,
|
||||
community: form.community.trim(),
|
||||
template_id: form.template_id ? Number(form.template_id) : 0,
|
||||
template_id: Number(form.template_id),
|
||||
vendor: tpl?.vendor || '', device_type: tpl?.device_type || 'switch',
|
||||
});
|
||||
toast.success('设备已添加');
|
||||
setDialogOpen(false);
|
||||
setForm({ ...emptyForm });
|
||||
refresh();
|
||||
} catch {
|
||||
// silently fail — user can retry
|
||||
} catch (e) {
|
||||
toast.error((e as { message?: string }).message || '添加失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const handleCollect = async (d: SnmpDevice, e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
setCollectingId(d.id);
|
||||
try {
|
||||
await snmpApi.deleteDevice(id);
|
||||
await snmpApi.collectDevice(d.id);
|
||||
toast.success(`${d.name} 采集成功`);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error((err as { message?: string }).message || '采集失败');
|
||||
refresh();
|
||||
} finally {
|
||||
setCollectingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openMetrics = async (d: SnmpDevice, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setMetricsDevice(d);
|
||||
setMetricsOpen(true);
|
||||
setMetricsLoading(true);
|
||||
try {
|
||||
const res = await snmpApi.getDeviceMetrics(d.id) as { list?: SnmpMetric[] };
|
||||
setMetrics(res?.list || []);
|
||||
} catch {
|
||||
// silently fail
|
||||
setMetrics([]);
|
||||
} finally {
|
||||
setMetricsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await snmpApi.deleteDevice(id);
|
||||
toast.success('已删除');
|
||||
if (selectedDevice?.id === id) setSelectedDevice(null);
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- status badge ----------
|
||||
const statusBadge = (status?: string) => {
|
||||
if (status === 'online') {
|
||||
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">在线</Badge>;
|
||||
}
|
||||
if (status === 'abnormal') {
|
||||
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30">异常</Badge>;
|
||||
}
|
||||
if (status === 'online') return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">在线</Badge>;
|
||||
if (status === 'error') return <Badge className="bg-red-500/15 dg-text-red border-red-500/30">异常</Badge>;
|
||||
return <Badge className="bg-[var(--dg-bg-active)]/15 dg-text-secondary border-[var(--dg-border)]">离线</Badge>;
|
||||
};
|
||||
|
||||
// ---------- render ----------
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Router className="h-4 w-4 dg-text-cyan" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">网络设备</h3>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">交换机 / 路由器 / 防火墙</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">设备总数</span>
|
||||
<span className="text-[var(--dg-text-primary)] font-mono">{stats.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">在线</span>
|
||||
<span className="dg-text-green font-mono">{stats.online}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="h-4 w-4 dg-text-purple" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">服务器硬件</h3>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">iLO / iDRAC / IPMI</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">异常</span>
|
||||
<span className="dg-text-red font-mono">{stats.abnormal}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Template Info */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Radio className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">OID 监控模板</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">
|
||||
支持 Zabbix 兼容的 SNMP OID 模板(XML 格式),内置思科/H3C/华为交换机模板、服务器硬件监控模板。
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-[var(--dg-text-secondary)]">
|
||||
{['思科交换机', 'H3C S5000', '华为交换机', 'iLO/Dell'].map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className="bg-[var(--dg-bg-tertiary)] rounded px-2 py-1 text-center border border-[var(--dg-border)]"
|
||||
>
|
||||
{t}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Device Table */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Router className="h-4 w-4 dg-text-cyan" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
SNMP 设备列表
|
||||
{!loading && (
|
||||
<span className="ml-2 text-xs text-[var(--dg-text-muted)] font-normal">
|
||||
({devices.length} 台)
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Add Device Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="bg-cyan-500/10 dg-text-cyan hover:bg-cyan-500/20 border border-cyan-500/30">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加设备
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">添加 SNMP 设备</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
输入设备信息以开始 SNMP 监控采集。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{/* Name */}
|
||||
<div className="space-y-4">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">SNMP 监控</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">5 分钟采集 · 指标保留 7 天</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={refresh} className="h-8">
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />刷新
|
||||
</Button>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="bg-cyan-500/10 dg-text-cyan border border-cyan-500/30 h-8">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />添加设备
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加 SNMP 设备</DialogTitle>
|
||||
<DialogDescription>选择厂商 OID 模板,配置管理 IP 与 Community</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>设备名称</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="核心交换机-A" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-name" className="text-[var(--dg-text-muted)]">
|
||||
设备名称
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-name"
|
||||
placeholder="例如: 核心交换机"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
<Label>IP 地址</Label>
|
||||
<Input value={form.ip} onChange={(e) => setForm({ ...form, ip: e.target.value })} placeholder="192.168.1.1" />
|
||||
</div>
|
||||
|
||||
{/* IP */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-ip" className="text-[var(--dg-text-muted)]">
|
||||
IP 地址
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-ip"
|
||||
placeholder="例如: 192.168.1.1"
|
||||
value={form.ip}
|
||||
onChange={(e) => setForm({ ...form, ip: e.target.value })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
<Label>端口</Label>
|
||||
<Input type="number" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 161 })} />
|
||||
</div>
|
||||
|
||||
{/* Port */}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>OID 模板</Label>
|
||||
<Select value={form.template_id} onValueChange={(v) => setForm({ ...form, template_id: v })}>
|
||||
<SelectTrigger><SelectValue placeholder="选择模板" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.id} value={String(t.id)}>{t.name} ({t.vendor})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-port" className="text-[var(--dg-text-muted)]">
|
||||
SNMP 端口
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-port"
|
||||
type="number"
|
||||
placeholder="161"
|
||||
value={form.port}
|
||||
onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 161 })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SNMP Version */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-version" className="text-[var(--dg-text-muted)]">
|
||||
SNMP 版本
|
||||
</Label>
|
||||
<Select
|
||||
value={form.snmp_version}
|
||||
onValueChange={(v) => setForm({ ...form, snmp_version: v as 'v2c' | 'v3' })}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="snmp-version"
|
||||
className="w-full bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
<Label>SNMP 版本</Label>
|
||||
<Select value={form.snmp_version} onValueChange={(v) => setForm({ ...form, snmp_version: v as 'v2c' | 'v3' })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="v2c">v2c</SelectItem>
|
||||
<SelectItem value="v3">v3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Community */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-community" className="text-[var(--dg-text-muted)]">
|
||||
Community
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-community"
|
||||
placeholder="public"
|
||||
value={form.community}
|
||||
onChange={(e) => setForm({ ...form, community: e.target.value })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template ID */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-template" className="text-[var(--dg-text-muted)]">
|
||||
模板 ID
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-template"
|
||||
type="number"
|
||||
placeholder="模板 ID"
|
||||
value={form.template_id}
|
||||
onChange={(e) => setForm({ ...form, template_id: e.target.value })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
<Label>Community</Label>
|
||||
<Input value={form.community} onChange={(e) => setForm({ ...form, community: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleAdd} disabled={submitting}>
|
||||
{submitting && <Loader2 className="h-4 w-4 animate-spin mr-1" />}确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)]"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={submitting || !form.name.trim() || !form.ip.trim()}
|
||||
className="bg-cyan-500/10 dg-text-cyan hover:bg-cyan-500/20 border border-cyan-500/30"
|
||||
>
|
||||
{submitting ? '添加中...' : '确认添加'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* 紧凑统计 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{[
|
||||
{ label: '设备总数', value: stats.total, color: '' },
|
||||
{ label: '在线', value: stats.online, color: 'dg-text-green' },
|
||||
{ label: '异常', value: stats.abnormal, color: 'dg-text-red' },
|
||||
{ label: 'OID 模板', value: templates.length, color: 'dg-text-cyan' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-base font-bold font-mono ${item.color}`}>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-sm text-[var(--dg-text-muted)]">加载中...</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
暂无 SNMP 设备,点击「添加设备」开始监控
|
||||
{loading ? (
|
||||
<div className="py-16 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="py-12 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
<Router className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
||||
暂无 SNMP 设备,点击「添加设备」开始监控
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<SnmpOverviewCharts devices={devices} overview={overview} />
|
||||
|
||||
{/* 主监控区:设备列表 + 趋势图 */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-4 items-start">
|
||||
<Card className="xl:col-span-5 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<h3 className="text-sm font-medium mb-3">设备列表 ({devices.length})</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">名称</TableHead>
|
||||
<TableHead className="text-xs">IP</TableHead>
|
||||
<TableHead className="text-xs">状态</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((d) => (
|
||||
<TableRow
|
||||
key={d.id}
|
||||
className={`cursor-pointer ${selectedDevice?.id === d.id ? 'bg-[var(--dg-bg-tertiary)]' : ''}`}
|
||||
onClick={() => setSelectedDevice(d)}
|
||||
>
|
||||
<TableCell className="text-sm font-medium py-2">{d.name}</TableCell>
|
||||
<TableCell className="font-mono text-[10px] py-2">{d.ip}</TableCell>
|
||||
<TableCell className="py-2">{statusBadge(d.status)}</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon-sm" disabled={collectingId === d.id} onClick={(e) => handleCollect(d, e)}>
|
||||
{collectingId === d.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Zap className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onClick={(e) => openMetrics(d, e)}>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onClick={(e) => handleDelete(d.id, e)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="xl:col-span-7">
|
||||
<SnmpDeviceChartsPanel
|
||||
device={selectedDevice}
|
||||
templateName={selectedDevice ? templateName(selectedDevice.template_id) : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* OID 模板 — 默认折叠 */}
|
||||
<SnmpTemplatesCollapsible templates={templates} />
|
||||
|
||||
{/* 指标详情弹窗 */}
|
||||
<Dialog open={metricsOpen} onOpenChange={setMetricsOpen}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto bg-[var(--dg-bg-secondary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{metricsDevice?.name} — 采集指标</DialogTitle>
|
||||
<DialogDescription>{metricsDevice?.ip} · {templateName(metricsDevice?.template_id || 0)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{metricsLoading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">加载中...</div>
|
||||
) : metrics.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
暂无指标,请先采集
|
||||
{metricsDevice?.last_error && <p className="mt-2 text-xs dg-text-red">{metricsDevice.last_error}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">IP 地址</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">端口</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">版本</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">Community</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">模板 ID</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-16">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((d) => (
|
||||
<TableRow key={d.id} className="border-[var(--dg-border)]">
|
||||
<TableCell className="text-[var(--dg-text-primary)] font-medium">
|
||||
{d.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)] font-mono text-xs">
|
||||
{d.ip}
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)]">{d.port}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{d.snmp_version}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)] font-mono text-xs">
|
||||
{d.community}
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)]">{d.template_id || '--'}</TableCell>
|
||||
<TableCell>{statusBadge(d.status)}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleDelete(d.id)}
|
||||
className="dg-text-muted hover:dg-text-red hover:bg-red-500/10"
|
||||
title="删除设备"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<div className="space-y-4">
|
||||
{metrics.filter((m) => keyMetrics.includes(m.name)).length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{metrics.filter((m) => keyMetrics.includes(m.name)).map((m) => (
|
||||
<div key={`${m.name}-${m.index}`} className="rounded border border-[var(--dg-border)] p-2 text-center">
|
||||
<p className="text-[10px] text-muted-foreground">{m.name}</p>
|
||||
<p className="text-lg font-mono">{m.raw_value || m.value}{m.unit ? ` ${m.unit}` : ''}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>指标</TableHead><TableHead>索引</TableHead><TableHead>值</TableHead><TableHead>OID</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{metrics.map((m) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell className="text-xs">{m.name}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{m.index || '-'}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{m.raw_value || m.value}{m.unit ? ` ${m.unit}` : ''}</TableCell>
|
||||
<TableCell className="font-mono text-[10px] text-muted-foreground truncate max-w-[160px]">{m.oid}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,49 @@
|
||||
// 站点拨测 — HTTP / HTTPS / TCP / DNS / PING 主动监控,连接真实后端 API
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
// 站点拨测 — 任务列表 + 详情可观测 // Logan.Li
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Globe,
|
||||
Server,
|
||||
ArrowUpDown,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Activity,
|
||||
Plus, Trash2, Pencil, Loader2, RefreshCw, Activity, ShieldAlert, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { siteApi } from '@/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
// 拨测协议类型(与后端 model 对齐:http, https, tcp, dns, ping)
|
||||
const probeTypes = [
|
||||
{ icon: Globe, name: 'HTTP', color: 'dg-text-blue', desc: 'HTTP URL 状态码 & 响应时间' },
|
||||
{ icon: Globe, name: 'HTTPS', color: 'dg-text-green', desc: 'HTTPS SSL 证书 & 响应时间' },
|
||||
{ icon: Server, name: 'TCP', color: 'dg-text-orange', desc: 'TCP 端口连通性检测' },
|
||||
{ icon: Globe, name: 'DNS', color: 'dg-text-cyan', desc: '域名解析延迟 & 结果验证' },
|
||||
{ icon: ArrowUpDown, name: 'PING', color: 'dg-text-purple', desc: 'ICMP 连通性 & 延迟' },
|
||||
] as const;
|
||||
|
||||
const PROBE_TYPE_OPTIONS = ['HTTP', 'HTTPS', 'TCP', 'DNS', 'PING'] as const;
|
||||
type ProbeType = (typeof PROBE_TYPE_OPTIONS)[number];
|
||||
|
||||
interface SiteStats {
|
||||
total_tasks: number;
|
||||
enabled_tasks: number;
|
||||
up_count: number;
|
||||
down_count: number;
|
||||
availability: number;
|
||||
}
|
||||
|
||||
interface SiteTask {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
type: string;
|
||||
frequency_sec: number;
|
||||
timeout_sec: number;
|
||||
expected_status_code: number;
|
||||
enabled: boolean;
|
||||
last_status?: string;
|
||||
last_response_time_ms?: number;
|
||||
last_checked_at?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
import { SiteProtocolPieChart } from './site-monitor/SiteProtocolPieChart';
|
||||
import { SiteTaskDetailDialog } from './site-monitor/SiteTaskDetailDialog';
|
||||
import {
|
||||
PROBE_TYPE_OPTIONS, type ProbeType, type SiteStats, type SiteTask,
|
||||
probeTypeMeta, fmtAvailResponse, fmtFrequency, isSslProbeTask, isSslExpiringSoon, normalizeProbeType,
|
||||
} from './site-monitor/helpers';
|
||||
|
||||
interface CreateTaskForm {
|
||||
name: string;
|
||||
url: string;
|
||||
type: ProbeType;
|
||||
frequency_sec: number;
|
||||
timeout_sec: number;
|
||||
expected_status_code: number;
|
||||
name: string; url: string; type: ProbeType;
|
||||
frequency_sec: number; timeout_sec: number; expected_status_code: number;
|
||||
}
|
||||
|
||||
const emptyForm: CreateTaskForm = {
|
||||
name: '',
|
||||
url: '',
|
||||
type: 'HTTP',
|
||||
frequency_sec: 60,
|
||||
timeout_sec: 10,
|
||||
expected_status_code: 200,
|
||||
name: '', url: '', type: 'HTTP',
|
||||
frequency_sec: 60, timeout_sec: 10, expected_status_code: 200,
|
||||
};
|
||||
|
||||
function statusIcon(status?: string) {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <CheckCircle2 className="h-4 w-4 dg-text-green" />;
|
||||
case 'down':
|
||||
return <XCircle className="h-4 w-4 dg-text-red" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 dg-text-secondary" />;
|
||||
const FILTER_TYPES: Array<ProbeType | 'ALL'> = ['ALL', ...PROBE_TYPE_OPTIONS];
|
||||
|
||||
function alertStatusBadge(status?: string) {
|
||||
if (status === 'alerting') {
|
||||
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30 text-[10px]">告警中</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status?: string) {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">正常</Badge>;
|
||||
case 'down':
|
||||
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30">异常</Badge>;
|
||||
default:
|
||||
return <Badge className="dg-badge-muted">未知</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtMs(ms?: number): string {
|
||||
if (ms == null || ms === 0) return '--';
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string): string {
|
||||
if (!iso) return '--';
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30 text-[10px]">正常</Badge>;
|
||||
}
|
||||
|
||||
export function SiteMonitor() {
|
||||
@@ -142,108 +52,124 @@ export function SiteMonitor() {
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingTaskId, setEditingTaskId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<CreateTaskForm>({ ...emptyForm });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
|
||||
const [selectedType, setSelectedType] = useState<ProbeType | 'ALL'>('ALL');
|
||||
const [detailTask, setDetailTask] = useState<SiteTask | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const _confirm = useConfirm();
|
||||
|
||||
// ---------- data fetching ----------
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await siteApi.getStats();
|
||||
setStats(data as SiteStats);
|
||||
} catch {
|
||||
// 后端不可用时保留空状态
|
||||
}
|
||||
try { setStats(await siteApi.getStats() as SiteStats); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const data = await siteApi.getTasks();
|
||||
setTasks((data as JsonData)?.list || []);
|
||||
} catch {
|
||||
// 后端不可用时保留空状态
|
||||
} finally {
|
||||
const list = (data as JsonData)?.list as SiteTask[] || [];
|
||||
setTasks(list);
|
||||
setDetailTask((prev) => {
|
||||
if (!prev) return null;
|
||||
return list.find((t) => t.id === prev.id) || null;
|
||||
});
|
||||
} catch { /* ignore */ } finally {
|
||||
setRefreshing(false);
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadStats(); loadTasks(); }, [loadStats, loadTasks]);
|
||||
|
||||
// 30s 静默刷新 // Logan.Li
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadTasks();
|
||||
}, [loadStats, loadTasks]);
|
||||
if (initialLoading && tasks.length === 0) return;
|
||||
const timer = setInterval(() => { loadStats(); loadTasks(); }, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadStats, loadTasks, initialLoading, tasks.length]);
|
||||
|
||||
// ---------- filtered tasks ----------
|
||||
const filteredTasks = selectedType
|
||||
? tasks.filter((t) => t.type?.toUpperCase() === selectedType.toUpperCase())
|
||||
: tasks;
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const t of PROBE_TYPE_OPTIONS) counts[t] = 0;
|
||||
for (const t of tasks) {
|
||||
const key = normalizeProbeType(t.type);
|
||||
counts[key]++;
|
||||
}
|
||||
return counts;
|
||||
}, [tasks]);
|
||||
|
||||
// ---------- actions ----------
|
||||
const openCreate = () => {
|
||||
setForm({ ...emptyForm });
|
||||
const filteredTasks = selectedType === 'ALL'
|
||||
? tasks
|
||||
: tasks.filter((t) => normalizeProbeType(t.type) === selectedType);
|
||||
|
||||
const openCreate = (type?: ProbeType) => {
|
||||
setEditingTaskId(null);
|
||||
setForm({ ...emptyForm, type: type || (selectedType !== 'ALL' ? selectedType : 'HTTP') });
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
toast.error('名称和 URL 不能为空');
|
||||
return;
|
||||
}
|
||||
const openEdit = (task: SiteTask) => {
|
||||
setEditingTaskId(task.id);
|
||||
setForm({
|
||||
name: task.name, url: task.url,
|
||||
type: normalizeProbeType(task.type),
|
||||
frequency_sec: task.frequency_sec || 60,
|
||||
timeout_sec: task.timeout_sec || 10,
|
||||
expected_status_code: task.expected_status_code || 200,
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openDetail = (task: SiteTask) => {
|
||||
setDetailTask(task);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim() || !form.url.trim()) { toast.error('名称和地址不能为空'); return; }
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
name: form.name.trim(), url: form.url.trim(), type: form.type.toLowerCase(),
|
||||
frequency_sec: Number(form.frequency_sec) || 60,
|
||||
timeout_sec: Number(form.timeout_sec) || 10,
|
||||
expected_status_code: Number(form.expected_status_code) || 200,
|
||||
};
|
||||
try {
|
||||
await siteApi.createTask({
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
type: form.type.toLowerCase(),
|
||||
frequency_sec: Number(form.frequency_sec) || 60,
|
||||
timeout_sec: Number(form.timeout_sec) || 10,
|
||||
expected_status_code: Number(form.expected_status_code) || 200,
|
||||
});
|
||||
toast.success('拨测任务已创建');
|
||||
setFormOpen(false);
|
||||
loadTasks();
|
||||
loadStats();
|
||||
if (editingTaskId) { await siteApi.updateTask(editingTaskId, payload); toast.success('任务已更新'); }
|
||||
else { await siteApi.createTask(payload); toast.success('拨测任务已创建'); }
|
||||
setFormOpen(false); setEditingTaskId(null);
|
||||
loadTasks(); loadStats();
|
||||
} catch (e) {
|
||||
toast.error((e as JsonData).message || '创建失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
toast.error((e as JsonData).message || (editingTaskId ? '更新失败' : '创建失败'));
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (task: SiteTask) => {
|
||||
const ok = await _confirm({
|
||||
title: '删除拨测任务',
|
||||
description: `确定删除「${task.name}」吗?此操作不可恢复。`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
const ok = await _confirm({ title: '删除拨测任务', description: `确定删除「${task.name}」吗?`, variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await siteApi.deleteTask(task.id);
|
||||
if (detailTask?.id === task.id) {
|
||||
setDetailTask(null);
|
||||
setDetailOpen(false);
|
||||
}
|
||||
toast.success('任务已删除');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
toast.error((e as JsonData).message || '删除失败');
|
||||
}
|
||||
loadTasks(); loadStats();
|
||||
} catch (e) { toast.error((e as JsonData).message || '删除失败'); }
|
||||
};
|
||||
|
||||
const handleToggle = async (task: SiteTask) => {
|
||||
try {
|
||||
await siteApi.toggleTask(task.id, !task.enabled);
|
||||
toast.success(task.enabled ? '已停用' : '已启用');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
toast.error((e as JsonData).message || '操作失败');
|
||||
}
|
||||
loadTasks(); loadStats();
|
||||
} catch (e) { toast.error((e as JsonData).message || '操作失败'); }
|
||||
};
|
||||
|
||||
// ---------- render ----------
|
||||
const availStr =
|
||||
stats?.availability != null ? `${(stats.availability * 100).toFixed(1)}%` : '--';
|
||||
const targetMeta = probeTypeMeta[form.type];
|
||||
const availStr = stats?.availability != null ? `${(stats.availability * 100).toFixed(1)}%` : '--';
|
||||
const probeCountStr = stats?.probe_count != null ? String(stats.probe_count) : '--';
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
@@ -254,290 +180,199 @@ export function SiteMonitor() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ===== Probe Type Grid ===== */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{probeTypes.map((p) => {
|
||||
const active = selectedType === p.name;
|
||||
return (
|
||||
<Card
|
||||
key={p.name}
|
||||
onClick={() => setSelectedType(active ? null : p.name)}
|
||||
className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] transition-colors cursor-pointer ${
|
||||
active ? 'border-[var(--dg-blue)]/60 ring-1 ring-[var(--dg-blue)]/30' : 'hover:border-[var(--dg-blue)]/30'
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-3 text-center">
|
||||
<p.icon className={`h-5 w-5 mx-auto mb-1.5 ${p.color}`} />
|
||||
<p className="text-xs font-medium text-[var(--dg-text-primary)]">{p.name}</p>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{p.desc}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">站点拨测</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">HTTP Web 拨测 · SSL 证书检测 · TCP / DNS / PING</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" className="h-8" onClick={() => { loadStats(); loadTasks(); }} disabled={refreshing}>
|
||||
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${refreshing ? 'animate-spin' : ''}`} />刷新
|
||||
</Button>
|
||||
<Button size="sm" className="h-8 gap-1.5" onClick={() => openCreate()}>
|
||||
<Plus className="h-3.5 w-3.5" />添加任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Summary Stats ===== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">拨测任务概览</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => { loadStats(); loadTasks(); }}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-4 items-start">
|
||||
<div className="xl:col-span-8 space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{[
|
||||
{ label: '总任务数', value: stats?.total_tasks ?? '--' },
|
||||
{ label: '已启用', value: stats?.enabled_tasks ?? '--' },
|
||||
{ label: '总任务', value: stats?.total_tasks ?? '--' },
|
||||
{ label: '正常', value: stats?.up_count ?? '--', color: 'dg-text-green' },
|
||||
{ label: '异常', value: stats?.down_count ?? '--', color: 'dg-text-red' },
|
||||
{ label: '可用率', value: availStr, color: 'dg-text-blue' },
|
||||
{ label: '可用率(24h)', value: availStr, color: 'dg-text-blue' },
|
||||
{ label: '探测次数', value: probeCountStr },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-lg font-bold ${item.color || 'text-[var(--dg-text-primary)]'}`}>
|
||||
{item.value}
|
||||
</p>
|
||||
<div key={item.label} className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-base font-bold ${item.color || 'text-[var(--dg-text-primary)]'}`}>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== Task Table ===== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
拨测任务列表
|
||||
{selectedType && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-2 text-[10px] cursor-pointer border-[var(--dg-blue)]/40 text-[var(--dg-blue)] h-5"
|
||||
onClick={() => setSelectedType(null)}
|
||||
>
|
||||
{selectedType} ×
|
||||
</Badge>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
添加任务
|
||||
</Button>
|
||||
</div>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium">拨测任务</h3>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">点击行查看详情</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{FILTER_TYPES.map((type) => {
|
||||
const count = type === 'ALL' ? tasks.length : typeCounts[type] || 0;
|
||||
const active = selectedType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--dg-blue)]/15 text-[var(--dg-blue)] border border-[var(--dg-blue)]/30'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border border-transparent hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{type === 'ALL' ? '全部' : type} {count > 0 && count}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="text-center py-12 text-sm text-[var(--dg-text-muted)]">
|
||||
{tasks.length === 0
|
||||
? '暂无拨测任务,点击「添加任务」创建第一个'
|
||||
: '当前筛选条件下无任务'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">URL</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">类型</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">间隔</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">状态</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">延迟</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">上次检测</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">启用</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTasks.map((task) => (
|
||||
<TableRow key={task.id} className="border-[var(--dg-border)]">
|
||||
<TableCell className="text-sm text-[var(--dg-text-primary)] font-medium">
|
||||
{task.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)] max-w-[200px] truncate">
|
||||
{task.url}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[11px] border-[var(--dg-border)]">
|
||||
{task.type?.toUpperCase()}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">
|
||||
{task.frequency_sec}s
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{statusIcon(task.last_status)}
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">
|
||||
{statusBadge(task.last_status)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">
|
||||
{fmtMs(task.last_response_time_ms)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">
|
||||
{fmtTime(task.last_checked_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={task.enabled}
|
||||
onCheckedChange={() => handleToggle(task)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 dg-text-red hover:dg-text-red hover:bg-red-500/10"
|
||||
onClick={() => handleDelete(task)}
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="text-center py-10 text-sm text-[var(--dg-text-muted)]">
|
||||
{tasks.length === 0 ? '暂无拨测任务' : '当前筛选无任务'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-xs">名称</TableHead>
|
||||
<TableHead className="text-xs">类型</TableHead>
|
||||
<TableHead className="text-xs">频率</TableHead>
|
||||
<TableHead className="text-xs">报警状态</TableHead>
|
||||
<TableHead className="text-xs">可用率/响应时间</TableHead>
|
||||
<TableHead className="text-xs">启用</TableHead>
|
||||
<TableHead className="text-xs w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTasks.map((task) => (
|
||||
<TableRow
|
||||
key={task.id}
|
||||
className="border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-tertiary)]/60"
|
||||
onClick={() => openDetail(task)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TableCell className="py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">{task.name}</span>
|
||||
{isSslProbeTask(task) && isSslExpiringSoon(task.ssl_days_remaining) && (
|
||||
<Badge className="bg-amber-500/15 dg-text-yellow border-amber-500/30 text-[10px] shrink-0 gap-0.5">
|
||||
<ShieldAlert className="h-3 w-3" />证书
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] truncate max-w-[220px]">{task.url}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<Badge variant="outline" className="text-[10px]">{normalizeProbeType(task.type)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] py-2">{fmtFrequency(task.frequency_sec)}</TableCell>
|
||||
<TableCell className="py-2">{alertStatusBadge(task.alert_status)}</TableCell>
|
||||
<TableCell className="text-xs font-mono py-2">{fmtAvailResponse(task)}</TableCell>
|
||||
<TableCell className="py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch checked={task.enabled} onCheckedChange={() => handleToggle(task)} />
|
||||
</TableCell>
|
||||
<TableCell className="py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(task)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon-sm" className="dg-text-red" onClick={() => handleDelete(task)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ===== Create Task Dialog ===== */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<div className="xl:col-span-4">
|
||||
<SiteProtocolPieChart tasks={tasks} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SiteTaskDetailDialog
|
||||
task={detailTask}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
onProbed={() => { loadTasks(); loadStats(); }}
|
||||
/>
|
||||
|
||||
<Dialog open={formOpen} onOpenChange={(open) => { setFormOpen(open); if (!open) setEditingTaskId(null); }}>
|
||||
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加拨测任务</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
配置站点探活任务,系统将按指定频率自动检测
|
||||
</DialogDescription>
|
||||
<DialogTitle>{editingTaskId ? '编辑拨测任务' : '添加拨测任务'}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">按协议类型配置探活任务,系统将按间隔自动拨测</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{/* 名称 */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="task-name" className="text-[var(--dg-text-primary)]">任务名称</Label>
|
||||
<Input
|
||||
id="task-name"
|
||||
placeholder="例如:官网首页检测"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<Label>任务名称</Label>
|
||||
<Input placeholder="例如:官网首页" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="task-url" className="text-[var(--dg-text-primary)]">目标 URL</Label>
|
||||
<Input
|
||||
id="task-url"
|
||||
placeholder="例如:https://example.com 或 192.168.1.1"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型 */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">协议类型</Label>
|
||||
<Select
|
||||
value={form.type}
|
||||
onValueChange={(v) => setForm({ ...form, type: v as ProbeType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<Label>协议类型</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v as ProbeType })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROBE_TYPE_OPTIONS.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
{PROBE_TYPE_OPTIONS.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{targetMeta.hint}</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{targetMeta.label}</Label>
|
||||
<Input placeholder={targetMeta.placeholder} value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{/* 频率 & 超时 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="freq" className="text-[var(--dg-text-primary)]">
|
||||
检测间隔 (秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="freq"
|
||||
type="number"
|
||||
min={10}
|
||||
max={86400}
|
||||
value={form.frequency_sec}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, frequency_sec: Number(e.target.value) || 60 })
|
||||
}
|
||||
/>
|
||||
<Label>检测间隔 (秒)</Label>
|
||||
<Input type="number" min={10} max={86400} value={form.frequency_sec}
|
||||
onChange={(e) => setForm({ ...form, frequency_sec: Number(e.target.value) || 60 })} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="timeout" className="text-[var(--dg-text-primary)]">
|
||||
超时 (秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
value={form.timeout_sec}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, timeout_sec: Number(e.target.value) || 10 })
|
||||
}
|
||||
/>
|
||||
<Label>超时 (秒)</Label>
|
||||
<Input type="number" min={1} max={300} value={form.timeout_sec}
|
||||
onChange={(e) => setForm({ ...form, timeout_sec: Number(e.target.value) || 10 })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 期望状态码 (仅 HTTP/HTTPS 显示) */}
|
||||
{(form.type === 'HTTP' || form.type === 'HTTPS') && (
|
||||
{form.type === 'HTTP' && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="status-code" className="text-[var(--dg-text-primary)]">
|
||||
期望状态码
|
||||
</Label>
|
||||
<Input
|
||||
id="status-code"
|
||||
type="number"
|
||||
min={100}
|
||||
max={599}
|
||||
value={form.expected_status_code}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, expected_status_code: Number(e.target.value) || 200 })
|
||||
}
|
||||
/>
|
||||
<Label>期望状态码</Label>
|
||||
<Input type="number" min={100} max={599} value={form.expected_status_code}
|
||||
onChange={(e) => setForm({ ...form, expected_status_code: Number(e.target.value) || 200 })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
<Button variant="outline" onClick={() => { setFormOpen(false); setEditingTaskId(null); }} disabled={saving}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||
创建任务
|
||||
{editingTaskId ? '保存' : '创建任务'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// 监控图表工具栏 — 固定卡片/弹框右上角 // Logan.Li
|
||||
import { Maximize2, ZoomOut } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
zoomed?: boolean;
|
||||
onExpand: () => void;
|
||||
onResetZoom?: () => void;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChartToolbar({
|
||||
zoomed, onExpand, onResetZoom, size = 'sm', className,
|
||||
}: Props) {
|
||||
const btn = size === 'md' ? 'h-8 w-8' : 'h-7 w-7';
|
||||
const icon = size === 'md' ? 'h-4 w-4' : 'h-3.5 w-3.5';
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-0.5 shrink-0', className)}>
|
||||
{zoomed && onResetZoom && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(btn, 'dg-text-secondary hover:dg-text-primary hover:bg-[var(--dg-bg-hover)]')}
|
||||
title="重置缩放"
|
||||
onClick={onResetZoom}
|
||||
>
|
||||
<ZoomOut className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(btn, 'dg-text-secondary hover:dg-text-blue hover:bg-[var(--dg-bg-hover)]')}
|
||||
title="放大查看"
|
||||
onClick={onExpand}
|
||||
>
|
||||
<Maximize2 className={icon} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// 双线图表(网卡带宽 rx+tx)// Logan.Li
|
||||
// 双线图表(网卡带宽 rx+tx)— 工具栏标题栏右上角 + 宽屏弹框 // Logan.Li
|
||||
import { useRef, useState } from 'react';
|
||||
import { Network } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Brush,
|
||||
} from 'recharts';
|
||||
import { ZoomableAreaChart, type ZoomableChartHandle } from './ZoomableAreaChart';
|
||||
import { ChartToolbar } from './ChartToolbar';
|
||||
import { MetricIconBox } from './MetricIconBox';
|
||||
import type { AggType, BucketPoint } from './types';
|
||||
|
||||
interface DualLineChartProps {
|
||||
@@ -20,57 +20,58 @@ interface DualLineChartProps {
|
||||
export function DualLineChart({
|
||||
title, currentRx, currentTx, rxData, txData, aggType, unit,
|
||||
}: DualLineChartProps) {
|
||||
const chartRef = useRef<ZoomableChartHandle>(null);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
|
||||
const chartData = rxData.map((d, i) => ({
|
||||
time: d.time,
|
||||
timestamp: d.timestamp,
|
||||
rx: d[aggType] ?? 0,
|
||||
tx: txData[i]?.[aggType] ?? 0,
|
||||
}));
|
||||
const rxGradId = `grad-rx-${title.replace(/\s/g, '')}`;
|
||||
const txGradId = `grad-tx-${title.replace(/\s/g, '')}`;
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Network className="h-4 w-4 dg-text-blue" />
|
||||
<span className="text-xs text-[var(--dg-text-primary)] font-medium">{title}</span>
|
||||
<div className="flex items-start justify-between gap-2 mb-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<MetricIconBox icon={<Network />} tone="blue" />
|
||||
<span className="text-xs text-[var(--dg-text-primary)] font-medium leading-8">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-blue-400 mr-1 align-middle" />接收 <b className="text-blue-300">{currentRx}</b></span>
|
||||
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-emerald-400 mr-1 align-middle" />发送 <b className="dg-text-green">{currentTx}</b></span>
|
||||
<div className="flex items-center gap-3 flex-wrap justify-end">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span>
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-blue-400 mr-1 align-middle" />
|
||||
接收 <b className="text-blue-300">{currentRx}</b>
|
||||
</span>
|
||||
<span>
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-emerald-400 mr-1 align-middle" />
|
||||
发送 <b className="dg-text-green">{currentTx}</b>
|
||||
</span>
|
||||
</div>
|
||||
<ChartToolbar
|
||||
zoomed={zoomed}
|
||||
onExpand={() => chartRef.current?.expand()}
|
||||
onResetZoom={() => chartRef.current?.resetZoom()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-56">
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-md">暂无数据</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 2, right: 2, bottom: 0, left: -10 }}>
|
||||
<defs>
|
||||
<linearGradient id={rxGradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id={txGradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" strokeOpacity={0.5} />
|
||||
<XAxis dataKey="time" hide />
|
||||
<YAxis hide domain={[0, 'auto']} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '4px', color: '#f8fafc', fontSize: '11px', padding: '4px 8px' }}
|
||||
formatter={(v: number, name: string) => [`${v.toFixed(2)} ${unit}`, name === 'rx' ? '接收' : '发送']}
|
||||
labelFormatter={(label) => `时间: ${label}`} />
|
||||
<Area type="monotone" dataKey="rx" stroke="#3b82f6" strokeWidth={1.5} fill={`url(#${rxGradId})`} name="rx" />
|
||||
<Area type="monotone" dataKey="tx" stroke="#10b981" strokeWidth={1.5} fill={`url(#${txGradId})`} name="tx" />
|
||||
<Brush dataKey="time" height={24} stroke="#334155" fill="#0f172a"
|
||||
startIndex={Math.max(0, chartData.length - Math.min(20, chartData.length))} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<ZoomableAreaChart
|
||||
ref={chartRef}
|
||||
data={chartData}
|
||||
series={[
|
||||
{ dataKey: 'rx', color: '#3b82f6', label: '接收' },
|
||||
{ dataKey: 'tx', color: '#10b981', label: '发送' },
|
||||
]}
|
||||
yDomain={[0, 'auto']}
|
||||
unit={unit}
|
||||
chartId={`dual-${title.replace(/\s/g, '')}`}
|
||||
title={title}
|
||||
subtitle={`接收 ${currentRx} · 发送 ${currentTx}`}
|
||||
height={208}
|
||||
toolbarPlacement="header"
|
||||
onZoomedChange={setZoomed}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 健康探针 Tab + 对话框 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { agentApi } from '@/api';
|
||||
import { useHealthProbes } from '@/hooks/api/useHostMonitor';
|
||||
@@ -25,7 +26,7 @@ interface HealthProbeSectionProps {
|
||||
export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
const confirm = useConfirm();
|
||||
const qc = useQueryClient();
|
||||
const { data: probes = [], refetch } = useHealthProbes(selectedHost);
|
||||
const { data: probes = [], isFetching, refetch } = useHealthProbes(selectedHost);
|
||||
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
|
||||
const [editingProbe, setEditingProbe] = useState<Record<string, unknown> | null>(null);
|
||||
const [probeForm, setProbeForm] = useState<ProbeForm>(DEFAULT_PROBE_FORM());
|
||||
@@ -89,8 +90,8 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">探针列表(Agent 在目标主机本地执行探测)</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => refetch()}>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />刷新
|
||||
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('w-3 h-3 mr-1', isFetching && 'animate-spin')} />刷新
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={openCreateDialog}>
|
||||
<Plus className="w-3 h-3 mr-1" />添加探针
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// 主机核心指标条 — CPU/内存/磁盘/网络/磁盘IO 一览 // Logan.Li
|
||||
import { Cpu, HardDrive, Network, Disc, Server } from 'lucide-react';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { formatBPS } from './utils';
|
||||
|
||||
interface Props {
|
||||
latest: JsonData;
|
||||
netInfo: JsonData;
|
||||
diskIOInfo: JsonData;
|
||||
onJump?: (tab: string) => void;
|
||||
}
|
||||
|
||||
function tone(val: number, warn: number, crit: number): string {
|
||||
if (val >= crit) return 'dg-text-red';
|
||||
if (val >= warn) return 'dg-text-yellow';
|
||||
return 'text-[var(--dg-text-primary)]';
|
||||
}
|
||||
|
||||
export function HostCoreMetricsStrip({ latest, netInfo, diskIOInfo, onJump }: Props) {
|
||||
const cpu = Number(latest?.cpu_usage ?? latest?.cpu ?? 0);
|
||||
const mem = Number(latest?.memory_usage ?? latest?.mem_usage ?? latest?.mem ?? 0);
|
||||
const disk = Number(latest?.disk_usage ?? latest?.disk ?? 0);
|
||||
const rx = netInfo?.net_rx_bps != null ? formatBPS(netInfo.net_rx_bps as number) : '--';
|
||||
const tx = netInfo?.net_tx_bps != null ? formatBPS(netInfo.net_tx_bps as number) : '--';
|
||||
const ioR = diskIOInfo?.read_bps != null ? formatBPS(diskIOInfo.read_bps as number) : '--';
|
||||
const ioW = diskIOInfo?.write_bps != null ? formatBPS(diskIOInfo.write_bps as number) : '--';
|
||||
|
||||
const items = [
|
||||
{ tab: 'basic', icon: Cpu, label: 'CPU', value: `${cpu.toFixed(1)}%`, cls: tone(cpu, 80, 90) },
|
||||
{ tab: 'basic', icon: Server, label: '内存', value: `${mem.toFixed(1)}%`, cls: tone(mem, 85, 90) },
|
||||
{ tab: 'disk', icon: HardDrive, label: '磁盘', value: `${disk.toFixed(1)}%`, cls: tone(disk, 80, 90) },
|
||||
{ tab: 'network', icon: Network, label: '网络', value: `↓${rx} ↑${tx}`, cls: 'text-[var(--dg-text-primary)]' },
|
||||
{ tab: 'disk', icon: Disc, label: '磁盘IO', value: `↓${ioR} ↑${ioW}`, cls: 'text-[var(--dg-text-primary)]' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => onJump?.(item.tab)}
|
||||
className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-2.5 py-2 text-left hover:bg-[var(--dg-bg-tertiary)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<item.icon className="h-3 w-3 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</span>
|
||||
</div>
|
||||
<p className={`text-sm font-mono font-semibold truncate ${item.cls}`}>{item.value}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// 主机监控时间范围选择器 — 预设 + 自定义 // Logan.Li
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Calendar, ChevronDown, Timer } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
TIME_RANGE_PRESETS,
|
||||
type HostMonitorTimeRange,
|
||||
type TimeRangePresetId,
|
||||
} from './types';
|
||||
import { presetToRange, toDatetimeLocalValue, formatRangeSummary } from './timeRange';
|
||||
|
||||
interface Props {
|
||||
value: HostMonitorTimeRange;
|
||||
onChange: (range: HostMonitorTimeRange) => void;
|
||||
}
|
||||
|
||||
export function HostTimeRangePicker({ value, onChange }: Props) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const now = new Date();
|
||||
const [customStart, setCustomStart] = useState(toDatetimeLocalValue(new Date(now.getTime() - 3600_000)));
|
||||
const [customEnd, setCustomEnd] = useState(toDatetimeLocalValue(now));
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (popRef.current && !popRef.current.contains(e.target as Node)) setCustomOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const selectPreset = (id: TimeRangePresetId) => {
|
||||
if (id === 'custom') return;
|
||||
onChange(presetToRange(id));
|
||||
setCustomOpen(false);
|
||||
};
|
||||
|
||||
const applyCustom = () => {
|
||||
const start = new Date(customStart);
|
||||
const end = new Date(customEnd);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return;
|
||||
const s = start.getTime() <= end.getTime() ? start : end;
|
||||
const e = start.getTime() <= end.getTime() ? end : start;
|
||||
onChange({
|
||||
preset: 'custom',
|
||||
label: '自定义',
|
||||
start: s.toISOString(),
|
||||
end: e.toISOString(),
|
||||
buckets: Math.min(120, Math.max(36, Math.ceil((e.getTime() - s.getTime()) / 60_000 / 2))),
|
||||
});
|
||||
setCustomOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] dg-text-muted hidden sm:inline-flex items-center gap-1">
|
||||
<Timer className="h-3 w-3" />
|
||||
时间范围
|
||||
</span>
|
||||
<div className="flex items-center gap-1 rounded-md border border-[var(--dg-border)] p-0.5 bg-[var(--dg-bg-secondary)] flex-wrap">
|
||||
{TIME_RANGE_PRESETS.map((p) => (
|
||||
<Button
|
||||
key={p.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 text-[11px] px-2 rounded ${value.preset === p.id ? 'dg-btn-primary shadow-sm' : 'dg-text-secondary hover:dg-text-primary'}`}
|
||||
onClick={() => selectPreset(p.id)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
<div className="relative" ref={popRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 text-[11px] px-2 rounded gap-1 ${value.preset === 'custom' ? 'dg-btn-primary shadow-sm' : 'dg-text-secondary hover:dg-text-primary'}`}
|
||||
onClick={() => setCustomOpen((v) => !v)}
|
||||
>
|
||||
<Calendar className="h-3 w-3" />
|
||||
自定义
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${customOpen ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
{customOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-72 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 shadow-lg">
|
||||
<p className="text-xs font-medium text-[var(--dg-text-primary)] mb-2">自定义时间范围</p>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-[10px] dg-text-muted">开始</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={customStart}
|
||||
onChange={(e) => setCustomStart(e.target.value)}
|
||||
className="h-8 text-xs dg-input mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] dg-text-muted">结束</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={customEnd}
|
||||
onChange={(e) => setCustomEnd(e.target.value)}
|
||||
className="h-8 text-xs dg-input mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setCustomOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" size="sm" className="h-7 text-xs dg-btn-primary" onClick={applyCustom}>
|
||||
应用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] dg-text-muted tabular-nums hidden md:inline">
|
||||
{formatRangeSummary(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +1,71 @@
|
||||
// 指标图表卡片 // Logan.Li
|
||||
// 指标图表卡片 — 工具栏固定标题栏右上角 // Logan.Li
|
||||
import { useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Brush,
|
||||
} from 'recharts';
|
||||
import { ZoomableAreaChart, type ZoomableChartHandle, type ChartReferenceLine } from './ZoomableAreaChart';
|
||||
import { ChartToolbar } from './ChartToolbar';
|
||||
import { MetricIconBox, type Tone } from './MetricIconBox';
|
||||
import type { AggType, BucketPoint } from './types';
|
||||
|
||||
interface MetricChartCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
icon: ReactNode;
|
||||
iconTone?: Tone;
|
||||
data: BucketPoint[];
|
||||
color: string;
|
||||
unit: string;
|
||||
domain: [number, number | 'auto'];
|
||||
aggType: AggType;
|
||||
referenceLines?: ChartReferenceLine[];
|
||||
}
|
||||
|
||||
function safeChartId(title: string) {
|
||||
return title.replace(/[^a-zA-Z0-9]/g, '-').slice(0, 32);
|
||||
}
|
||||
|
||||
export function MetricChartCard({
|
||||
title, value, icon, data, color, unit, domain, aggType,
|
||||
title, value, icon, iconTone = 'blue', data, color, unit, domain, aggType, referenceLines,
|
||||
}: MetricChartCardProps) {
|
||||
const chartData = data.map(d => ({ ...d, value: d[aggType] }));
|
||||
const chartRef = useRef<ZoomableChartHandle>(null);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const chartData = data.map((d) => ({ ...d, value: d[aggType] }));
|
||||
const shortTitle = title.replace(/\(.+\)/, '').trim();
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1.5">{icon}<span className="text-xs text-[var(--dg-text-primary)] font-medium truncate">{title}</span></div>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<MetricIconBox icon={icon} tone={iconTone} />
|
||||
<span className="text-xs text-[var(--dg-text-primary)] font-medium truncate leading-8">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<ChartToolbar
|
||||
zoomed={zoomed}
|
||||
onExpand={() => chartRef.current?.expand()}
|
||||
onResetZoom={() => chartRef.current?.resetZoom()}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="mb-2 pl-10">
|
||||
<span className="text-xl font-bold text-[var(--dg-text-primary)] tabular-nums">{value}</span>
|
||||
{unit && <span className="text-xs text-[var(--dg-text-muted)] ml-1">{unit}</span>}
|
||||
</div>
|
||||
<div className="h-48">
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-md">暂无数据</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 2, right: 2, bottom: 0, left: -10 }}>
|
||||
<defs>
|
||||
<linearGradient id={`grad-${title}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" strokeOpacity={0.5} />
|
||||
<XAxis dataKey="time" hide />
|
||||
<YAxis hide domain={domain[1] === 'auto' ? [0, (max: number) => max * 1.1] : domain as [number, number]} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '4px', color: '#f8fafc', fontSize: '11px', padding: '4px 8px' }}
|
||||
formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, title.replace(/\(.+\)/, '')]}
|
||||
labelFormatter={(label) => `时间: ${label}`} />
|
||||
<Area type="monotone" dataKey="value" stroke={color} strokeWidth={1.5} fill={`url(#grad-${title})`} />
|
||||
<Brush dataKey="time" height={24} stroke="#334155" fill="#0f172a"
|
||||
startIndex={Math.max(0, chartData.length - Math.min(20, chartData.length))} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<ZoomableAreaChart
|
||||
ref={chartRef}
|
||||
data={chartData}
|
||||
series={[{ dataKey: 'value', color, label: shortTitle }]}
|
||||
yDomain={domain}
|
||||
unit={unit}
|
||||
chartId={safeChartId(title)}
|
||||
title={title}
|
||||
subtitle={`当前 ${value}${unit ? ` ${unit}` : ''}`}
|
||||
height={188}
|
||||
toolbarPlacement="header"
|
||||
onZoomedChange={setZoomed}
|
||||
referenceLines={referenceLines}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 监控指标图标容器 — 统一尺寸与色块 // Logan.Li
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type Tone = 'blue' | 'green' | 'orange' | 'red' | 'purple' | 'cyan' | 'yellow';
|
||||
|
||||
const toneClass: Record<Tone, string> = {
|
||||
blue: 'border-blue-500/25 bg-blue-500/10 text-blue-400',
|
||||
green: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-400',
|
||||
orange: 'border-orange-500/25 bg-orange-500/10 text-orange-400',
|
||||
red: 'border-red-500/25 bg-red-500/10 text-red-400',
|
||||
purple: 'border-purple-500/25 bg-purple-500/10 text-purple-400',
|
||||
cyan: 'border-cyan-500/25 bg-cyan-500/10 text-cyan-400',
|
||||
yellow: 'border-amber-500/25 bg-amber-500/10 text-amber-400',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
icon: ReactNode;
|
||||
tone?: Tone;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MetricIconBox({ icon, tone = 'blue', className }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center w-8 h-8 rounded-lg border shrink-0',
|
||||
'[&_svg]:h-4 [&_svg]:w-4',
|
||||
toneClass[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// 可缩放面积图 — 光标拖拽选区 / 宽屏弹框展开 // Logan.Li
|
||||
import {
|
||||
useState, useMemo, useCallback, useId, useEffect,
|
||||
forwardRef, useImperativeHandle,
|
||||
} from 'react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, ReferenceArea, ReferenceLine,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ChartToolbar } from './ChartToolbar';
|
||||
import { MonitorChartTooltip } from '../shared/monitorChartTheme';
|
||||
import { ChartRangeHint } from '../shared/ChartRangeHint';
|
||||
|
||||
export interface ChartSeries {
|
||||
dataKey: string;
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ChartReferenceLine {
|
||||
value: number;
|
||||
label?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ZoomableChartHandle {
|
||||
expand: () => void;
|
||||
resetZoom: () => void;
|
||||
}
|
||||
|
||||
interface ZoomRange {
|
||||
left: string;
|
||||
right: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: Record<string, unknown>[];
|
||||
series: ChartSeries[];
|
||||
xDataKey?: string;
|
||||
height?: number | string;
|
||||
unit?: string;
|
||||
yDomain?: [number, number | 'auto'];
|
||||
chartId: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showHint?: boolean;
|
||||
/** header=按钮由父组件放在卡片标题栏;overlay=浮在图表右上角(无卡片场景) // Logan.Li */
|
||||
toolbarPlacement?: 'header' | 'overlay';
|
||||
onZoomedChange?: (zoomed: boolean) => void;
|
||||
referenceLines?: ChartReferenceLine[];
|
||||
}
|
||||
|
||||
const dialogClass =
|
||||
'!max-w-[min(96vw,88rem)] sm:!max-w-[min(96vw,88rem)] w-[min(96vw,88rem)] max-h-[92vh] flex flex-col gap-2 p-4 sm:p-5 bg-[var(--dg-bg-primary)] border-[var(--dg-border)] overflow-hidden';
|
||||
|
||||
function ChartBody({
|
||||
displayData,
|
||||
series,
|
||||
xDataKey,
|
||||
height,
|
||||
unit,
|
||||
yDomain,
|
||||
gradPrefix,
|
||||
zoomRange,
|
||||
refLeft,
|
||||
refRight,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
compact,
|
||||
referenceLines,
|
||||
}: {
|
||||
displayData: Record<string, unknown>[];
|
||||
series: ChartSeries[];
|
||||
xDataKey: string;
|
||||
height: number | string;
|
||||
unit: string;
|
||||
yDomain: [number, number | 'auto'];
|
||||
gradPrefix: string;
|
||||
zoomRange: ZoomRange | null;
|
||||
refLeft: string;
|
||||
refRight: string;
|
||||
onMouseDown: (e: { activeLabel?: string }) => void;
|
||||
onMouseMove: (e: { activeLabel?: string }) => void;
|
||||
onMouseUp: () => void;
|
||||
compact?: boolean;
|
||||
referenceLines?: ChartReferenceLine[];
|
||||
}) {
|
||||
return (
|
||||
<div style={{ height }} className="select-none w-full min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={displayData}
|
||||
margin={{ top: 6, right: compact ? 12 : 8, bottom: compact ? 12 : 8, left: 2 }}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
style={{ cursor: 'crosshair' }}
|
||||
>
|
||||
<defs>
|
||||
{series.map((s) => (
|
||||
<linearGradient key={s.dataKey} id={`${gradPrefix}-${s.dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={s.color} stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor={s.color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" strokeOpacity={0.6} />
|
||||
<XAxis
|
||||
dataKey={xDataKey}
|
||||
tick={{ fontSize: compact ? 11 : 10, fill: 'var(--dg-text-muted)' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: 'var(--dg-border)' }}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={compact ? 56 : 40}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: compact ? 11 : 10, fill: 'var(--dg-text-muted)' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={compact ? 44 : 36}
|
||||
domain={yDomain[1] === 'auto' ? [0, (max: number) => max * 1.15] : yDomain as [number, number]}
|
||||
tickFormatter={(v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(Math.round(v * 10) / 10))}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(
|
||||
<MonitorChartTooltip
|
||||
nameFormatter={(name) => series.find((s) => s.dataKey === name)?.label || name}
|
||||
valueFormatter={(v) => `${Number(v).toFixed(2)}${unit ? ` ${unit}` : ''}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{referenceLines?.map((line) => (
|
||||
<ReferenceLine
|
||||
key={`ref-${line.value}-${line.label || ''}`}
|
||||
y={line.value}
|
||||
stroke={line.color || 'var(--dg-yellow)'}
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.75}
|
||||
label={{
|
||||
value: line.label || String(line.value),
|
||||
position: 'insideTopRight',
|
||||
fontSize: compact ? 10 : 9,
|
||||
fill: line.color || 'var(--dg-yellow)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{series.map((s) => (
|
||||
<Area
|
||||
key={s.dataKey}
|
||||
type="monotone"
|
||||
dataKey={s.dataKey}
|
||||
stroke={s.color}
|
||||
strokeWidth={compact ? 2 : 1.5}
|
||||
fill={`url(#${gradPrefix}-${s.dataKey})`}
|
||||
name={s.dataKey}
|
||||
activeDot={{ r: compact ? 5 : 4, strokeWidth: 0 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
{refLeft && refRight ? (
|
||||
<ReferenceArea
|
||||
x1={refLeft}
|
||||
x2={refRight}
|
||||
strokeOpacity={0.4}
|
||||
fill="var(--dg-blue)"
|
||||
fillOpacity={0.15}
|
||||
stroke="var(--dg-blue)"
|
||||
/>
|
||||
) : null}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ZoomableAreaChart = forwardRef<ZoomableChartHandle, Props>(function ZoomableAreaChart({
|
||||
data,
|
||||
series,
|
||||
xDataKey = 'time',
|
||||
height = 192,
|
||||
unit = '',
|
||||
yDomain = [0, 'auto'],
|
||||
chartId,
|
||||
title,
|
||||
subtitle,
|
||||
showHint = true,
|
||||
toolbarPlacement = 'header',
|
||||
onZoomedChange,
|
||||
referenceLines,
|
||||
}, ref) {
|
||||
const uid = useId().replace(/:/g, '');
|
||||
const gradPrefix = `grad-${chartId}-${uid}`;
|
||||
|
||||
const [zoomRange, setZoomRange] = useState<ZoomRange | null>(null);
|
||||
const [refLeft, setRefLeft] = useState('');
|
||||
const [refRight, setRefRight] = useState('');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const dataFingerprint = useMemo(() => {
|
||||
if (data.length === 0) return 'empty';
|
||||
const first = data[0]?.[xDataKey];
|
||||
const last = data[data.length - 1]?.[xDataKey];
|
||||
return `${data.length}|${String(first)}|${String(last)}`;
|
||||
}, [data, xDataKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setZoomRange(null);
|
||||
setRefLeft('');
|
||||
setRefRight('');
|
||||
setExpanded(false);
|
||||
}, [dataFingerprint]);
|
||||
|
||||
useEffect(() => {
|
||||
onZoomedChange?.(!!zoomRange);
|
||||
}, [zoomRange, onZoomedChange]);
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
if (!zoomRange || data.length === 0) return data;
|
||||
const li = data.findIndex((d) => d[xDataKey] === zoomRange.left);
|
||||
const ri = data.findIndex((d) => d[xDataKey] === zoomRange.right);
|
||||
if (li < 0 || ri < 0) return data;
|
||||
const start = Math.min(li, ri);
|
||||
const end = Math.max(li, ri);
|
||||
if (end - start < 2) return data;
|
||||
return data.slice(start, end + 1);
|
||||
}, [data, zoomRange, xDataKey]);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setZoomRange(null);
|
||||
setRefLeft('');
|
||||
setRefRight('');
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
expand: () => setExpanded(true),
|
||||
resetZoom: zoomOut,
|
||||
}), [zoomOut]);
|
||||
|
||||
const onMouseDown = useCallback((e: { activeLabel?: string }) => {
|
||||
if (!e?.activeLabel) return;
|
||||
setRefLeft(String(e.activeLabel));
|
||||
setRefRight(String(e.activeLabel));
|
||||
}, []);
|
||||
|
||||
const onMouseMove = useCallback((e: { activeLabel?: string }) => {
|
||||
if (!refLeft || !e?.activeLabel) return;
|
||||
setRefRight(String(e.activeLabel));
|
||||
}, [refLeft]);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
if (!refLeft || !refRight || refLeft === refRight) {
|
||||
setRefLeft('');
|
||||
setRefRight('');
|
||||
return;
|
||||
}
|
||||
setZoomRange({ left: refLeft, right: refRight });
|
||||
setRefLeft('');
|
||||
setRefRight('');
|
||||
}, [refLeft, refRight]);
|
||||
|
||||
const chartProps = {
|
||||
displayData,
|
||||
series,
|
||||
xDataKey,
|
||||
unit,
|
||||
yDomain,
|
||||
gradPrefix,
|
||||
zoomRange,
|
||||
refLeft,
|
||||
refRight,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
referenceLines,
|
||||
};
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-xs dg-text-muted border border-dashed border-[var(--dg-border)] rounded-md w-full"
|
||||
style={{ height }}
|
||||
>
|
||||
暂无数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative w-full">
|
||||
{toolbarPlacement === 'overlay' && (
|
||||
<div className="absolute top-1 right-1 z-10">
|
||||
<ChartToolbar
|
||||
zoomed={!!zoomRange}
|
||||
onExpand={() => setExpanded(true)}
|
||||
onResetZoom={zoomOut}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ChartBody {...chartProps} height={height} />
|
||||
{showHint && toolbarPlacement === 'header' && (
|
||||
<ChartRangeHint
|
||||
label={zoomRange ? `${zoomRange.left} — ${zoomRange.right}` : null}
|
||||
zoomed={!!zoomRange}
|
||||
onReset={zoomOut}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={expanded} onOpenChange={setExpanded}>
|
||||
<DialogContent className={dialogClass}>
|
||||
<DialogHeader className="shrink-0 flex-row items-start justify-between gap-4 space-y-0 pr-10">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-base truncate">{title || '指标详情'}</DialogTitle>
|
||||
{subtitle && (
|
||||
<p className="text-xs dg-text-muted mt-0.5 truncate">{subtitle}</p>
|
||||
)}
|
||||
<DialogDescription className="sr-only">放大查看监控指标趋势</DialogDescription>
|
||||
</div>
|
||||
<ChartToolbar
|
||||
size="md"
|
||||
zoomed={!!zoomRange}
|
||||
onExpand={() => setExpanded(true)}
|
||||
onResetZoom={zoomOut}
|
||||
/>
|
||||
</DialogHeader>
|
||||
|
||||
{series.length > 1 && (
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs shrink-0 px-0.5">
|
||||
{series.map((s) => (
|
||||
<span key={s.dataKey} className="inline-flex items-center gap-1.5 dg-text-secondary">
|
||||
<span className="w-3 h-0.5 rounded-full" style={{ backgroundColor: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 宽屏趋势区:21:8 比例,监控弹框标准形态 // Logan.Li */}
|
||||
<div className="w-full flex-1 min-h-[260px] max-h-[min(48vh,480px)] aspect-[21/8]">
|
||||
<ChartBody {...chartProps} height="100%" compact />
|
||||
</div>
|
||||
|
||||
{zoomRange && (
|
||||
<div className="shrink-0 border-t border-[var(--dg-border)] pt-2">
|
||||
<ChartRangeHint
|
||||
label={`${zoomRange.left} — ${zoomRange.right}`}
|
||||
zoomed
|
||||
onReset={zoomOut}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// 主机监控时间范围工具 // Logan.Li
|
||||
import {
|
||||
TIME_RANGE_PRESETS,
|
||||
DEFAULT_TIME_RANGE,
|
||||
type HostMonitorTimeRange,
|
||||
type TimeRangePresetId,
|
||||
} from './types';
|
||||
|
||||
export function presetToRange(id: TimeRangePresetId): HostMonitorTimeRange {
|
||||
const p = TIME_RANGE_PRESETS.find((x) => x.id === id);
|
||||
if (!p) return DEFAULT_TIME_RANGE;
|
||||
return { preset: p.id, label: p.label, minutes: p.minutes, buckets: p.buckets };
|
||||
}
|
||||
|
||||
export function getRangeSpanMinutes(range: HostMonitorTimeRange): number {
|
||||
if (range.preset === 'custom' && range.start && range.end) {
|
||||
const diff = new Date(range.end).getTime() - new Date(range.start).getTime();
|
||||
return Math.max(1, Math.round(diff / 60_000));
|
||||
}
|
||||
return range.minutes ?? 60;
|
||||
}
|
||||
|
||||
/** Query 缓存 key // Logan.Li */
|
||||
export function rangeQueryKey(range: HostMonitorTimeRange): string {
|
||||
if (range.preset === 'custom' && range.start && range.end) {
|
||||
return `custom:${range.start}:${range.end}`;
|
||||
}
|
||||
return `preset:${range.preset}:${range.minutes}`;
|
||||
}
|
||||
|
||||
export function toDatetimeLocalValue(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function formatRangeSummary(range: HostMonitorTimeRange): string {
|
||||
if (range.preset === 'custom' && range.start && range.end) {
|
||||
const fmt = (s: string) => {
|
||||
const d = new Date(s);
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
});
|
||||
};
|
||||
return `${fmt(range.start)} — ${fmt(range.end)}`;
|
||||
}
|
||||
return range.label;
|
||||
}
|
||||
|
||||
export function buildHistoryQuery(range: HostMonitorTimeRange): {
|
||||
minutes?: number;
|
||||
start?: string;
|
||||
end?: string;
|
||||
hours?: number;
|
||||
} {
|
||||
if (range.preset === 'custom' && range.start && range.end) {
|
||||
return { start: range.start, end: range.end };
|
||||
}
|
||||
if (range.minutes != null) {
|
||||
return { minutes: range.minutes };
|
||||
}
|
||||
return { hours: 1 };
|
||||
}
|
||||
@@ -1,15 +1,44 @@
|
||||
// 主机监控类型定义 // Logan.Li
|
||||
|
||||
export const TIME_RANGES = [
|
||||
{ label: '1小时', hours: 1, window: 60, buckets: 60 },
|
||||
{ label: '6小时', hours: 6, window: 300, buckets: 72 },
|
||||
{ label: '12小时', hours: 12, window: 600, buckets: 72 },
|
||||
{ label: '1天', hours: 24, window: 1800, buckets: 96 },
|
||||
{ label: '3天', hours: 72, window: 3600, buckets: 72 },
|
||||
{ label: '7天', hours: 168, window: 10800, buckets: 56 },
|
||||
{ label: '14天', hours: 336, window: 21600, buckets: 48 },
|
||||
/** 预设时间范围(分钟粒度,支持 15/30 分钟) // Logan.Li */
|
||||
export const TIME_RANGE_PRESETS = [
|
||||
{ id: '15m', label: '15分钟', minutes: 15, buckets: 30 },
|
||||
{ id: '30m', label: '30分钟', minutes: 30, buckets: 36 },
|
||||
{ id: '1h', label: '1小时', minutes: 60, buckets: 60 },
|
||||
{ id: '6h', label: '6小时', minutes: 360, buckets: 72 },
|
||||
{ id: '12h', label: '12小时', minutes: 720, buckets: 72 },
|
||||
{ id: '1d', label: '1天', minutes: 1440, buckets: 96 },
|
||||
{ id: '7d', label: '7天', minutes: 10080, buckets: 84 },
|
||||
] as const;
|
||||
|
||||
export type TimeRangePresetId = (typeof TIME_RANGE_PRESETS)[number]['id'] | 'custom';
|
||||
|
||||
export interface HostMonitorTimeRange {
|
||||
preset: TimeRangePresetId;
|
||||
label: string;
|
||||
/** 相对范围分钟数(预设) // Logan.Li */
|
||||
minutes?: number;
|
||||
/** 自定义绝对范围 ISO 字符串 // Logan.Li */
|
||||
start?: string;
|
||||
end?: string;
|
||||
buckets: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TIME_RANGE: HostMonitorTimeRange = {
|
||||
preset: '1h',
|
||||
label: '1小时',
|
||||
minutes: 60,
|
||||
buckets: 60,
|
||||
};
|
||||
|
||||
/** @deprecated 兼容旧引用 */
|
||||
export const TIME_RANGES = TIME_RANGE_PRESETS.map((p) => ({
|
||||
label: p.label,
|
||||
hours: p.minutes / 60,
|
||||
window: 60,
|
||||
buckets: p.buckets,
|
||||
}));
|
||||
|
||||
export type AggType = 'avg' | 'max' | 'min';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -1,8 +1,54 @@
|
||||
// 主机监控工具函数 // Logan.Li
|
||||
import type { BucketPoint } from './types';
|
||||
import type { BucketPoint, HostMonitorTimeRange } from './types';
|
||||
import type { JsonData } from '@/types/common';
|
||||
/** 按时间跨度格式化 X 轴标签(避免 7 天范围只显示时分) // Logan.Li */
|
||||
export function formatAxisTime(ts: number, spanMinutes: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
if (spanMinutes <= 180) {
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
if (spanMinutes <= 2880) {
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
});
|
||||
}
|
||||
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
|
||||
export function bucketWithStats(raw: number[], timestamps: number[], targetBuckets: number): BucketPoint[] {
|
||||
function recordTimestamp(r: JsonData): number {
|
||||
const raw = r.report_time || r.created_at;
|
||||
if (!raw) return 0;
|
||||
const t = new Date(raw as string).getTime();
|
||||
return Number.isNaN(t) ? 0 : t / 1000;
|
||||
}
|
||||
|
||||
/** 客户端二次过滤,确保图表与所选时间一致 // Logan.Li */
|
||||
export function filterHistoryByRange(history: JsonData[], range: HostMonitorTimeRange): JsonData[] {
|
||||
if (!history.length) return history;
|
||||
const now = Date.now();
|
||||
let sinceMs: number;
|
||||
let untilMs = now;
|
||||
|
||||
if (range.preset === 'custom' && range.start && range.end) {
|
||||
sinceMs = new Date(range.start).getTime();
|
||||
untilMs = new Date(range.end).getTime();
|
||||
} else {
|
||||
const mins = range.minutes ?? 60;
|
||||
sinceMs = now - mins * 60_000;
|
||||
}
|
||||
|
||||
return history.filter((r) => {
|
||||
const ts = recordTimestamp(r) * 1000;
|
||||
return ts >= sinceMs && ts <= untilMs;
|
||||
});
|
||||
}
|
||||
|
||||
export function bucketWithStats(
|
||||
raw: number[],
|
||||
timestamps: number[],
|
||||
targetBuckets: number,
|
||||
spanMinutes: number,
|
||||
): BucketPoint[] {
|
||||
if (!raw || raw.length === 0) return [];
|
||||
const perBucket = Math.max(1, Math.ceil(raw.length / targetBuckets));
|
||||
const result: BucketPoint[] = [];
|
||||
@@ -15,7 +61,7 @@ export function bucketWithStats(raw: number[], timestamps: number[], targetBucke
|
||||
const min = Math.min(...slice);
|
||||
const ts = tsSlice[Math.floor(tsSlice.length / 2)] || 0;
|
||||
result.push({
|
||||
time: new Date(ts * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
time: formatAxisTime(ts, spanMinutes),
|
||||
timestamp: ts,
|
||||
avg: Number(avg.toFixed(2)),
|
||||
max: Number(max.toFixed(2)),
|
||||
@@ -40,9 +86,12 @@ export function formatBPS(bps: number): string {
|
||||
return `${bps.toFixed(1)} B/s`;
|
||||
}
|
||||
|
||||
interface TimeConfig { buckets: number; }
|
||||
interface TimeConfig { buckets: number; spanMinutes: number; }
|
||||
|
||||
export function buildMetricsFromHistory(history: JsonData[], timeConfig: TimeConfig) {
|
||||
const spanMinutes = timeConfig.spanMinutes;
|
||||
const bucket = (raw: number[], ts: number[]) =>
|
||||
bucketWithStats(raw, ts, timeConfig.buckets, spanMinutes);
|
||||
const rawCpu: number[] = []; const rawMem: number[] = []; const rawLoad1: number[] = [];
|
||||
const rawDisk: number[] = []; const rawTcp: number[] = []; const rawTw: number[] = []; const rawCw: number[] = [];
|
||||
const rawRx: number[] = []; const rawTx: number[] = [];
|
||||
@@ -55,7 +104,7 @@ export function buildMetricsFromHistory(history: JsonData[], timeConfig: TimeCon
|
||||
const timestamps: number[] = [];
|
||||
|
||||
for (const r of history) {
|
||||
const ts = r.report_time ? new Date(r.report_time).getTime() / 1000 : (r.created_at ? new Date(r.created_at).getTime() / 1000 : 0);
|
||||
const ts = recordTimestamp(r);
|
||||
timestamps.push(ts);
|
||||
rawCpu.push(r.cpu_usage || 0);
|
||||
rawIOWait.push(r.cpu_iowait || 0);
|
||||
@@ -95,29 +144,40 @@ export function buildMetricsFromHistory(history: JsonData[], timeConfig: TimeCon
|
||||
}
|
||||
|
||||
return {
|
||||
cpu: bucketWithStats(rawCpu, timestamps, timeConfig.buckets),
|
||||
mem: bucketWithStats(rawMem, timestamps, timeConfig.buckets),
|
||||
load1: bucketWithStats(rawLoad1, timestamps, timeConfig.buckets),
|
||||
disk: bucketWithStats(rawDisk, timestamps, timeConfig.buckets),
|
||||
tcpEst: bucketWithStats(rawTcp, timestamps, timeConfig.buckets),
|
||||
tcpTW: bucketWithStats(rawTw, timestamps, timeConfig.buckets),
|
||||
tcpCW: bucketWithStats(rawCw, timestamps, timeConfig.buckets),
|
||||
rx: bucketWithStats(rawRx.map(v => v / 1024), timestamps, timeConfig.buckets),
|
||||
tx: bucketWithStats(rawTx.map(v => v / 1024), timestamps, timeConfig.buckets),
|
||||
ioRead: bucketWithStats(rawIoR.map(v => v / 1024), timestamps, timeConfig.buckets),
|
||||
ioWrite: bucketWithStats(rawIoW.map(v => v / 1024), timestamps, timeConfig.buckets),
|
||||
swap: bucketWithStats(rawSwap, timestamps, timeConfig.buckets),
|
||||
fd: bucketWithStats(rawFD, timestamps, timeConfig.buckets),
|
||||
iowait: bucketWithStats(rawIOWait, timestamps, timeConfig.buckets),
|
||||
memAvail: bucketWithStats(rawMemAvail, timestamps, timeConfig.buckets),
|
||||
ioUtil: bucketWithStats(rawIoUtil, timestamps, timeConfig.buckets),
|
||||
connTotal: bucketWithStats(rawConnTotal, timestamps, timeConfig.buckets),
|
||||
rxErr: bucketWithStats(rawRxErr, timestamps, timeConfig.buckets),
|
||||
txErr: bucketWithStats(rawTxErr, timestamps, timeConfig.buckets),
|
||||
inode: bucketWithStats(rawInode, timestamps, timeConfig.buckets),
|
||||
cpu: bucket(rawCpu, timestamps),
|
||||
mem: bucket(rawMem, timestamps),
|
||||
load1: bucket(rawLoad1, timestamps),
|
||||
disk: bucket(rawDisk, timestamps),
|
||||
tcpEst: bucket(rawTcp, timestamps),
|
||||
tcpTW: bucket(rawTw, timestamps),
|
||||
tcpCW: bucket(rawCw, timestamps),
|
||||
rx: bucket(rawRx.map((v) => v / 1024), timestamps),
|
||||
tx: bucket(rawTx.map((v) => v / 1024), timestamps),
|
||||
ioRead: bucket(rawIoR.map((v) => v / 1024), timestamps),
|
||||
ioWrite: bucket(rawIoW.map((v) => v / 1024), timestamps),
|
||||
swap: bucket(rawSwap, timestamps),
|
||||
fd: bucket(rawFD, timestamps),
|
||||
iowait: bucket(rawIOWait, timestamps),
|
||||
memAvail: bucket(rawMemAvail, timestamps),
|
||||
ioUtil: bucket(rawIoUtil, timestamps),
|
||||
connTotal: bucket(rawConnTotal, timestamps),
|
||||
rxErr: bucket(rawRxErr, timestamps),
|
||||
txErr: bucket(rawTxErr, timestamps),
|
||||
inode: bucket(rawInode, timestamps),
|
||||
};
|
||||
}
|
||||
|
||||
/** 从过滤后历史计算实际数据时间跨度(用于展示) // Logan.Li */
|
||||
export function getActualDataSpan(history: JsonData[], range: HostMonitorTimeRange): string | null {
|
||||
if (!history.length) return null;
|
||||
const ts = history.map(recordTimestamp).filter((t) => t > 0);
|
||||
if (!ts.length) return null;
|
||||
const min = Math.min(...ts);
|
||||
const max = Math.max(...ts);
|
||||
const spanMin = Math.max(1, Math.round((max - min) / 60));
|
||||
return `${formatAxisTime(min, spanMin)} — ${formatAxisTime(max, spanMin)} · ${history.length} 点`;
|
||||
}
|
||||
|
||||
export function getCurrentVal(latest: JsonData, netInfo: JsonData, diskIOInfo: JsonData, swapInfo: JsonData, fdInfo: JsonData,
|
||||
key: string, fallback = '--',
|
||||
): string {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 中间件实例卡片 — 紧凑列表项,详情见右侧可观测面板 // Logan.Li
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, Loader2, Pencil, Trash2, Zap } from 'lucide-react';
|
||||
import type { MwTarget } from './helpers';
|
||||
import {
|
||||
evalMwHealth, fmtLastSeen, fmtVal,
|
||||
healthLevelColor, redisModeLabel, worstHealthLevel,
|
||||
} from './helpers';
|
||||
|
||||
interface Props {
|
||||
target: MwTarget;
|
||||
selected?: boolean;
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
}
|
||||
|
||||
export function InstanceCard({ target, selected, onSelect, onEdit, onDelete, onTest, testing }: Props) {
|
||||
const m = target.latest_metrics || {};
|
||||
const isRedis = target.description === 'redis';
|
||||
const issues = evalMwHealth(target);
|
||||
const health = worstHealthLevel(issues);
|
||||
const healthColor = healthLevelColor(health);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`w-full text-left rounded-lg px-2.5 py-2 border transition-colors ${
|
||||
selected
|
||||
? 'border-cyan-500/50 bg-[var(--dg-bg-tertiary)] ring-1 ring-cyan-500/20'
|
||||
: 'border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] hover:border-[var(--dg-border)]/80'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0 mb-1">
|
||||
<span className="text-sm font-medium truncate">{target.name}</span>
|
||||
{health !== 'ok' && (
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" style={{ color: healthColor }} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 truncate">
|
||||
{target.ip}:{target.port}
|
||||
{isRedis && ` · ${redisModeLabel(target.config_public?.redis_mode)}`}
|
||||
{' · '}{fmtLastSeen(target.last_seen_at)}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
{isRedis ? (
|
||||
<>
|
||||
<MetricPill label="QPS" value={fmtVal(m.redis_qps, 1)} />
|
||||
<MetricPill label="Key" value={fmtVal(m.redis_db_size)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MetricPill label="QPS" value={fmtVal(m.mysql_qps, 1)} />
|
||||
<MetricPill label="线程" value={fmtVal(m.mysql_threads_running)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={onTest} disabled={testing}>
|
||||
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3" />}测试
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={onEdit}>
|
||||
<Pencil className="h-3 w-3" />编辑
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] dg-text-red" onClick={onDelete}>
|
||||
<Trash2 className="h-3 w-3" />删除
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] bg-[var(--dg-bg-primary)] rounded px-1.5 py-0.5">
|
||||
<span className="text-[var(--dg-text-muted)]">{label}</span>
|
||||
<span className="font-mono font-medium">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// 中间件添加/编辑弹框 // Logan.Li
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { middlewareMonitorApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import type { MwTarget } from './helpers';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
editTarget: MwTarget | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function MiddlewareDialog({ open, onOpenChange, editTarget, onSuccess }: Props) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '', type: 'redis' as 'redis' | 'mysql', ip: '', port: 6379,
|
||||
password: '', username: '', database: '',
|
||||
redis_mode: 'standalone' as 'standalone' | 'cluster',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editTarget) {
|
||||
setForm({
|
||||
name: editTarget.name || '',
|
||||
type: editTarget.description === 'mysql' ? 'mysql' : 'redis',
|
||||
ip: editTarget.ip || '',
|
||||
port: editTarget.port || 6379,
|
||||
password: '',
|
||||
username: editTarget.config_public?.username || '',
|
||||
database: editTarget.config_public?.database || '',
|
||||
redis_mode: editTarget.config_public?.redis_mode || 'standalone',
|
||||
});
|
||||
} else {
|
||||
setForm({ name: '', type: 'redis', ip: '', port: 6379, password: '', username: '', database: '', redis_mode: 'standalone' });
|
||||
}
|
||||
}, [editTarget, open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim() || !form.ip.trim()) {
|
||||
toast.error('请填写名称和 IP');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name.trim(), type: form.type, ip: form.ip.trim(),
|
||||
port: Number(form.port) || (form.type === 'redis' ? 6379 : 3306), interval: 30,
|
||||
};
|
||||
if (form.password) payload.password = form.password;
|
||||
if (form.type === 'redis') payload.redis_mode = form.redis_mode;
|
||||
if (form.type === 'mysql') {
|
||||
if (form.username) payload.username = form.username;
|
||||
if (form.database) payload.database = form.database;
|
||||
}
|
||||
if (editTarget) {
|
||||
await middlewareMonitorApi.update(editTarget.id, payload);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
await middlewareMonitorApi.create(payload);
|
||||
toast.success('创建成功');
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
toast.error(editTarget ? '更新失败' : '创建失败', { description: (err as { message?: string }).message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? '编辑中间件' : '添加中间件'}</DialogTitle>
|
||||
<DialogDescription>配置 Redis(单机/集群)或 MySQL 连接,平台自动采集指标</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div className="space-y-2">
|
||||
<Label>类型</Label>
|
||||
<div className="flex gap-2">
|
||||
{(['redis', 'mysql'] as const).map((t) => (
|
||||
<button key={t} type="button" onClick={() => setForm((f) => ({ ...f, type: t, port: t === 'redis' ? 6379 : 3306 }))}
|
||||
className={`flex-1 py-2 rounded-lg text-sm ${form.type === t ? 'bg-blue-600 text-white' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
||||
{t === 'redis' ? 'Redis' : 'MySQL'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{form.type === 'redis' && (
|
||||
<div className="space-y-2">
|
||||
<Label>部署模式</Label>
|
||||
<div className="flex gap-2">
|
||||
{[{ value: 'standalone' as const, label: '单机' }, { value: 'cluster' as const, label: '集群' }].map((opt) => (
|
||||
<button key={opt.value} type="button" onClick={() => setForm((f) => ({ ...f, redis_mode: opt.value }))}
|
||||
className={`flex-1 py-2 rounded-lg text-sm ${form.redis_mode === opt.value ? 'bg-blue-600 text-white' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>名称 *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label>IP *</Label>
|
||||
<Input value={form.ip} onChange={(e) => setForm((f) => ({ ...f, ip: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>端口</Label>
|
||||
<Input type="number" value={form.port} onChange={(e) => setForm((f) => ({ ...f, port: Number(e.target.value) }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>密码 {editTarget && <span className="text-xs text-muted-foreground">(留空不修改)</span>}</Label>
|
||||
<Input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
|
||||
</div>
|
||||
{form.type === 'mysql' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>用户名</Label>
|
||||
<Input value={form.username} onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>数据库</Label>
|
||||
<Input value={form.database} onChange={(e) => setForm((f) => ({ ...f, database: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editTarget ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// 中间件指标进度条 — 内存/连接/命中率可视化 // Logan.Li
|
||||
import type { HealthLevel } from './helpers';
|
||||
import { healthLevelColor } from './helpers';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: number;
|
||||
display: string;
|
||||
level?: HealthLevel;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function MwGaugeBar({ label, value, display, level = 'ok', hint }: Props) {
|
||||
const pct = Math.min(100, Math.max(0, value));
|
||||
const color = healthLevelColor(level);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-[var(--dg-text-muted)]">{label}</span>
|
||||
<span className="font-mono text-[var(--dg-text-primary)]">{display}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-[var(--dg-bg-primary)] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
{hint && <p className="text-[9px] text-[var(--dg-text-muted)]">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// 中间件健康告警条 // Logan.Li
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import type { MwHealthIssue } from './helpers';
|
||||
import { worstHealthLevel, healthLevelColor } from './helpers';
|
||||
|
||||
interface Props {
|
||||
issues: MwHealthIssue[];
|
||||
}
|
||||
|
||||
export function MwHealthStrip({ issues }: Props) {
|
||||
const level = worstHealthLevel(issues);
|
||||
const color = healthLevelColor(level);
|
||||
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 dg-text-green shrink-0" />
|
||||
<span className="text-[var(--dg-text-muted)]">指标正常,无告警</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-xs"
|
||||
style={{ borderColor: `${color}40`, backgroundColor: `${color}10` }}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" style={{ color }} />
|
||||
{issues.map((issue) => (
|
||||
<span
|
||||
key={issue.message}
|
||||
className="rounded px-1.5 py-0.5 font-medium"
|
||||
style={{ color: healthLevelColor(issue.level), backgroundColor: `${healthLevelColor(issue.level)}15` }}
|
||||
>
|
||||
{issue.message}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// 中间件概览图表 — 状态 + 命中率 + QPS/连接(支持放大)// Logan.Li
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, Cell, PieChart, Pie,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { BarChart3, PieChart as PieChartIcon, Percent } from 'lucide-react';
|
||||
import type { MwTarget } from './helpers';
|
||||
import { fmtHitRate, fmtVal } from './helpers';
|
||||
import { ChartExpandShell } from '../shared/ChartExpandShell';
|
||||
import { MonitorChartTooltip, MONITOR_PIE_STROKE, MonitorPieTooltip } from '../shared/monitorChartTheme';
|
||||
|
||||
interface Props {
|
||||
targets: MwTarget[];
|
||||
}
|
||||
|
||||
function shortName(name: string) {
|
||||
return name.length > 10 ? `${name.slice(0, 10)}…` : name;
|
||||
}
|
||||
|
||||
export function MwOverviewCharts({ targets }: Props) {
|
||||
const statusData = useMemo(() => {
|
||||
const online = targets.filter((t) => t.status === 'online').length;
|
||||
const offline = targets.length - online;
|
||||
return [
|
||||
{ name: '在线', value: online, fill: 'var(--dg-green)' },
|
||||
{ name: '离线', value: offline, fill: 'var(--dg-red)' },
|
||||
].filter((d) => d.value > 0);
|
||||
}, [targets]);
|
||||
|
||||
const redisHitData = useMemo(() => targets
|
||||
.filter((t) => t.description === 'redis' && t.latest_metrics?.redis_hit_rate > 0)
|
||||
.map((t) => ({
|
||||
name: shortName(t.name), fullName: t.name,
|
||||
value: (t.latest_metrics.redis_hit_rate || 0) * 100,
|
||||
fill: (t.latest_metrics.redis_hit_rate || 0) >= 0.9 ? 'var(--dg-green)' : 'var(--dg-yellow)',
|
||||
}))
|
||||
.sort((a, b) => a.value - b.value), [targets]);
|
||||
|
||||
const redisQpsData = useMemo(() => targets
|
||||
.filter((t) => t.description === 'redis' && t.latest_metrics?.redis_qps > 0)
|
||||
.map((t) => ({ name: shortName(t.name), fullName: t.name, value: t.latest_metrics.redis_qps, fill: 'var(--dg-blue)' }))
|
||||
.sort((a, b) => b.value - a.value), [targets]);
|
||||
|
||||
const mysqlConnData = useMemo(() => targets
|
||||
.filter((t) => t.description === 'mysql' && t.latest_metrics?.mysql_connections > 0)
|
||||
.map((t) => ({ name: shortName(t.name), fullName: t.name, value: t.latest_metrics.mysql_connections, fill: 'var(--dg-blue)' }))
|
||||
.sort((a, b) => b.value - a.value), [targets]);
|
||||
|
||||
if (targets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
<OverviewChartCard title="实例状态" icon={<PieChartIcon className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={statusData.length === 0}>
|
||||
{(h) => (
|
||||
<ResponsiveContainer width="100%" height={h}>
|
||||
<PieChart>
|
||||
<Pie data={statusData} dataKey="value" nameKey="name" cx="50%" cy="50%"
|
||||
innerRadius={h * 0.22} outerRadius={h * 0.34} paddingAngle={2} isAnimationActive={false}>
|
||||
{statusData.map((e) => <Cell key={e.name} fill={e.fill} stroke={MONITOR_PIE_STROKE} strokeWidth={2} />)}
|
||||
</Pie>
|
||||
<Tooltip content={<MonitorPieTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</OverviewChartCard>
|
||||
|
||||
<OverviewChartCard title="Redis 命中率" icon={<Percent className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={redisHitData.length === 0}>
|
||||
{(h) => (
|
||||
<ResponsiveContainer width="100%" height={h}>
|
||||
<BarChart data={redisHitData} layout="vertical" margin={{ left: 0, right: 8 }}>
|
||||
<XAxis type="number" domain={[0, 100]} tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} tickFormatter={(v) => `${v}%`} />
|
||||
<YAxis type="category" dataKey="name" width={64} tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => fmtHitRate(v / 100)} nameFormatter={() => '命中率'} />} />
|
||||
<Bar dataKey="value" radius={[0, 3, 3, 0]} maxBarSize={14} isAnimationActive={false}>
|
||||
{redisHitData.map((e) => <Cell key={e.fullName} fill={e.fill} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</OverviewChartCard>
|
||||
|
||||
<OverviewChartCard title="Redis QPS" icon={<BarChart3 className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={redisQpsData.length === 0}>
|
||||
{(h) => (
|
||||
<ResponsiveContainer width="100%" height={h}>
|
||||
<BarChart data={redisQpsData} layout="vertical" margin={{ left: 0, right: 8 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} />
|
||||
<YAxis type="category" dataKey="name" width={64} tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => fmtVal(v, 1)} nameFormatter={() => 'QPS'} />} />
|
||||
<Bar dataKey="value" radius={[0, 3, 3, 0]} maxBarSize={14} isAnimationActive={false}>
|
||||
{redisQpsData.map((e) => <Cell key={e.fullName} fill={e.fill} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</OverviewChartCard>
|
||||
|
||||
<OverviewChartCard title="MySQL 连接" icon={<BarChart3 className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={mysqlConnData.length === 0}>
|
||||
{(h) => (
|
||||
<ResponsiveContainer width="100%" height={h}>
|
||||
<BarChart data={mysqlConnData} layout="vertical" margin={{ left: 0, right: 8 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} />
|
||||
<YAxis type="category" dataKey="name" width={64} tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => fmtVal(v)} nameFormatter={() => '连接'} />} />
|
||||
<Bar dataKey="value" radius={[0, 3, 3, 0]} maxBarSize={14} isAnimationActive={false}>
|
||||
{mysqlConnData.map((e) => <Cell key={e.fullName} fill={e.fill} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</OverviewChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewChartCard({
|
||||
title, icon, empty, children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
empty?: boolean;
|
||||
children: (height: number) => ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<ChartExpandShell
|
||||
title={title}
|
||||
icon={icon}
|
||||
className="bg-transparent p-0"
|
||||
compactHeight={140}
|
||||
expandedHeight={360}
|
||||
empty={empty}
|
||||
>
|
||||
{children}
|
||||
</ChartExpandShell>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// 中间件实例可观测面板 — 批量拉取 + 健康度 + 仪表盘 // Logan.Li
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Activity, LineChart, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { middlewareMonitorApi } from '@/api';
|
||||
import type { MwChartDef, MwTarget, MwTimeRange } from './helpers';
|
||||
import {
|
||||
MW_TIME_RANGE_HOURS, fmtMetricDisplay, fmtMemory, fmtHitRate, fmtVal, fmtLastSeen,
|
||||
mysqlConnPct, redisMemoryPct, redisModeLabel, groupHistoryByMetric, counterToDelta,
|
||||
evalMwHealth, gaugeTone, gaugeToneLow, REDIS_PRIMARY_KEYS, MYSQL_PRIMARY_KEYS,
|
||||
} from './helpers';
|
||||
import { MwTrendChart } from './MwTrendChart';
|
||||
import { MwGaugeBar } from './MwGaugeBar';
|
||||
import { MwHealthStrip } from './MwHealthStrip';
|
||||
import { ChartExpandShell } from '../shared/ChartExpandShell';
|
||||
|
||||
interface Props {
|
||||
target: MwTarget | null;
|
||||
chartDefs: MwChartDef[];
|
||||
accent: 'redis' | 'mysql';
|
||||
}
|
||||
|
||||
export function MwTargetChartsPanel({ target, chartDefs, accent }: Props) {
|
||||
const [timeRange, setTimeRange] = useState<MwTimeRange>('6h');
|
||||
const [historyMap, setHistoryMap] = useState<Record<string, Array<{ timestamp: number; value: number }>>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const primaryKeys = accent === 'redis' ? REDIS_PRIMARY_KEYS : MYSQL_PRIMARY_KEYS;
|
||||
const visibleDefs = useMemo(
|
||||
() => (showAll ? chartDefs : chartDefs.filter((d) => primaryKeys.includes(d.key))),
|
||||
[showAll, chartDefs, primaryKeys],
|
||||
);
|
||||
|
||||
const loadCharts = useCallback(async () => {
|
||||
if (!target) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await middlewareMonitorApi.getMetrics(target.id, {
|
||||
hours: MW_TIME_RANGE_HOURS[timeRange],
|
||||
}) as { list?: Array<{ metric_name: string; timestamp: number; value: number }> };
|
||||
setHistoryMap(groupHistoryByMetric(res?.list || []));
|
||||
} catch {
|
||||
setHistoryMap({});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [target, timeRange]);
|
||||
|
||||
useEffect(() => { loadCharts(); }, [loadCharts]);
|
||||
|
||||
// 30s 静默刷新趋势 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
const timer = setInterval(loadCharts, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [target, loadCharts]);
|
||||
|
||||
if (!target) {
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-full">
|
||||
<CardContent className="p-8 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
选择左侧实例查看趋势与健康度
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const m = target.latest_metrics || {};
|
||||
const memPct = redisMemoryPct(m);
|
||||
const connPct = mysqlConnPct(m);
|
||||
const healthIssues = evalMwHealth(target);
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-full">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-2 mb-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)] shrink-0" />
|
||||
<h3 className="text-sm font-medium truncate">可观测 · {target.name}</h3>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">{target.ip}:{target.port}</Badge>
|
||||
{accent === 'redis' && (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">{redisModeLabel(target.config_public?.redis_mode)}</Badge>
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] shrink-0">更新 {fmtLastSeen(target.last_seen_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? '核心指标' : '全部指标'}
|
||||
</Button>
|
||||
<Select value={timeRange} onValueChange={(v) => setTimeRange(v as MwTimeRange)}>
|
||||
<SelectTrigger className="h-7 w-[92px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1h">近 1 小时</SelectItem>
|
||||
<SelectItem value="6h">近 6 小时</SelectItem>
|
||||
<SelectItem value="24h">近 24 小时</SelectItem>
|
||||
<SelectItem value="7d">近 7 天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={loadCharts} disabled={loading}>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4"><MwHealthStrip issues={healthIssues} /></div>
|
||||
|
||||
{/* 仪表盘 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4">
|
||||
{accent === 'redis' && (
|
||||
<>
|
||||
<MwGaugeBar
|
||||
label="命中率"
|
||||
value={(m.redis_hit_rate || 0) * 100}
|
||||
display={fmtHitRate(m.redis_hit_rate)}
|
||||
level={m.redis_hit_rate > 0 ? gaugeToneLow((m.redis_hit_rate || 0) * 100, 90, 80) : 'ok'}
|
||||
/>
|
||||
<MwGaugeBar
|
||||
label="内存占用"
|
||||
value={memPct ?? (m.redis_used_memory > 0 ? 50 : 0)}
|
||||
display={fmtMemory(m.redis_used_memory)}
|
||||
level={memPct != null ? gaugeTone(memPct, 80, 90) : 'ok'}
|
||||
hint={memPct != null ? `已用 ${memPct.toFixed(0)}%` : '未配置 maxmemory'}
|
||||
/>
|
||||
<MwGaugeBar
|
||||
label="连接数"
|
||||
value={Math.min(100, (m.redis_connected_clients || 0) / 2)}
|
||||
display={fmtVal(m.redis_connected_clients)}
|
||||
level={(m.redis_blocked_clients || 0) > 0 ? 'warn' : 'ok'}
|
||||
hint={m.redis_cluster_known_nodes ? `集群 ${fmtVal(m.redis_cluster_known_nodes)} 节点` : undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{accent === 'mysql' && (
|
||||
<>
|
||||
<MwGaugeBar
|
||||
label="缓冲池命中率"
|
||||
value={(m.mysql_buffer_hit_rate || 0) * 100}
|
||||
display={fmtHitRate(m.mysql_buffer_hit_rate)}
|
||||
level={m.mysql_buffer_hit_rate > 0 ? gaugeToneLow((m.mysql_buffer_hit_rate || 0) * 100, 95, 90) : 'ok'}
|
||||
/>
|
||||
<MwGaugeBar
|
||||
label="连接占用"
|
||||
value={connPct ?? 0}
|
||||
display={`${fmtVal(m.mysql_connections)} / ${fmtVal(m.mysql_max_connections)}`}
|
||||
level={connPct != null ? gaugeTone(connPct, 80, 90) : 'ok'}
|
||||
/>
|
||||
<MwGaugeBar
|
||||
label="活跃线程"
|
||||
value={Math.min(100, (m.mysql_threads_running || 0) * 5)}
|
||||
display={fmtVal(m.mysql_threads_running)}
|
||||
level={(m.mysql_threads_running || 0) > 20 ? 'warn' : 'ok'}
|
||||
hint={`慢查询 ${fmtVal(m.mysql_slow_queries)}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 核心摘要 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
|
||||
{accent === 'redis' ? (
|
||||
<>
|
||||
<SummaryTile label="QPS" value={fmtVal(m.redis_qps, 1)} />
|
||||
<SummaryTile label="Key 数" value={fmtVal(m.redis_db_size)} />
|
||||
<SummaryTile label="逐出 Key" value={fmtVal(m.redis_evicted_keys)} warn={(m.redis_evicted_keys || 0) > 0} />
|
||||
<SummaryTile label="碎片率" value={m.redis_mem_fragmentation_ratio ? fmtVal(m.redis_mem_fragmentation_ratio, 2) : '--'} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SummaryTile label="QPS" value={fmtVal(m.mysql_qps, 1)} />
|
||||
<SummaryTile label="接收流量" value={fmtMemory(m.mysql_bytes_received)} />
|
||||
<SummaryTile label="发送流量" value={fmtMemory(m.mysql_bytes_sent)} />
|
||||
<SummaryTile label="运行时长" value={m.mysql_uptime ? `${Math.floor(m.mysql_uptime / 3600)}h` : '--'} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-10 flex items-center justify-center text-sm text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载趋势...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{visibleDefs.map((def) => {
|
||||
const series = def.counterDelta ? counterToDelta(historyMap[def.key] || []) : (historyMap[def.key] || []);
|
||||
return (
|
||||
<ChartExpandShell
|
||||
key={def.key}
|
||||
className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] p-3"
|
||||
title={`${def.label} · ${fmtMetricDisplay(def, m[def.key] ?? 0)}`}
|
||||
icon={<LineChart className="h-3.5 w-3.5 text-[var(--dg-text-muted)] shrink-0" />}
|
||||
compactHeight={150}
|
||||
expandedHeight={400}
|
||||
empty={series.length === 0}
|
||||
>
|
||||
{(h) => (
|
||||
<MwTrendChart
|
||||
data={series}
|
||||
color={def.color}
|
||||
unit={def.unit}
|
||||
percent={def.percent}
|
||||
formatter={def.formatter}
|
||||
height={h}
|
||||
/>
|
||||
)}
|
||||
</ChartExpandShell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryTile({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-lg p-3 text-center ${warn ? 'bg-amber-500/10' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{label}</p>
|
||||
<p className={`text-sm font-mono font-semibold mt-0.5 ${warn ? 'dg-text-yellow' : ''}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user