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:
LoganandClaude committed 2026-07-01 23:44:45 +08:00
1 parent 092d0e4b9e
commit 471353e3d7
190 files changed
+16640 -3484

No files matched your search

+1504 -3
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -10,7 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.1",
@@ -60,9 +62,11 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.2.2",
"react-router-dom": "^7.13.2",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
+15 -1
View File
@@ -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 执行命令(即时返回)
+3 -2
View File
@@ -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 || '' }),
+69
View File
@@ -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`),
};
+23
View File
@@ -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);
},
};
+2
View File
@@ -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';
+12 -2
View File
@@ -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 = {
+24 -15
View File
@@ -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 } : {}),
}),
};
+27
View File
@@ -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),
};
+2
View File
@@ -1,3 +1,5 @@
// 系统管理 API 聚合出口 // Logan.Li
export { userApi, ldapApi } from './user';
export { auditSettingsApi } from './audit-settings';
export type { AuditSettings, AuditCleanupStats } from './audit-settings';
+3
View File
@@ -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);
},
+1
View File
@@ -3,3 +3,4 @@
export * from './cron-job';
export * from './script';
export * from './remote-exec';
export * from './system-task';
+15
View File
@@ -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);
},
+24
View File
@@ -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>
);
}
}
+329 -124
View File
@@ -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>
);
}
+293 -54
View File
@@ -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>
);
}
}
+25 -11
View File
@@ -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"
+32 -27
View File
@@ -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')}
+1 -12
View File
@@ -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} 行
+217 -211
View File
@@ -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;
+98 -167
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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>
);
}
+111 -10
View File
@@ -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>
);
}
+21 -44
View File
@@ -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 });
};
+8 -4
View File
@@ -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 }
+9 -2
View File
@@ -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>
+4 -1
View File
@@ -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';
+3 -1
View File
@@ -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,
+87 -37
View File
@@ -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,
};
}
+12 -5
View File
@@ -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,
});
}
+15
View File
@@ -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,
};
}
+18
View File
@@ -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"
},
+18
View File
@@ -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
+104 -67
View File
@@ -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>
+193 -496
View File
@@ -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>
);
}
}
+27 -8
View File
@@ -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 />}
+305 -339
View File
@@ -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>
);
}
}
+255 -420
View File
@@ -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>
);
}
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more