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

@@ -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>