refactor(frontend): api/components/pages 目录架构统一对齐菜单域 + 日志中心修复

1. 目录架构统一(API = components = 菜单域,一域一目录 + barrel)
   - log/ 提为独立顶层域(日志中心在菜单中与监控中心平级,不再塞在 monitor/)
   - task/ 拆分原 automation.ts → cron-job / script / remote-exec
   - aiops/ 合并 ai.ts + agentCli.ts → assistant + agent-cli
   - agent/ approval/ audit/ 提升为域目录
   - auth 拆分:authApi(认证自服务) + system/user.ts(userApi+ldapApi 管理端)
   - components/automation → components/task,菜单"自动化运维"→"作业任务"
   - pages/ 按域分子目录(approval/audit/monitor),删孤儿 AuditPage.tsx
   - 所有 API 符号名不变,组件经 @/api 聚合引用,运行时行为零变化

2. Loki 查询无返回修复:aiops 容器 DNS 解析外网域名失败
   (宿主机 systemd-resolved 转发链路异常),docker-compose 显式配 dns 上游

3. ES 查询框布局:工具栏单行挤 7 控件(ES 多 150px 索引框),拆两行——
   查询输入独占一行(+查询按钮),次级控件换行 flex-wrap

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
attackerandClaude committed 2026-06-28 06:06:48 +00:00
1 parent da01cbb119
commit c0ee8e4ddd
74 files changed
+229 -216

No files matched your search

+543
View File
@@ -0,0 +1,543 @@
// 站点拨测 — HTTP / HTTPS / TCP / DNS / PING 主动监控,连接真实后端 API
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Globe,
Server,
ArrowUpDown,
Plus,
Trash2,
Loader2,
RefreshCw,
CheckCircle2,
XCircle,
Clock,
Activity,
} from 'lucide-react';
import { toast } from 'sonner';
import { siteApi } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import type { JsonData } from '@/types/common';
// 拨测协议类型(与后端 model 对齐:http, https, tcp, dns, ping)
const probeTypes = [
{ icon: Globe, name: 'HTTP', color: 'text-blue-400', desc: 'HTTP URL 状态码 & 响应时间' },
{ icon: Globe, name: 'HTTPS', color: 'text-emerald-400', desc: 'HTTPS SSL 证书 & 响应时间' },
{ icon: Server, name: 'TCP', color: 'text-orange-400', desc: 'TCP 端口连通性检测' },
{ icon: Globe, name: 'DNS', color: 'text-cyan-400', desc: '域名解析延迟 & 结果验证' },
{ icon: ArrowUpDown, name: 'PING', color: 'text-purple-400', desc: 'ICMP 连通性 & 延迟' },
] as const;
const PROBE_TYPE_OPTIONS = ['HTTP', 'HTTPS', 'TCP', 'DNS', 'PING'] as const;
type ProbeType = (typeof PROBE_TYPE_OPTIONS)[number];
interface SiteStats {
total_tasks: number;
enabled_tasks: number;
up_count: number;
down_count: number;
availability: number;
}
interface SiteTask {
id: number;
name: string;
url: string;
type: string;
frequency_sec: number;
timeout_sec: number;
expected_status_code: number;
enabled: boolean;
last_status?: string;
last_response_time_ms?: number;
last_checked_at?: string;
created_at: string;
updated_at?: string;
}
interface CreateTaskForm {
name: string;
url: string;
type: ProbeType;
frequency_sec: number;
timeout_sec: number;
expected_status_code: number;
}
const emptyForm: CreateTaskForm = {
name: '',
url: '',
type: 'HTTP',
frequency_sec: 60,
timeout_sec: 10,
expected_status_code: 200,
};
function statusIcon(status?: string) {
switch (status) {
case 'up':
return <CheckCircle2 className="h-4 w-4 text-emerald-400" />;
case 'down':
return <XCircle className="h-4 w-4 text-red-400" />;
default:
return <Clock className="h-4 w-4 text-gray-400" />;
}
}
function statusBadge(status?: string) {
switch (status) {
case 'up':
return <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">正常</Badge>;
case 'down':
return <Badge className="bg-red-500/15 text-red-400 border-red-500/30">异常</Badge>;
default:
return <Badge className="bg-gray-500/15 text-gray-400 border-gray-500/30">未知</Badge>;
}
}
function fmtMs(ms?: number): string {
if (ms == null || ms === 0) return '--';
if (ms < 1000) return `${ms.toFixed(0)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function fmtTime(iso?: string): string {
if (!iso) return '--';
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function SiteMonitor() {
const [stats, setStats] = useState<SiteStats | null>(null);
const [tasks, setTasks] = useState<SiteTask[]>([]);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState<CreateTaskForm>({ ...emptyForm });
const [saving, setSaving] = useState(false);
const [selectedType, setSelectedType] = useState<string | null>(null);
const _confirm = useConfirm();
// ---------- data fetching ----------
const loadStats = useCallback(async () => {
try {
const data = await siteApi.getStats();
setStats(data as SiteStats);
} catch {
// 后端不可用时保留空状态
}
}, []);
const loadTasks = useCallback(async () => {
setRefreshing(true);
try {
const data = await siteApi.getTasks();
setTasks((data as JsonData)?.list || []);
} catch {
// 后端不可用时保留空状态
} finally {
setRefreshing(false);
setInitialLoading(false);
}
}, []);
useEffect(() => {
loadStats();
loadTasks();
}, [loadStats, loadTasks]);
// ---------- filtered tasks ----------
const filteredTasks = selectedType
? tasks.filter((t) => t.type?.toUpperCase() === selectedType.toUpperCase())
: tasks;
// ---------- actions ----------
const openCreate = () => {
setForm({ ...emptyForm });
setFormOpen(true);
};
const handleCreate = async () => {
if (!form.name.trim() || !form.url.trim()) {
toast.error('名称和 URL 不能为空');
return;
}
setSaving(true);
try {
await siteApi.createTask({
name: form.name.trim(),
url: form.url.trim(),
type: form.type.toLowerCase(),
frequency_sec: Number(form.frequency_sec) || 60,
timeout_sec: Number(form.timeout_sec) || 10,
expected_status_code: Number(form.expected_status_code) || 200,
});
toast.success('拨测任务已创建');
setFormOpen(false);
loadTasks();
loadStats();
} catch (e) {
toast.error((e as JsonData).message || '创建失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (task: SiteTask) => {
const ok = await _confirm({
title: '删除拨测任务',
description: `确定删除「${task.name}」吗?此操作不可恢复。`,
variant: 'destructive',
});
if (!ok) return;
try {
await siteApi.deleteTask(task.id);
toast.success('任务已删除');
loadTasks();
loadStats();
} catch (e) {
toast.error((e as JsonData).message || '删除失败');
}
};
const handleToggle = async (task: SiteTask) => {
try {
await siteApi.toggleTask(task.id, !task.enabled);
toast.success(task.enabled ? '已停用' : '已启用');
loadTasks();
loadStats();
} catch (e) {
toast.error((e as JsonData).message || '操作失败');
}
};
// ---------- render ----------
const availStr =
stats?.availability != null ? `${(stats.availability * 100).toFixed(1)}%` : '--';
if (initialLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-[var(--dg-text-muted)]" />
</div>
);
}
return (
<div className="space-y-6">
{/* ===== Probe Type Grid ===== */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{probeTypes.map((p) => {
const active = selectedType === p.name;
return (
<Card
key={p.name}
onClick={() => setSelectedType(active ? null : p.name)}
className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] transition-colors cursor-pointer ${
active ? 'border-[var(--dg-blue)]/60 ring-1 ring-[var(--dg-blue)]/30' : 'hover:border-[var(--dg-blue)]/30'
}`}
>
<CardContent className="p-3 text-center">
<p.icon className={`h-5 w-5 mx-auto mb-1.5 ${p.color}`} />
<p className="text-xs font-medium text-[var(--dg-text-primary)]">{p.name}</p>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{p.desc}</p>
</CardContent>
</Card>
);
})}
</div>
{/* ===== Summary Stats ===== */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">拨测任务概览</h3>
</div>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
onClick={() => { loadStats(); loadTasks(); }}
disabled={refreshing}
>
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? 'animate-spin' : ''}`} />
刷新
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{[
{ label: '总任务数', value: stats?.total_tasks ?? '--' },
{ label: '已启用', value: stats?.enabled_tasks ?? '--' },
{ label: '正常', value: stats?.up_count ?? '--', color: 'text-emerald-400' },
{ label: '异常', value: stats?.down_count ?? '--', color: 'text-red-400' },
{ label: '可用率', value: availStr, color: 'text-blue-400' },
].map((item) => (
<div key={item.label} className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 text-center">
<p className="text-xs text-[var(--dg-text-muted)]">{item.label}</p>
<p className={`text-lg font-bold ${item.color || 'text-[var(--dg-text-primary)]'}`}>
{item.value}
</p>
</div>
))}
</div>
</CardContent>
</Card>
{/* ===== Task Table ===== */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
拨测任务列表
{selectedType && (
<Badge
variant="outline"
className="ml-2 text-[10px] cursor-pointer border-[var(--dg-blue)]/40 text-[var(--dg-blue)] h-5"
onClick={() => setSelectedType(null)}
>
{selectedType} ×
</Badge>
)}
</h3>
</div>
<Button
size="sm"
className="h-8 gap-1.5"
onClick={openCreate}
>
<Plus className="h-3.5 w-3.5" />
添加任务
</Button>
</div>
{filteredTasks.length === 0 ? (
<div className="text-center py-12 text-sm text-[var(--dg-text-muted)]">
{tasks.length === 0
? '暂无拨测任务,点击「添加任务」创建第一个'
: '当前筛选条件下无任务'}
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-xs text-[var(--dg-text-muted)]">名称</TableHead>
<TableHead className="text-xs text-[var(--dg-text-muted)]">URL</TableHead>
<TableHead className="text-xs text-[var(--dg-text-muted)]">类型</TableHead>
<TableHead className="text-xs text-[var(--dg-text-muted)]">间隔</TableHead>
<TableHead className="text-xs text-[var(--dg-text-muted)]">状态</TableHead>
<TableHead className="text-xs text-[var(--dg-text-muted)]">延迟</TableHead>
<TableHead className="text-xs text-[var(--dg-text-muted)]">上次检测</TableHead>
<TableHead className="text-xs text-[var(--dg-text-muted)]">启用</TableHead>
<TableHead className="text-xs text-[var(--dg-text-muted)]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTasks.map((task) => (
<TableRow key={task.id} className="border-[var(--dg-border)]">
<TableCell className="text-sm text-[var(--dg-text-primary)] font-medium">
{task.name}
</TableCell>
<TableCell className="text-sm text-[var(--dg-text-muted)] max-w-[200px] truncate">
{task.url}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[11px] border-[var(--dg-border)]">
{task.type?.toUpperCase()}
</Badge>
</TableCell>
<TableCell className="text-sm text-[var(--dg-text-muted)]">
{task.frequency_sec}s
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
{statusIcon(task.last_status)}
<span className="text-sm text-[var(--dg-text-muted)]">
{statusBadge(task.last_status)}
</span>
</div>
</TableCell>
<TableCell className="text-sm text-[var(--dg-text-muted)]">
{fmtMs(task.last_response_time_ms)}
</TableCell>
<TableCell className="text-sm text-[var(--dg-text-muted)]">
{fmtTime(task.last_checked_at)}
</TableCell>
<TableCell>
<Switch
checked={task.enabled}
onCheckedChange={() => handleToggle(task)}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleDelete(task)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* ===== Create Task Dialog ===== */}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader>
<DialogTitle>添加拨测任务</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
配置站点探活任务,系统将按指定频率自动检测
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
{/* 名称 */}
<div className="grid gap-1.5">
<Label htmlFor="task-name" className="text-[var(--dg-text-primary)]">任务名称</Label>
<Input
id="task-name"
placeholder="例如:官网首页检测"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
{/* URL */}
<div className="grid gap-1.5">
<Label htmlFor="task-url" className="text-[var(--dg-text-primary)]">目标 URL</Label>
<Input
id="task-url"
placeholder="例如:https://example.com 或 192.168.1.1"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
/>
</div>
{/* 类型 */}
<div className="grid gap-1.5">
<Label className="text-[var(--dg-text-primary)]">协议类型</Label>
<Select
value={form.type}
onValueChange={(v) => setForm({ ...form, type: v as ProbeType })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROBE_TYPE_OPTIONS.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 频率 & 超时 */}
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="freq" className="text-[var(--dg-text-primary)]">
检测间隔 (秒)
</Label>
<Input
id="freq"
type="number"
min={10}
max={86400}
value={form.frequency_sec}
onChange={(e) =>
setForm({ ...form, frequency_sec: Number(e.target.value) || 60 })
}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="timeout" className="text-[var(--dg-text-primary)]">
超时 (秒)
</Label>
<Input
id="timeout"
type="number"
min={1}
max={300}
value={form.timeout_sec}
onChange={(e) =>
setForm({ ...form, timeout_sec: Number(e.target.value) || 10 })
}
/>
</div>
</div>
{/* 期望状态码 (仅 HTTP/HTTPS 显示) */}
{(form.type === 'HTTP' || form.type === 'HTTPS') && (
<div className="grid gap-1.5">
<Label htmlFor="status-code" className="text-[var(--dg-text-primary)]">
期望状态码
</Label>
<Input
id="status-code"
type="number"
min={100}
max={599}
value={form.expected_status_code}
onChange={(e) =>
setForm({ ...form, expected_status_code: Number(e.target.value) || 200 })
}
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFormOpen(false)} disabled={saving}>
取消
</Button>
<Button onClick={handleCreate} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
创建任务
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}