chore: 统一本地服务器与远程仓库源码

- 合并 main 与 master 历史并以服务器结构为基线
- 保留迁移、数据保留、AWS 账单与部署安全能力
- 建立 SDD 规格和 CI 验证闭环
This commit is contained in:
Logan.Li committed 2026-08-05 20:03:25 +08:00
commit fd60361d93
460 files changed
+37492 -23871

No files matched your search

@@ -6,7 +6,7 @@ function ApprovalPermissionPage() {
const { t } = useTranslation();
const PERMISSION_FIELDS: FormField[] = [
{ key: 'perm_type', label: t('approval.type.permission'), type: 'select', options: [
t('approval.permType.bastion'), 'VPN', 'Log', 'DB', 'SSH', 'K8s',
'堡垒机', 'VPN', 'Log', 'DB', 'SSH', 'K8s',
], required: true },
{ key: 'account', label: 'Account', type: 'text', placeholder: 'logan.li', required: true },
{ key: 'reason', label: 'Reason', type: 'textarea', placeholder: '', required: true },
@@ -5,7 +5,7 @@ import { ApprovalCategoryPage, type FormField } from './ApprovalCategoryPage';
function ApprovalResourcePage() {
const { t } = useTranslation();
const RESOURCE_FIELDS: FormField[] = [
{ key: 'provider', label: t('approval.resourceTitle'), type: 'select', options: [t('approval.provider.aliyun'), t('approval.provider.tencent'), t('approval.provider.huawei'), 'AWS'], required: true },
{ key: 'provider', label: t('approval.resourceTitle'), type: 'select', options: ['阿里云', '腾讯云', '华为云', 'AWS'], required: true },
{ key: 'region', label: 'Region', type: 'text', placeholder: 'cn-hangzhou', required: true },
{ key: 'spec', label: 'Spec', type: 'select', options: ['2C4G', '4C8G', '8C16G', '16C32G', '32C64G'], required: true },
{ key: 'disk', label: 'Disk(GB)', type: 'number', defaultValue: 40, required: true },
@@ -1,19 +1,461 @@
// 审批流程配置 — 参考 OpsxSQL WorkflowPage 工作流模式
// Q10 组件拆分: 类型/常量/工具 → types.ts, 用户选择 → UserSelectInline.tsx,
// 编辑器弹窗 → WorkflowEditorModal.tsx, 卡片 → WorkflowCard.tsx
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, GitBranch } from 'lucide-react';
import {
Plus, Edit2, Trash2, X, ChevronDown, ChevronUp, ArrowUp, ArrowDown,
Play, Loader2, Star, CheckCircle, GitBranch, Users, Shield,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import { approvalWorkflowApi, userApi } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import type { JsonData } from '@/types/common';
import { WorkflowEditorModal } from './approval-workflow/WorkflowEditorModal';
import { WorkflowCard } from './approval-workflow/WorkflowCard';
import type { WorkflowItem, UserItem } from './approval-workflow/types';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface WorkflowStep {
id?: number;
workflow_id?: number;
step_number: number;
name: string;
step_type: string;
approver_ids: string | null;
is_required: boolean;
timeout_hours: number;
auto_approve_on_timeout: boolean;
}
interface WorkflowItem {
id: number;
name: string;
description: string | null;
category: string;
is_default: boolean;
is_active: boolean;
created_at: string;
steps: WorkflowStep[];
}
interface UserItem {
id: number;
username: string;
display_name: string | null;
role: string;
is_active?: boolean;
}
interface StepDraft {
name: string;
step_type: 'submit' | 'audit' | 'execute';
approver_ids: number[];
}
const STEP_TYPE_COLORS: Record<string, string> = {
submit: 'bg-blue-500/15 dg-text-blue border-blue-500/30',
audit: 'bg-purple-500/15 dg-text-purple border-purple-500/30',
execute: 'bg-green-500/15 dg-text-green border-green-500/30',
};
const STEP_TYPE_ICONS: Record<string, React.ElementType> = {
submit: Users,
audit: Shield,
execute: Play,
};
/** 解析审批人 ID 列表 // Logan.Li */
function parseApproverIds(ids: string | null): number[] {
if (!ids) return [];
try {
const p = JSON.parse(ids);
return Array.isArray(p) ? p : [];
} catch { return []; }
}
/** 查找 admin 用户 ID,用于新建流程默认审批/执行人 // Logan.Li */
function resolveAdminId(users: UserItem[]): number[] {
const admin = users.find((u) => u.username === 'admin');
return admin ? [admin.id] : [];
}
/** 新建流程默认步骤:提交 → admin 审批 → admin 执行 // Logan.Li */
function buildDefaultSteps(users: UserItem[], t: (key: string) => string): StepDraft[] {
const adminIds = resolveAdminId(users);
return [
{ name: t('approval.detail.submitNode'), step_type: 'submit', approver_ids: [] },
{ name: t('approval.step.auditShort'), step_type: 'audit', approver_ids: adminIds },
{ name: t('approval.step.executeShort'), step_type: 'execute', approver_ids: adminIds },
];
}
/* ------------------------------------------------------------------ */
/* UserSelectInline */
/* ------------------------------------------------------------------ */
function UserSelectInline({
users, selectedIds, label, onToggle, onToggleAll, onClear, getUserName,
}: {
users: UserItem[];
selectedIds: number[];
label: string;
onToggle: (uid: number) => void;
onToggleAll: () => void;
onClear: () => void;
getUserName: (uid: number) => string;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
const updatePos = () => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}
};
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => { if (!open) updatePos(); setOpen(!open); }}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${
selectedIds.length === 0
? 'bg-[var(--dg-bg-tertiary)] border border-red-500/40 text-[var(--dg-text-muted)]'
: 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)]'
}`}
>
<span className="truncate">
{selectedIds.length === 0 ? `${label} * ${t('approval.editor.pleaseSelect')}` : `${label} (${t('approval.editor.people', { count: selectedIds.length })})`}
</span>
<ChevronDown className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{selectedIds.length > 1 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{selectedIds.map((uid) => (
<span key={uid} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-blue-500/15 dg-text-blue">
{getUserName(uid)}
<button onClick={() => onToggle(uid)} className="hover:dg-text-red"><X className="w-3 h-3" /></button>
</span>
))}
</div>
)}
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="fixed z-50 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl max-h-52 overflow-y-auto"
style={{ top: pos.top, left: pos.left, width: pos.width }}>
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--dg-border)]">
<button onClick={onToggleAll} className="text-[11px] dg-text-blue hover:text-blue-300">
{selectedIds.length === users.length && users.length > 0 ? t('approval.editor.deselectAll') : t('approval.editor.selectAll')}
</button>
{selectedIds.length > 0 && (
<button onClick={onClear} className="text-[11px] dg-text-red hover:dg-text-red">{t('common.clear')}</button>
)}
</div>
{users.map((u) => {
const checked = selectedIds.includes(u.id);
return (
<div key={u.id} onClick={() => onToggle(u.id)}
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-[var(--dg-bg-hover)] transition-colors">
<div className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
checked ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)]'
}`}>
{checked && <CheckCircle className="w-3 h-3 text-white" />}
</div>
<span className={`text-sm flex-1 ${checked ? 'text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-secondary)]'}`}>
{u.display_name || u.username}
</span>
<span className="text-[10px] text-[var(--dg-text-muted)] px-1.5 py-0.5 rounded bg-[var(--dg-bg-tertiary)]">
{u.role}
</span>
</div>
);
})}
</div>
</>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* WorkflowEditorModal */
/* ------------------------------------------------------------------ */
interface EditorProps {
workflow?: WorkflowItem | null;
users: UserItem[];
onClose: () => void;
onSaved: () => void;
}
function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps) {
const { t } = useTranslation();
const isEdit = !!workflow;
const [name, setName] = useState(workflow?.name || '');
const [description, setDescription] = useState(workflow?.description || '');
const [category, setCategory] = useState(workflow?.category || 'resource');
const [steps, setSteps] = useState<StepDraft[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const stepTypeLabels: Record<string, string> = {
submit: t('approval.step.submit'),
audit: t('approval.step.audit'),
execute: t('approval.step.execute'),
};
const stepTypes = [
{ value: 'submit' as const, label: t('approval.step.submit') },
{ value: 'audit' as const, label: t('approval.step.audit') },
{ value: 'execute' as const, label: t('approval.step.execute') },
];
useEffect(() => {
if (workflow?.steps?.length) {
setSteps(
workflow.steps.map((s) => ({
name: s.name,
step_type: (s.step_type === 'serial' || s.step_type === 'parallel')
? ('audit' as const)
: (s.step_type as 'submit' | 'audit' | 'execute'),
approver_ids: parseApproverIds(s.approver_ids),
}))
);
} else {
setSteps(buildDefaultSteps(users, t));
}
}, [workflow, users, t]);
const addStep = (type: 'submit' | 'audit' | 'execute') => {
const labels: Record<string, string> = {
submit: t('approval.step.submitShort'),
audit: t('approval.step.auditShort'),
execute: t('approval.step.executeShort'),
};
const count = steps.filter((s) => s.step_type === type).length;
const suffix = count > 0 ? ` ${count + 1}` : '';
setSteps([...steps, { name: `${labels[type]}${suffix}`, step_type: type, approver_ids: [] }]);
};
const removeStep = (idx: number) => setSteps(steps.filter((_, i) => i !== idx));
const moveStep = (idx: number, dir: -1 | 1) => {
const target = idx + dir;
if (target < 0 || target >= steps.length) return;
const next = [...steps];
[next[idx], next[target]] = [next[target], next[idx]];
setSteps(next);
};
const updateStep = (idx: number, patch: Partial<StepDraft>) => {
setSteps(steps.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
};
const toggleUser = (idx: number, uid: number) => {
const step = steps[idx];
const has = step.approver_ids.includes(uid);
updateStep(idx, { approver_ids: has ? step.approver_ids.filter((id) => id !== uid) : [...step.approver_ids, uid] });
};
const getEligibleUsers = (stepType: string) => {
const active = users.filter(u => u.is_active !== false);
if (stepType === 'audit') return active;
if (stepType === 'execute') return active.filter(u =>
['super_admin', 'k8s_admin', 'release_admin', 'resource_admin'].includes(u.role)
);
return active;
};
const toggleAllUsers = (idx: number) => {
const step = steps[idx];
const eligible = getEligibleUsers(step.step_type);
const allSelected = step.approver_ids.length === eligible.length && eligible.length > 0;
updateStep(idx, { approver_ids: allSelected ? [] : eligible.map((u) => u.id) });
};
const handleSubmit = async () => {
if (!name.trim()) { setError(t('approval.editor.nameRequired')); return; }
if (steps.length === 0) { setError(t('approval.editor.addStepRequired')); return; }
for (let i = 0; i < steps.length; i++) {
if (steps[i].step_type !== 'submit' && steps[i].approver_ids.length === 0) {
setError(t('approval.editor.stepNeedsApprover', { name: steps[i].name }));
return;
}
}
setSubmitting(true);
setError('');
try {
const payload = {
name: name.trim(),
description: description.trim() || null,
category,
steps: steps.map((s, i) => ({
step_number: i + 1,
name: s.name,
step_type: s.step_type,
approver_ids: s.approver_ids.length > 0 ? JSON.stringify(s.approver_ids) : null,
is_required: true,
timeout_hours: 24,
auto_approve_on_timeout: false,
})),
};
if (isEdit && workflow) {
await approvalWorkflowApi.update(workflow.id, payload);
} else {
await approvalWorkflowApi.create(payload);
}
onSaved();
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || t('approval.editor.saveFailed');
setError(msg);
} finally {
setSubmitting(false);
}
};
const getUserName = (uid: number) => {
const u = users.find((u) => u.id === uid);
return u ? (u.display_name || u.username) : `#${uid}`;
};
const inputCls = 'w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors text-[var(--dg-text-primary)]';
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="dg-card sm:max-w-2xl max-h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
<DialogHeader className="px-5 py-4 border-b border-[var(--dg-border)] shrink-0">
<DialogTitle className="text-base font-semibold text-[var(--dg-text-primary)]">
{isEdit ? t('approval.editor.editTitle') : t('approval.editor.newTitle')}
</DialogTitle>
<DialogDescription className="sr-only">
{isEdit ? t('approval.editor.editTitle') : t('approval.editor.newTitle')}
</DialogDescription>
</DialogHeader>
<div className="px-5 pb-5 space-y-4 pt-4 overflow-y-auto flex-1 min-h-0">
{error && <div className="px-3 py-2 text-sm dg-text-red bg-red-500/10 rounded-lg">{error}</div>}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">{t('approval.editor.workflowName')} *</label>
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} placeholder={t('approval.editor.enterName')} />
</div>
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">{t('approval.editor.workflowCategory')}</label>
<select className={inputCls} value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="resource">{t('approval.category.resource')}</option>
<option value="app">{t('approval.category.app')}</option>
<option value="permission">{t('approval.category.permission')}</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">{t('approval.editor.workflowDesc')}</label>
<input className={inputCls} value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('common.optional')} />
</div>
{/* Steps */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-[var(--dg-text-secondary)]">{t('approval.editor.workflowSteps')}</label>
<div className="flex items-center gap-1">
{stepTypes.map((tp) => (
<button key={tp.value} onClick={() => addStep(tp.value)}
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium bg-blue-500/10 dg-text-blue hover:bg-blue-500/20 rounded transition-colors">
<Plus className="w-3 h-3" />{tp.label}
</button>
))}
</div>
</div>
<div className="space-y-2">
{steps.map((step, idx) => {
const colorCls = STEP_TYPE_COLORS[step.step_type];
const Icon = STEP_TYPE_ICONS[step.step_type] || Shield;
return (
<div key={idx} className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-center gap-2 mb-2">
<div className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${colorCls.split(' ')[0]}`}>
<Icon className={`w-3.5 h-3.5 ${colorCls.split(' ')[1]}`} />
</div>
<input
className="flex-1 px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-sm focus:border-blue-400 focus:outline-none text-[var(--dg-text-primary)]"
value={step.name} onChange={(e) => updateStep(idx, { name: e.target.value })}
placeholder={t('approval.step.name')}
/>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colorCls}`}>
{stepTypeLabels[step.step_type]}
</span>
<div className="flex items-center gap-0.5 ml-1">
<button onClick={() => moveStep(idx, -1)} disabled={idx === 0}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] disabled:opacity-20 transition-colors">
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button onClick={() => moveStep(idx, 1)} disabled={idx === steps.length - 1}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] disabled:opacity-20 transition-colors">
<ArrowDown className="w-3.5 h-3.5" />
</button>
<button onClick={() => removeStep(idx)}
className="p-1 rounded hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:dg-text-red transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{step.step_type !== 'submit' && (
<div className="pl-9 mt-2">
<UserSelectInline
users={getEligibleUsers(step.step_type)}
selectedIds={step.approver_ids}
label={step.step_type === 'audit' ? t('approval.editor.auditUser') : t('approval.editor.executeUser')}
onToggle={(uid) => toggleUser(idx, uid)}
onToggleAll={() => toggleAllUsers(idx)}
onClear={() => updateStep(idx, { approver_ids: [] })}
getUserName={getUserName}
/>
</div>
)}
</div>
);
})}
{steps.length === 0 && (
<div className="text-center py-4 text-sm text-[var(--dg-text-muted)]">
{t('approval.step.addHint')}
</div>
)}
</div>
</div>
</div>
<DialogFooter className="px-5 py-4 border-t border-[var(--dg-border)] shrink-0">
<Button variant="outline" className="dg-btn-secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button className="dg-btn-primary" onClick={handleSubmit} disabled={submitting}>
{submitting && <Loader2 className="w-4 h-4 animate-spin mr-1" />}
{isEdit ? t('common.save') : t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/* ------------------------------------------------------------------ */
/* Main Page */
/* ------------------------------------------------------------------ */
export default function ApprovalWorkflowPage() {
const { t } = useTranslation();
@@ -25,6 +467,18 @@ export default function ApprovalWorkflowPage() {
const [expandedId, setExpandedId] = useState<number | null>(null);
const [editorWorkflow, setEditorWorkflow] = useState<WorkflowItem | null | undefined>(undefined);
const categoryLabels: Record<string, string> = {
resource: t('approval.category.resource'),
app: t('approval.category.app'),
permission: t('approval.category.permission'),
};
const stepTypeLabels: Record<string, string> = {
submit: t('approval.step.submit'),
audit: t('approval.step.audit'),
execute: t('approval.step.execute'),
};
const fetchWorkflows = useCallback(async () => {
try {
setLoading(true);
@@ -127,7 +581,7 @@ export default function ApprovalWorkflowPage() {
/>
) : items.length === 0 ? (
<PageEmpty
icon={<GitBranch className="h-12 w-12" />}
icon={<GitBranch className="w-12 h-12" />}
title={t('approval.noWorkflows')}
action={(
<Button variant="outline" className="dg-btn-secondary" onClick={() => setEditorWorkflow(null)}>
@@ -137,19 +591,103 @@ export default function ApprovalWorkflowPage() {
/>
) : (
<div className="grid gap-4">
{items.map((wf) => (
<WorkflowCard
key={wf.id}
wf={wf}
isExpanded={expandedId === wf.id}
getUserName={getUserName}
onToggleExpand={() => setExpandedId(expandedId === wf.id ? null : wf.id)}
onSetDefault={() => setDefault(wf.id)}
onToggleActive={() => toggleActive(wf)}
onEdit={() => setEditorWorkflow(wf)}
onDelete={() => handleDelete(wf.id, wf.name)}
/>
))}
{items.map((wf) => {
const isExpanded = expandedId === wf.id;
return (
<Card key={wf.id} className={`dg-card overflow-hidden ${!wf.is_active ? 'opacity-60' : ''}`}>
<CardContent className="p-4 sm:p-5">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{wf.name}</h3>
{wf.is_default && <span className="text-[10px] px-1.5 py-0.5 rounded bg-yellow-400/15 dg-text-yellow flex items-center gap-1"><Star className="w-3 h-3" />{t('approval.status.default')}</span>}
<span className={`text-[10px] px-1.5 py-0.5 rounded ${wf.is_active ? 'bg-green-500/15 dg-text-green' : 'bg-red-500/15 dg-text-red'}`}>
{wf.is_active ? t('approval.status.enabled') : t('approval.status.disabled')}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/15 dg-text-blue">
{categoryLabels[wf.category] || wf.category}
</span>
</div>
{wf.description && <p className="text-sm text-[var(--dg-text-secondary)] mb-1">{wf.description}</p>}
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
{wf.steps?.map((s, i) => (
<span key={i} className="flex items-center gap-1">
<span className={`text-[10px] px-1.5 py-0.5 rounded ${STEP_TYPE_COLORS[s.step_type] || 'dg-badge-muted'}`}>
{stepTypeLabels[s.step_type] || s.step_type}
</span>
{i < (wf.steps?.length || 0) - 1 && (
<span className="text-[var(--dg-text-muted)] text-[10px]">→</span>
)}
</span>
))}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0 sm:ml-4 self-end sm:self-start">
<button onClick={() => setExpandedId(isExpanded ? null : wf.id)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] transition-colors">
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{!wf.is_default && (
<button onClick={() => setDefault(wf.id)}
className="p-1.5 rounded hover:bg-yellow-500/10 text-[var(--dg-text-muted)] hover:dg-text-yellow transition-colors" title={t('approval.msg.setDefaultSuccess')}>
<Star className="w-4 h-4" />
</button>
)}
<button onClick={() => toggleActive(wf)}
className={`p-1.5 rounded transition-colors ${wf.is_active ? 'hover:bg-red-500/10 dg-text-green hover:dg-text-red' : 'hover:bg-green-500/10 dg-text-red hover:dg-text-green'}`}
title={wf.is_active ? t('approval.status.disabled') : t('approval.status.enabled')}>
<Play className={`w-4 h-4 ${!wf.is_active ? 'rotate-180' : ''}`} />
</button>
<button onClick={() => setEditorWorkflow(wf)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:dg-text-blue transition-colors" title={t('common.edit')}>
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(wf.id, wf.name)}
className="p-1.5 rounded hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:dg-text-red transition-colors" title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-[var(--dg-border)] space-y-3">
{wf.steps?.map((s, i) => {
const ids = parseApproverIds(s.approver_ids);
return (
<div key={i} className="flex items-start gap-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg">
<div className="w-6 h-6 rounded-full bg-[var(--dg-bg-tertiary)] flex items-center justify-center text-xs font-medium text-[var(--dg-text-secondary)] flex-shrink-0 mt-0.5">
{s.step_number}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{s.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${STEP_TYPE_COLORS[s.step_type] || 'dg-badge-muted'}`}>
{stepTypeLabels[s.step_type] || s.step_type}
</span>
</div>
{ids.length > 0 && (
<div className="flex flex-wrap gap-1">
{ids.map((uid) => (
<span key={uid} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-blue-500/15 dg-text-blue">
{getUserName(uid)}
</span>
))}
</div>
)}
{ids.length === 0 && s.step_type !== 'submit' && (
<span className="text-[11px] dg-text-red">{t('approval.detail.noApproverLinked')}</span>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
</PageShell>
+255 -4
View File
@@ -1,6 +1,5 @@
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
// v2: 虚拟滚动渲染,支持万级日志流畅查看
// Q32: 拆分 types/LogDetailDialog/StatisticsDialog 到 audit-operations/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
@@ -13,6 +12,7 @@ import {
RefreshCw,
Download,
Eye,
X,
ClipboardList,
} from 'lucide-react';
import { PageShell } from '@/components/layout/PageShell';
@@ -21,12 +21,49 @@ import { PageEmpty } from '@/components/layout/PageEmpty';
import { StatStrip } from '@/components/ui/stat-strip';
import { auditApi } from '@/api';
import { useAuditLogs } from '@/hooks/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import type { OperationLog, OptionItem } from '@/types';
import { normalizeLog } from './audit-operations/types';
import { LogDetailDialog } from './audit-operations/LogDetailDialog';
// Normalize backend log to frontend type
function normalizeLog(raw: Record<string, unknown>): OperationLog {
const status = raw.status as number | undefined;
const result: OperationLog['result'] =
status === 0 ? 'failure' : status === 1 ? 'success' : raw.result === 'failure' ? 'failure' : 'success';
const request = String(raw.request ?? '');
const response = String(raw.response ?? '');
const error = String(raw.error ?? '');
const message = error || response || request || String(raw.message ?? raw.detail ?? '');
return {
id: String(raw.id ?? ''),
userId: String(raw.user_id ?? raw.userId ?? ''),
username: String(raw.username ?? ''),
module: String(raw.module ?? raw.resource ?? ''),
action: String(raw.action ?? ''),
resource: String(raw.resource ?? ''),
ip: String(raw.ip ?? ''),
userAgent: String(raw.user_agent ?? raw.userAgent ?? ''),
result,
message,
request,
response,
error,
createdAt: String(raw.createdAt ?? raw.created_at ?? ''),
};
}
interface AuditStatistics {
total_logs?: number;
success_count?: number;
failure_count?: number;
today_count?: number;
top_modules?: { module: string; count: number }[];
top_actions?: { action: string; count: number }[];
top_users?: { username: string; count: number }[];
}
export function AuditOperationsTab() {
const { t } = useTranslation();
const _confirm = useConfirm();
// Filters
const [keyword, setKeyword] = useState('');
const [moduleFilter, setModuleFilter] = useState('');
@@ -50,6 +87,11 @@ export function AuditOperationsTab() {
// Detail modal
const [detailLog, setDetailLog] = useState<OperationLog | null>(null);
// Statistics modal
const [statsOpen, setStatsOpen] = useState(false);
const [statistics, setStatistics] = useState<AuditStatistics | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const fetchOptions = useCallback(async () => {
try {
const [modRes, actRes] = await Promise.all([auditApi.getModules(), auditApi.getActions()]);
@@ -100,6 +142,19 @@ export function AuditOperationsTab() {
}
};
const _handleShowStats = async () => {
setStatsOpen(true);
setStatsLoading(true);
try {
const data = (await auditApi.getStatistics()) as AuditStatistics;
setStatistics(data);
} catch {
setStatistics(null);
} finally {
setStatsLoading(false);
}
};
const viewDetail = (log: OperationLog) => {
setDetailLog(log);
};
@@ -108,7 +163,6 @@ export function AuditOperationsTab() {
const scrollRef = useRef<HTMLDivElement>(null);
const GRID_COLS = 'minmax(140px,1fr) minmax(80px,1fr) minmax(100px,1fr) minmax(100px,1fr) minmax(140px,1fr) minmax(100px,1fr) minmax(80px,0.8fr) 48px';
// eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual returns non-memoizable functions (same as useVirtualRows/PodsTab)
const virtualizer = useVirtualizer({
count: filteredLogs.length,
getScrollElement: () => scrollRef.current,
@@ -271,6 +325,203 @@ export function AuditOperationsTab() {
{/* Detail Modal */}
<LogDetailDialog log={detailLog} onClose={() => setDetailLog(null)} />
{/* Statistics Modal */}
<StatisticsDialog
open={statsOpen}
onClose={() => setStatsOpen(false)}
loading={statsLoading}
stats={statistics}
/>
</PageShell>
);
}
// =========================================================================
// Log Detail Dialog
// =========================================================================
function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose: () => void }) {
const { t } = useTranslation();
if (!log) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative dg-card rounded-lg p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[85vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{t('audit.operations.detailTitle')}</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 dg-text-secondary hover:text-[var(--dg-text-primary)]"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-3 text-sm">
<DetailRow label="ID" value={log.id} />
<DetailRow label={t('audit.operations.col.time')} value={log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'} />
<DetailRow label={t('audit.operations.col.user')} value={log.username || '--'} />
<DetailRow label={t('audit.operations.detailCol.userId')} value={log.userId || '--'} />
<DetailRow label={t('audit.operations.col.module')} value={log.module || '--'} />
<DetailRow label={t('audit.operations.col.actionType')} value={log.action || '--'} />
<DetailRow label={t('audit.operations.col.resource')} value={log.resource || '--'} />
<DetailRow label={t('audit.operations.detailCol.ipAddress')} value={log.ip || '--'} />
<DetailRow label={t('audit.operations.detailCol.userAgent')} value={log.userAgent || '--'} />
<DetailRow
label={t('audit.operations.col.result')}
value={
<Badge
variant="outline"
className={
log.result === 'success'
? 'border-emerald-500/30 dg-text-green bg-emerald-500/10'
: 'border-red-500/30 dg-text-red bg-red-500/10'
}
>
{log.result === 'success' ? t('common.success') : t('common.failed')}
</Badge>
}
/>
{log.request && <DetailRow label={t('audit.operations.detailCol.request', { defaultValue: '请求参数' })} value={log.request} mono />}
{log.response && <DetailRow label={t('audit.operations.detailCol.response', { defaultValue: '执行输出' })} value={log.response} mono />}
{log.error && <DetailRow label={t('audit.operations.detailCol.error', { defaultValue: '错误信息' })} value={log.error} mono />}
{!log.request && !log.response && log.message && (
<DetailRow label={t('audit.operations.detailCol.message')} value={log.message} mono />
)}
</div>
<div className="mt-5 flex justify-end">
<Button
variant="outline"
className="dg-btn-secondary"
onClick={onClose}
>
{t('common.close')}
</Button>
</div>
</div>
</div>
);
}
function DetailRow({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div className="flex items-start gap-4">
<span className="dg-text-muted w-20 flex-shrink-0">{label}</span>
<span className={mono ? 'text-[var(--dg-text-secondary)] font-mono text-xs break-all' : 'text-[var(--dg-text-primary)] break-all'}>
{value}
</span>
</div>
);
}
// =========================================================================
// Statistics Dialog
// =========================================================================
function StatisticsDialog({
open,
onClose,
loading,
stats,
}: {
open: boolean;
onClose: () => void;
loading: boolean;
stats: AuditStatistics | null;
}) {
const { t } = useTranslation();
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative dg-card rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{t('audit.operations.statsTitle')}</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 dg-text-secondary hover:text-[var(--dg-text-primary)]"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-10">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : stats ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<StatCard label={t('audit.operations.stats.totalLogs')} value={String(stats.total_logs ?? '--')} color="blue" />
<StatCard label={t('audit.operations.stats.todayLogs')} value={String(stats.today_count ?? '--')} color="emerald" />
<StatCard label={t('audit.operations.stats.successCount')} value={String(stats.success_count ?? '--')} color="emerald" />
<StatCard label={t('audit.operations.stats.failureCount')} value={String(stats.failure_count ?? '--')} color="red" />
</div>
{stats.top_modules && stats.top_modules.length > 0 && (
<div>
<h4 className="text-xs font-semibold dg-text-secondary mb-2">{t('audit.operations.topModules')}</h4>
<div className="space-y-1">
{stats.top_modules.slice(0, 5).map((m, i) => (
<div key={i} className="flex justify-between text-xs">
<span className="text-[var(--dg-text-secondary)]">{m.module}</span>
<span className="dg-text-muted">{m.count}</span>
</div>
))}
</div>
</div>
)}
{stats.top_users && stats.top_users.length > 0 && (
<div>
<h4 className="text-xs font-semibold dg-text-secondary mb-2">{t('audit.operations.topUsers')}</h4>
<div className="space-y-1">
{stats.top_users.slice(0, 5).map((u, i) => (
<div key={i} className="flex justify-between text-xs">
<span className="text-[var(--dg-text-secondary)]">{u.username}</span>
<span className="dg-text-muted">{u.count}</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<p className="dg-text-muted text-center py-10 text-sm">{t('audit.operations.noStats')}</p>
)}
<div className="mt-5 flex justify-end">
<Button
variant="outline"
className="dg-btn-secondary"
onClick={onClose}
>
{t('common.close')}
</Button>
</div>
</div>
</div>
);
}
function StatCard({ label, value, color }: { label: string; value: string; color: 'blue' | 'emerald' | 'red' }) {
const colorMap = {
blue: 'border-blue-500/20 bg-blue-500/5 dg-text-blue',
emerald: 'border-emerald-500/20 bg-emerald-500/5 dg-text-green',
red: 'border-red-500/20 bg-red-500/5 dg-text-red',
};
return (
<div className={`rounded-lg border p-3 ${colorMap[color]}`}>
<div className="text-xs dg-text-muted">{label}</div>
<div className="text-xl font-bold mt-0.5">{value}</div>
</div>
);
}
@@ -138,7 +138,7 @@ function RdpActiveSessionRow({
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-red" onClick={() => onKill(session.id, session.username)} title={t('audit.terminal.killTitle')}>
<XOctagon className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-blue" onClick={() => onMonitor(session.id)} title={t('audit.rdpRealtimeAudit')}>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-blue" onClick={() => onMonitor(session.id)} title="RDP 实时审计">
<Eye className="h-4 w-4" />
</Button>
</div>
@@ -175,7 +175,6 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
// 用户未手动关闭时,回到在线会话自动开启刷新
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!userToggledOff) setAutoRefresh(true);
}, [userToggledOff]);
@@ -319,13 +318,13 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
<Card className="dg-card">
<CardContent className="p-0">
<div className="px-4 py-2 border-b border-[var(--dg-border)] text-xs font-semibold dg-text-secondary">{t('common.rdpActiveSessions')}</div>
<div className="px-4 py-2 border-b border-[var(--dg-border)] text-xs font-semibold dg-text-secondary">RDP 在线会话</div>
{rdpQuery.isLoading ? (
<div className="flex items-center justify-center py-10"><div className="w-7 h-7 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" /></div>
) : rdpSessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 dg-text-muted">
<Activity className="h-7 w-7 mb-2 opacity-40" />
<p className="text-sm">{t("common.noRdpActive")}</p>
<p className="text-sm">暂无 RDP 在线会话</p>
</div>
) : (
<Table>
@@ -248,7 +248,7 @@ export function HistorySessionsPanel() {
<Card className="dg-card">
<CardContent className="p-0">
<div className="px-4 py-2 border-b border-[var(--dg-border)] text-xs font-semibold dg-text-secondary">{t('common.rdpHistorySessions')}</div>
<div className="px-4 py-2 border-b border-[var(--dg-border)] text-xs font-semibold dg-text-secondary">RDP 历史会话</div>
{rdpQuery.isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-7 h-7 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
@@ -256,7 +256,7 @@ export function HistorySessionsPanel() {
) : rdpSessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 dg-text-muted">
<Monitor className="h-7 w-7 mb-2 opacity-40" />
<p className="text-sm">{t("common.noRdpHistory")}</p>
<p className="text-sm">暂无 RDP 历史会话</p>
</div>
) : (
<>
@@ -290,7 +290,7 @@ export function HistorySessionsPanel() {
<TableCell>{getStatusBadge(s.status)}</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-0.5">
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-blue" onClick={() => openRdpReplay(s.id)} disabled={!s.record_size} title={t('audit.rdpHistoryReplay')}>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-blue" onClick={() => openRdpReplay(s.id)} disabled={!s.record_size} title="RDP 历史回放">
<Play className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-green" onClick={() => rdpSessionApi.download(s.id)} disabled={!s.record_size} title={t('audit.terminal.downloadRecord')}>
@@ -303,7 +303,7 @@ export function HistorySessionsPanel() {
</TableBody>
</Table>
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
<span className="text-xs dg-text-muted">{t('common.rdpSessionCount', { count: rdpTotal })}</span>
<span className="text-xs dg-text-muted">RDP 会话共 {rdpTotal} 条</span>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={rdpPage <= 1} onClick={() => setRdpPage((p) => p - 1)}>
<ChevronLeft className="h-4 w-4" />
+384 -63
View File
@@ -6,41 +6,34 @@ import { useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/api';
import { useHostList, useHostHistory, usePortPolicy } from '@/hooks/api/useHostMonitor';
import { queryKeys } from '@/hooks/api/queryKeys';
import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PageShell } from '@/components/layout/PageShell';
import { PageLoading } from '@/components/layout/PageLoading';
import { Monitor, HardDrive, Network, Server, Info, List, HeartPulse } from 'lucide-react';
import type { ZoomableChartHandle } from './host-monitor/ZoomableAreaChart';
import { DEFAULT_TIME_RANGE, PCT_REF_LINES, type AggType, type HostRecord, type HostMonitorTimeRange } from './host-monitor/types';
import {
Cpu, HardDrive, Network, Activity, Server,
Info, Monitor, Disc, Clock, List, HeartPulse, RefreshCw, XCircle,
} from 'lucide-react';
import { ZoomableAreaChart, type ZoomableChartHandle } from './host-monitor/ZoomableAreaChart';
import { ChartToolbar } from './host-monitor/ChartToolbar';
import { MetricIconBox } from './host-monitor/MetricIconBox';
import { DEFAULT_TIME_RANGE, type AggType, type HostRecord, type HostMonitorTimeRange } from './host-monitor/types';
import { HostTimeRangePicker } from './host-monitor/HostTimeRangePicker';
import { getRangeSpanMinutes } from './host-monitor/timeRange';
import { parseJSON, buildMetricsFromHistory, getCurrentVal, filterHistoryByRange, getActualDataSpan } from './host-monitor/utils';
import { parseJSON, formatBPS, buildMetricsFromHistory, getCurrentVal, filterHistoryByRange, getActualDataSpan } from './host-monitor/utils';
import { MetricChartCard } from './host-monitor/MetricChartCard';
import { DualLineChart } from './host-monitor/DualLineChart';
import { HealthProbeSection } from './host-monitor/HealthProbeSection';
import { PortPolicyDialog } from './host-monitor/PortPolicyDialog';
import { SystemInfoSection } from './host-monitor/SystemInfoSection';
import { HostCoreMetricsStrip } from './host-monitor/HostCoreMetricsStrip';
import { BasicTab } from './host-monitor/tabs/BasicTab';
import { DiskTab } from './host-monitor/tabs/DiskTab';
import { NetworkTab } from './host-monitor/tabs/NetworkTab';
import { PortMonitorTab } from './host-monitor/tabs/PortMonitorTab';
import { ProcessTab } from './host-monitor/tabs/ProcessTab';
import type { JsonData } from '@/types/common';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
const TABS = [
{ id: 'basic', label: i18n.t('monitor.lbl.basicMonitor'), icon: <Monitor className="w-3.5 h-3.5" /> },
{ id: 'disk', label: i18n.t('monitor.lbl.diskMonitor'), icon: <HardDrive className="w-3.5 h-3.5" /> },
{ id: 'network', label: i18n.t('monitor.lbl.networkMonitor'), icon: <Network className="w-3.5 h-3.5" /> },
{ id: 'ports', label: i18n.t('agent.portMonitor'), icon: <Server className="w-3.5 h-3.5" /> },
{ id: 'process', label: i18n.t('monitor.hostProcess'), icon: <List className="w-3.5 h-3.5" /> },
{ id: 'system', label: i18n.t('system.systemInfo'), icon: <Info className="w-3.5 h-3.5" /> },
{ id: 'health_probes', label: i18n.t('kubernetes.container.probes'), icon: <HeartPulse className="w-3.5 h-3.5" /> },
];
export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [searchParams] = useSearchParams();
const urlHost = searchParams.get('host') || '';
@@ -89,10 +82,19 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
} finally { setPortPolicySaving(false); }
};
const history = useMemo(() => filterHistoryByRange(rawHistory, timeRange), [rawHistory, timeRange]);
const history = useMemo(
() => filterHistoryByRange(rawHistory, timeRange),
[rawHistory, timeRange],
);
const spanMinutes = getRangeSpanMinutes(timeRange);
const timeConfig = useMemo(() => ({ buckets: timeRange.buckets, spanMinutes }), [timeRange.buckets, spanMinutes]);
const actualDataSpan = useMemo(() => getActualDataSpan(history, timeRange), [history, timeRange]);
const timeConfig = useMemo(
() => ({ buckets: timeRange.buckets, spanMinutes }),
[timeRange.buckets, spanMinutes],
);
const actualDataSpan = useMemo(
() => getActualDataSpan(history, timeRange),
[history, timeRange],
);
const loading = hostsLoading || historyLoading;
const serverInfo = (hosts.find((h) => h.hostname === selectedHost) || null) as HostRecord | null;
const latest = useMemo((): HostRecord => {
@@ -104,10 +106,10 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
const metrics = useMemo(() => buildMetricsFromHistory(history, timeConfig), [history, timeConfig]);
const netInfo = parseJSON(latest?.net_info);
const portInfo: JsonData[] = Array.isArray(latest?.port_info) ? latest.port_info as JsonData[] : (Array.isArray(parseJSON(latest?.port_info)) ? parseJSON(latest?.port_info) as unknown as JsonData[] : []);
const portInfo: JsonData = Array.isArray(latest?.port_info) ? latest.port_info : (Array.isArray(parseJSON(latest?.port_info)) ? parseJSON(latest?.port_info) : []);
const diskIOInfo = parseJSON(latest?.disk_io_info);
const swapInfo = parseJSON(latest?.swap_info);
const processTop: JsonData[] = Array.isArray(parseJSON(latest?.process_top_info)) ? parseJSON(latest?.process_top_info) as unknown as JsonData[] : [];
const processTop: JsonData = Array.isArray(parseJSON(latest?.process_top_info)) ? parseJSON(latest?.process_top_info) : [];
const fdInfo = parseJSON(latest?.fd_info);
const csInfo = parseJSON(latest?.cs_info);
const heartbeatInfo = parseJSON(latest?.heartbeat_info);
@@ -115,12 +117,17 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
const currentVal = (key: string, fallback = '--') =>
getCurrentVal(latest, netInfo, diskIOInfo, swapInfo, fdInfo, key, fallback);
const pctRefLines = [
{ value: 80, label: '80%', color: 'var(--dg-yellow)' },
{ value: 90, label: '90%', color: 'var(--dg-red)' },
];
const toolbar = (
<div className="flex items-center justify-between flex-wrap gap-2 w-full">
<div className="flex items-center gap-2">
<Select value={selectedHost} onValueChange={setSelectedHost}>
<SelectTrigger className="w-52 h-8 text-xs dg-input">
<SelectValue placeholder={t('monitor.lbl.selectHost')} />
<SelectValue placeholder={i18n.t('monitor.hostmonitortsx.sb6637d99')} />
</SelectTrigger>
<SelectContent className="dg-input">
{hosts.map((h: JsonData) => (
@@ -130,23 +137,25 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
</Select>
{serverInfo && (
<span className="text-xs dg-text-muted">
{String(serverInfo.os || '')}{serverInfo.cpu_cores ? ` | ${serverInfo.cpu_cores as number | string} ${t('monitor.hostMonitorPanel.cores')}` : ''}{serverInfo.mem_total ? ` | ${(serverInfo.mem_total as number).toFixed(0)}GB` : ''}
{String(serverInfo.os || '')}{serverInfo.cpu_cores ? ` | ${serverInfo.cpu_cores as number | string} ${i18n.t('monitor.hostMonitorPanel.cores')}` : ''}{serverInfo.mem_total ? ` | ${(serverInfo.mem_total as number).toFixed(0)}GB` : ''}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Select value={aggType} onValueChange={(v) => setAggType(v as AggType)}>
<SelectTrigger className="w-24 h-7 text-xs dg-input"><SelectValue /></SelectTrigger>
<SelectTrigger className="w-24 h-7 text-xs dg-input">
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input">
<SelectItem value="avg"> {t('monitor.lbl.selectHost')} </SelectItem>
<SelectItem value="max"> {t('monitor.lbl.selectHost')} </SelectItem>
<SelectItem value="min"> {t('monitor.lbl.selectHost')} </SelectItem>
<SelectItem value="avg"> {i18n.t('monitor.hostmonitortsx.se68632a3')} </SelectItem>
<SelectItem value="max"> {i18n.t('monitor.hostmonitortsx.s5da89314')} </SelectItem>
<SelectItem value="min"> {i18n.t('monitor.hostmonitortsx.sc322edb8')} </SelectItem>
</SelectContent>
</Select>
<div className="flex flex-col items-end gap-0.5">
<HostTimeRangePicker value={timeRange} onChange={setTimeRange} />
{actualDataSpan && (
<span className="text-[10px] dg-text-muted tabular-nums">{t('monitor.hostMonitorPanel.dataSpan', { span: actualDataSpan })}</span>
<span className="text-[10px] dg-text-muted tabular-nums">{i18n.t('monitor.hostMonitorPanel.dataSpan', { span: actualDataSpan })}</span>
)}
</div>
</div>
@@ -156,7 +165,15 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
const monitorBody = (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="h-8 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] gap-0 overflow-x-auto">
{TABS.map(tab => (
{[
{ id: 'basic', label: i18n.t('monitor.hostmonitortsx.s00303869'), icon: <Monitor className="w-3.5 h-3.5" /> },
{ id: 'disk', label: i18n.t('monitor.hostmonitortsx.s5dd0a090'), icon: <HardDrive className="w-3.5 h-3.5" /> },
{ id: 'network', label: i18n.t('monitor.hostmonitortsx.s59805cc8'), icon: <Network className="w-3.5 h-3.5" /> },
{ id: 'ports', label: i18n.t('agent.portMonitor'), icon: <Server className="w-3.5 h-3.5" /> },
{ id: 'process', label: i18n.t('monitor.hostProcess'), icon: <List className="w-3.5 h-3.5" /> },
{ id: 'system', label: i18n.t('system.systemInfo'), icon: <Info className="w-3.5 h-3.5" /> },
{ id: 'health_probes', label: i18n.t('kubernetes.container.probes'), icon: <HeartPulse className="w-3.5 h-3.5" /> },
].map(tab => (
<TabsTrigger key={tab.id} value={tab.id} className="text-[11px] h-7 gap-1.5 px-3 data-[state=active]:bg-[var(--dg-bg-primary)]">
<span className="shrink-0 opacity-80">{tab.icon}</span>
{tab.label}
@@ -164,40 +181,334 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
))}
</TabsList>
<TabsContent value="basic" className="mt-3">
<BasicTab metrics={metrics} latest={latest} currentVal={currentVal} aggType={aggType} pctRefLines={PCT_REF_LINES} />
</TabsContent>
<TabsContent value="basic" className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.s4e6cbf10')} value={currentVal('cpu')} icon={<Cpu />} iconTone="blue"
data={metrics.cpu} color="#3b82f6" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.s79f80fbf')} value={latest?.cpu_iowait != null ? `${(latest.cpu_iowait as number).toFixed(1)}%` : '--'} icon={<Clock />} iconTone="orange"
data={metrics.iowait} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.s78262b59')} value={currentVal('mem')} icon={<Server />} iconTone="green"
data={metrics.mem} color="#10b981" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.sa23fcda0')} value={latest?.mem_available != null ? `${(latest.mem_available as number).toFixed(1)}GB` : '--'} icon={<Server />} iconTone="green"
data={metrics.memAvail} color="#34d399" unit="GB" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.s05589bfd')} value={currentVal('swap')} icon={<HardDrive />} iconTone="red"
data={metrics.swap} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} />
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.sae62efb2')} value={currentVal('load1')} icon={<Activity />} iconTone="purple"
data={metrics.load1} color="#8b5cf6" unit="" domain={[0, 'auto']} aggType={aggType} />
</TabsContent>
<TabsContent value="disk" className="mt-3">
<DiskTab metrics={metrics} latest={latest} currentVal={currentVal} aggType={aggType} pctRefLines={PCT_REF_LINES} diskIOInfo={diskIOInfo} />
</TabsContent>
<TabsContent value="disk" className="mt-3 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.sdf969221')} value={currentVal('disk')} icon={<HardDrive />} iconTone="orange"
data={metrics.disk} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.s20296121')} value={(() => {
const dArr = Array.isArray(parseJSON(latest?.disk_info)) ? parseJSON(latest?.disk_info) : [];
const root = dArr.find((d: JsonData) => d.mount === '/') || dArr[0];
return root?.inode_usage_pct != null ? `${root.inode_usage_pct.toFixed(1)}%` : '--';
})()} icon={<HardDrive />} iconTone="purple"
data={metrics.inode} color="#8b5cf6" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.s72e3c273')} value={currentVal('ioR')} icon={<Disc />} iconTone="blue"
data={metrics.ioRead} color="#3b82f6" unit="KB/s" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.sa9b96f9b')} value={currentVal('ioW')} icon={<Disc />} iconTone="green"
data={metrics.ioWrite} color="#10b981" unit="KB/s" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.s996adabd')} value={diskIOInfo?.util != null ? `${diskIOInfo.util.toFixed(1)}%` : '--'} icon={<Disc />} iconTone="red"
data={metrics.ioUtil} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
</div>
</TabsContent>
<TabsContent value="network" className="mt-3">
<NetworkTab metrics={metrics} netInfo={netInfo} currentVal={currentVal} aggType={aggType} />
</TabsContent>
<TabsContent value="network" className="mt-3 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<MetricChartCard title="TCP EST" value={currentVal('tcp')} icon={<Network />} iconTone="purple"
data={metrics.tcpEst} color="#8b5cf6" unit={i18n.t('monitor.hostMonitorPanel.portUnit')} domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title="TCP TIME_WAIT" value={currentVal('tw')} icon={<Clock />} iconTone="yellow"
data={metrics.tcpTW} color="#f59e0b" unit="个" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title="TCP CLOSE_WAIT" value={netInfo?.tcp_close_wait != null ? String(netInfo.tcp_close_wait) : '--'} icon={<Clock />} iconTone="red"
data={metrics.tcpCW} color="#ef4444" unit="个" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.s56c1b42a')} value={netInfo ? String((netInfo.tcp_established||0)+(netInfo.tcp_time_wait||0)+(netInfo.tcp_close_wait||0)) : '--'} icon={<Network />} iconTone="cyan"
data={metrics.connTotal} color="#22d3ee" unit="个" domain={[0, 'auto']} aggType={aggType} />
</div>
<DualLineChart
title={i18n.t('monitor.hostmonitortsx.s1e2a9e41')}
currentRx={netInfo?.net_rx_bps != null ? formatBPS(netInfo.net_rx_bps) : '--'}
currentTx={netInfo?.net_tx_bps != null ? formatBPS(netInfo.net_tx_bps) : '--'}
rxData={metrics.rx} txData={metrics.tx}
aggType={aggType} unit="KB/s"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.s9f7e9902')} value={netInfo?.net_rx_errors != null ? String(netInfo.net_rx_errors) : '--'} icon={<XCircle />} iconTone="red"
data={metrics.rxErr} color="#ef4444" unit="个" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title={i18n.t('monitor.hostmonitortsx.sb2ffd58e')} value={netInfo?.net_tx_errors != null ? String(netInfo.net_tx_errors) : '--'} icon={<XCircle />} iconTone="orange"
data={metrics.txErr} color="#f97316" unit="个" domain={[0, 'auto']} aggType={aggType} />
</div>
</TabsContent>
<TabsContent value="ports" className="mt-3">
<PortMonitorTab portInfo={portInfo} portPolicyMode={portPolicyMode} portWhitelist={portWhitelist}
onOpenPortPolicy={() => { refetchPortPolicy(); setPortPolicyDialogOpen(true); }} />
</TabsContent>
<TabsContent value="ports" className="mt-3 space-y-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<Server className="h-4 w-4 dg-text-green" />监听端口 {portInfo.length}
<span className="ml-auto flex items-center gap-2">
{portPolicyMode === 'blacklist' && portWhitelist.length > 0 && (
<Badge variant="outline" className="text-[10px] dg-text-yellow border-amber-500/30">
黑名单: {portWhitelist.join(',')}
</Badge>
)}
{portPolicyMode === 'whitelist' && (
<Badge variant="outline" className="text-[10px] dg-text-blue border-blue-500/30">
白名单模式
</Badge>
)}
<Button variant="outline" size="sm" className="h-6 text-[10px] px-2"
onClick={() => { refetchPortPolicy(); setPortPolicyDialogOpen(true); }}>
策略配置
</Button>
</span>
</h3>
{portInfo.length === 0 ? (
<div className="text-center py-6 text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
暂无端口数据
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="py-1.5 text-left font-medium"> {i18n.t('system.ldapPort')} </th>
<th className="py-1.5 text-left font-medium"> {i18n.t('kubernetes.service.protocol')} </th>
<th className="py-1.5 text-left font-medium"> {i18n.t('monitor.hostmonitortsx.sf88522cf')} </th>
<th className="py-1.5 text-right font-medium"> {i18n.t('system.terminalStatus')} </th>
</tr>
</thead>
<tbody>
{portInfo.map((p: JsonData, i: number) => (
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="py-1.5 font-mono text-[var(--dg-text-primary)]">{p.port}</td>
<td className="py-1.5">
<Badge variant="outline" className="text-[10px] font-mono">
{p.protocol || 'tcp'}
</Badge>
</td>
<td className="py-1.5 font-mono text-[var(--dg-text-muted)]">{p.process || '—'}</td>
<td className="py-1.5 text-right">
<Badge variant="default" className="text-[10px] bg-emerald-500/20 dg-text-green border-emerald-500/30">
LISTEN
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="process" className="mt-3">
<ProcessTab processTop={processTop} fdInfo={fdInfo} csInfo={csInfo} fdMetrics={metrics.fd}
aggType={aggType} fdChartRef={fdChartRef} fdChartZoomed={fdChartZoomed} setFdChartZoomed={setFdChartZoomed} />
</TabsContent>
<TabsContent value="process" className="mt-3 space-y-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<List className="h-4 w-4 dg-text-cyan" />进程 TOP {processTop.length}
</h3>
{processTop.length === 0 ? (
<div className="text-center py-6 text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
Agent v2.0+ 支持,等待数据上报…
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="py-1.5 text-left font-medium w-[40%]"> {i18n.t('monitor.hostmonitortsx.s23ecaf10')} </th>
<th className="py-1.5 text-right font-medium w-[8%]">PID</th>
<th className="py-1.5 text-right font-medium w-[16%]"> {i18n.t('monitor.hostmonitortsx.s78262b59')} </th>
<th className="py-1.5 text-right font-medium w-[16%]">CPU使用率(%)</th>
<th className="py-1.5 text-right font-medium w-[20%]"> {i18n.t('monitor.hostmonitortsx.s8fe5ed35')} </th>
</tr>
</thead>
<tbody>
{processTop.map((p: JsonData, i: number) => {
const cmd = p.cmdline || p.name || '?';
const cpup = p.cpu_pct != null ? Number(p.cpu_pct) : null;
const memp = p.mem_pct != null ? Number(p.mem_pct) : null;
return (
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="py-1.5 pr-2">
<div className="flex items-center gap-1 group/cmd">
<span className="font-mono text-[var(--dg-text-primary)] truncate max-w-[320px] inline-block align-middle" title={cmd}>
{cmd}
</span>
<button
className="opacity-0 group-hover/cmd:opacity-100 transition-opacity p-0.5 hover:dg-text-cyan shrink-0"
title={i18n.t('monitor.hostmonitortsx.s92cb88ae')}
onClick={async (e) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(cmd);
} catch {
const ta = document.createElement('textarea');
ta.value = cmd; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
}
}}
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
</td>
<td className="py-1.5 text-right font-mono text-[var(--dg-text-muted)]">{p.pid}</td>
<td className="py-1.5 text-right font-mono">
<span className={memp != null && memp > 50 ? 'text-rose-400' : 'dg-text-cyan'}>
{memp != null ? memp.toFixed(2) : '--'}
</span>
</td>
<td className="py-1.5 text-right font-mono">
<span className={cpup != null && cpup > 50 ? 'text-rose-400' : 'text-[var(--dg-text-primary)]'}>
{cpup != null ? cpup.toFixed(2) : '--'}
</span>
</td>
<td className="py-1.5 text-right font-mono text-[var(--dg-text-muted)]">{p.fd_count ?? '--'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<SystemInfoSection selectedHost={selectedHost} serverInfo={serverInfo} latest={latest}
heartbeatInfo={heartbeatInfo} portInfoCount={portInfo.length} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2 mb-3">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] flex items-center gap-2 leading-8">
<MetricIconBox icon={<HardDrive />} tone="purple" />
FD 文件句柄
</h3>
{metrics.fd.length > 0 && (
<ChartToolbar
zoomed={fdChartZoomed}
onExpand={() => fdChartRef.current?.expand()}
onResetZoom={() => fdChartRef.current?.resetZoom()}
/>
)}
</div>
{fdInfo?.allocated != null ? (
<>
<div className="flex items-center gap-4 mb-3 text-xs">
<div>
<span className="text-[var(--dg-text-muted)]"> {i18n.t('deploy.toast.assigned')} </span>
<span className="font-mono text-[var(--dg-text-primary)] font-semibold">{fdInfo.allocated.toLocaleString()}</span>
</div>
<div>
<span className="text-[var(--dg-text-muted)]"> {i18n.t('monitor.metricCatalog.colMax')} </span>
<span className="font-mono text-[var(--dg-text-primary)] font-semibold">{fdInfo.max === -1 ? i18n.t('monitor.hostmonitortsx.sbc436447') : fdInfo.max?.toLocaleString() || '—'}</span>
</div>
<div>
<span className="text-[var(--dg-text-muted)]"> {i18n.t('server.usage')} </span>
<span className={`font-mono font-semibold ${fdInfo.usage_pct > 80 ? 'text-rose-400' : 'dg-text-purple'}`}>
{fdInfo.usage_pct != null ? `${fdInfo.usage_pct.toFixed(1)}%` : '—'}
</span>
</div>
</div>
{metrics.fd.length > 0 && (
<ZoomableAreaChart
ref={fdChartRef}
data={metrics.fd.map((d) => ({ ...d, value: d[aggType] }))}
series={[{ dataKey: 'value', color: '#a78bfa', label: i18n.t('monitor.hostmonitortsx.s28130dec') }]}
yDomain={[0, 'auto']}
unit="%"
chartId="fd-usage"
title={i18n.t('monitor.hostmonitortsx.sc026a1ea')}
subtitle={fdInfo?.usage_pct != null ? `当前 ${fdInfo.usage_pct.toFixed(1)}%` : undefined}
height={160}
toolbarPlacement="header"
onZoomedChange={setFdChartZoomed}
showHint={false}
/>
)}
</>
) : (
<div className="text-center py-4 text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.hostmonitortsx.sae1a30b7')} </div>
)}
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<RefreshCw className="h-4 w-4 dg-text-green" />上下文切换
</h3>
{csInfo?.ctx_per_sec != null ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-3 text-center">
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">CTX 切换/秒</div>
<div className="text-lg font-bold font-mono dg-text-green">
{(csInfo.ctx_per_sec / 1e6).toFixed(1)}<span className="text-xs font-normal text-[var(--dg-text-muted)] ml-0.5">M</span>
</div>
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1 font-mono">{csInfo.ctx_per_sec?.toLocaleString()} 次</div>
</div>
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-3 text-center">
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">IRQ 中断/秒</div>
<div className="text-lg font-bold font-mono dg-text-yellow">
{(csInfo.irq_per_sec / 1e6).toFixed(1)}<span className="text-xs font-normal text-[var(--dg-text-muted)] ml-0.5">M</span>
</div>
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1 font-mono">{csInfo.irq_per_sec?.toLocaleString()} 次</div>
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="dg-text-green">CTX</span>
<span className="dg-text-yellow">IRQ</span>
</div>
<div className="h-2 bg-[var(--dg-bg-primary)] rounded-full overflow-hidden flex">
{csInfo.ctx_per_sec + csInfo.irq_per_sec > 0 && (
<>
<div className="h-full bg-emerald-500/60 rounded-l-full transition-all"
style={{ width: `${(csInfo.ctx_per_sec / (csInfo.ctx_per_sec + csInfo.irq_per_sec) * 100).toFixed(1)}%` }} />
<div className="h-full bg-amber-500/60 rounded-r-full transition-all"
style={{ width: `${(csInfo.irq_per_sec / (csInfo.ctx_per_sec + csInfo.irq_per_sec) * 100).toFixed(1)}%` }} />
</>
)}
</div>
</div>
</div>
) : (
<div className="text-center py-4 text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.hostmonitortsx.sae1a30b7')} </div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<SystemInfoSection
selectedHost={selectedHost}
serverInfo={serverInfo}
latest={latest}
heartbeatInfo={heartbeatInfo}
portInfoCount={portInfo.length}
/>
<HealthProbeSection selectedHost={selectedHost} />
</Tabs>
);
const dialogs = (
<PortPolicyDialog open={portPolicyDialogOpen} onOpenChange={setPortPolicyDialogOpen}
portPolicyMode={portPolicyMode} onModeChange={setPortPolicyMode}
portWhitelist={portWhitelist} onWhitelistChange={setPortWhitelist}
onSave={handleSavePortPolicy} saving={portPolicySaving} />
<PortPolicyDialog
open={portPolicyDialogOpen}
onOpenChange={setPortPolicyDialogOpen}
portPolicyMode={portPolicyMode}
onModeChange={setPortPolicyMode}
portWhitelist={portWhitelist}
onWhitelistChange={setPortWhitelist}
onSave={handleSavePortPolicy}
saving={portPolicySaving}
/>
);
if (embedded) {
@@ -205,7 +516,12 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
<div className="space-y-3">
{toolbar}
{!loading && history.length > 0 && (
<HostCoreMetricsStrip latest={latest} netInfo={netInfo} diskIOInfo={diskIOInfo} onJump={setActiveTab} />
<HostCoreMetricsStrip
latest={latest}
netInfo={netInfo}
diskIOInfo={diskIOInfo}
onJump={setActiveTab}
/>
)}
{loading && !history.length ? <PageLoading /> : monitorBody}
{dialogs}
@@ -214,11 +530,16 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
}
return (
<PageShell variant="workspace" title={t('monitor.alertRulesTab.catHost')}
subtitle={selectedHost ? t('common.currentHost', { host: selectedHost }) : t('monitor.lbl.selectHostHint')}
icon={<Server className="h-5 w-5" />} toolbar={toolbar}
loading={loading && !history.length} loadingLabel={t('monitor.hostMonitor.loadingData')}
contentClassName="space-y-3">
<PageShell
variant="workspace"
title={i18n.t('monitor.alertRulesTab.catHost')}
subtitle={selectedHost ? `当前主机: ${selectedHost}` : i18n.t('monitor.hostmonitortsx.s8d493048')}
icon={<Server className="h-5 w-5" />}
toolbar={toolbar}
loading={loading && !history.length}
loadingLabel={i18n.t('monitor.hostmonitortsx.sc23e802e')}
contentClassName="space-y-3"
>
{monitorBody}
{dialogs}
</PageShell>
+14 -16
View File
@@ -9,7 +9,6 @@ import { metricsApi } from '@/api';
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import type { JsonData } from '@/types/common';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface TargetInfo {
@@ -35,11 +34,10 @@ const METRIC_CATEGORIES: Record<string, { label: string; icon: React.ReactNode }
disk: { label: i18n.t('monitor.dashboard.col.disk'), icon: <HardDrive className="h-4 w-4" /> },
network: { label: i18n.t('monitor.hostMetric.network'), icon: <Network className="h-4 w-4" /> },
middleware: { label: i18n.t('monitor.alertRulesTab.catMiddleware'), icon: <Database className="h-4 w-4" /> },
device: { label: i18n.t('monitor.lbl.device'), icon: <Activity className="h-4 w-4" /> },
device: { label: i18n.t('monitor.metricsexplorer.sf55f1736'), icon: <Activity className="h-4 w-4" /> },
};
export function MetricsExplorer() {
const { t } = useTranslation();
const [targets, setTargets] = useState<TargetInfo[]>([]);
const [metricNames, setMetricNames] = useState<string[]>([]);
const [selectedTarget, setSelectedTarget] = useState('');
@@ -111,7 +109,7 @@ export function MetricsExplorer() {
<div className="flex items-center gap-4 flex-wrap w-full">
<Select value={selectedTarget} onValueChange={setSelectedTarget}>
<SelectTrigger className="w-64 dg-input">
<SelectValue placeholder={loading ? t('monitor.logPanel.logResults.loading') : t('common.loading')} />
<SelectValue placeholder={loading ? i18n.t('monitor.logPanel.logResults.loading') : i18n.t('monitor.metricsexplorer.sce4193cc')} />
</SelectTrigger>
<SelectContent className="dg-input max-h-80">
{targets.map(t => (
@@ -128,13 +126,13 @@ export function MetricsExplorer() {
<Search className="absolute left-2.5 top-2 h-4 w-4 dg-text-muted" />
<Input
className="pl-8 dg-input h-9"
placeholder={t('monitor.lbl.searchMetrics')}
placeholder={i18n.t('monitor.metricsexplorer.s21512a93')}
value={searchKw}
onChange={(e) => setSearchKw(e.target.value)}
/>
</div>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => { setSelectedMetrics([]); setQueryResults([]); }}>
{t('common.clear')}
清除
</Button>
</div>
);
@@ -142,19 +140,19 @@ export function MetricsExplorer() {
return (
<PageShell
variant="workspace"
title={t('monitor.lbl.explorerTitle')}
subtitle={i18n.t('monitor.lbl.explorerSubtitle')}
title={i18n.t('monitor.metricsexplorer.s26a10dc0')}
subtitle={i18n.t('monitor.metricsexplorer.s454945df')}
icon={<Activity className="h-5 w-5" />}
toolbar={toolbar}
loading={loading}
loadingLabel={i18n.t('monitor.lbl.loadingTargets')}
loadingLabel={i18n.t('monitor.metricsexplorer.s17f42742')}
contentClassName="space-y-6"
>
{!selectedTarget ? (
<PageEmpty
icon={<Filter className="h-12 w-12" />}
title={i18n.t('monitor.lbl.selectTarget')}
description={i18n.t('monitor.lbl.selectHint')}
title={i18n.t('monitor.metricsexplorer.sce251ece')}
description={i18n.t('monitor.metricsexplorer.sc1966a27')}
/>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -162,8 +160,8 @@ export function MetricsExplorer() {
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<BarChart3 className="h-4 w-4 dg-text-muted" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]"> {t('monitor.metricsExplorer.metricList')} </h3>
<span className="text-xs dg-text-muted ml-auto">{t('common.selected', { count: selectedMetrics.length })}</span>
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]"> {i18n.t('monitor.metricsexplorer.s3ec50388')} </h3>
<span className="text-xs dg-text-muted ml-auto">{selectedMetrics.length} 已选</span>
</div>
<div className="space-y-4 max-h-[60vh] overflow-y-auto">
{Object.entries(grouped).map(([cat, names]) => (
@@ -203,13 +201,13 @@ export function MetricsExplorer() {
<div className="flex items-center gap-2 mb-3">
<Activity className="h-4 w-4 dg-text-muted" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
{target?.name || ''} — {t('common.last1Hour')}
{target?.name || ''} — 最近 1 小时
</h3>
</div>
{selectedMetrics.length === 0 ? (
<PageEmpty icon={<Plus className="h-8 w-8" />} title={i18n.t('monitor.lbl.selectMetricsHint')} className="py-16" />
<PageEmpty icon={<Plus className="h-8 w-8" />} title={i18n.t('monitor.metricsexplorer.s701e608d')} className="py-16" />
) : sortedTimes.length === 0 ? (
<PageEmpty title={t('aiops.assistantNoData')} className="py-16" />
<PageEmpty title={i18n.t('aiops.assistantNoData')} className="py-16" />
) : (
<div className="h-80 overflow-x-auto">
<svg viewBox={`0 0 ${Math.max(sortedTimes.length * 4, 400)} 300`} className="w-full h-full min-w-[400px]">
@@ -12,7 +12,6 @@ import { InstanceCard } from './middleware-monitor/InstanceCard';
import { MiddlewareDialog } from './middleware-monitor/MiddlewareDialog';
import type { MwTarget } from './middleware-monitor/helpers';
import { REDIS_CHARTS, MYSQL_CHARTS, evalMwHealth, worstHealthLevel } from './middleware-monitor/helpers';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
type TabKey = 'redis' | 'mysql';
@@ -21,7 +20,6 @@ const REFRESH_INTERVAL_MS = 30_000;
interface MwListResponse { list: MwTarget[]; total: number }
export function MiddlewareMonitor() {
const { t } = useTranslation();
const confirm = useConfirm();
const [targets, setTargets] = useState<MwTarget[]>([]);
const [loading, setLoading] = useState(true);
@@ -64,14 +62,14 @@ export function MiddlewareMonitor() {
hasLoaded.current = true;
} catch (err) {
if (!silent) {
setError((err as { message?: string }).message || t('kubernetes.msg.loadFailed'));
setError((err as { message?: string }).message || i18n.t('kubernetes.msg.loadFailed'));
setTargets([]);
}
} finally {
setLoading(false);
setRefreshing(false);
}
}, [syncSelection, t]);
}, [syncSelection]);
useEffect(() => { loadTargets(); }, [loadTargets]);
@@ -88,14 +86,14 @@ export function MiddlewareMonitor() {
const warnCount = targets.filter((t) => worstHealthLevel(evalMwHealth(t)) !== 'ok').length;
const handleDelete = async (target: MwTarget) => {
const ok = await confirm({ title: t('monitor.lbl.deleteMonitor'), description: i18n.t('k8s.toast.confirmDeleteMonitor', { name: target.name }), confirmText: t('kubernetes.storage.delete2') });
const ok = await confirm({ title: i18n.t('monitor.middlewaremonit.sf9be97ed'), description: `确认删除 "${target.name}"?`, confirmText: i18n.t('kubernetes.storage.delete2') });
if (!ok) return;
try {
await middlewareMonitorApi.delete(target.id);
toast.success(t('monitor.silenceRules.deleted'));
toast.success(i18n.t('monitor.silenceRules.deleted'));
loadTargets();
} catch (err) {
toast.error(t('kubernetes.msg.deleteFailed2'), { description: (err as { message?: string }).message });
toast.error(i18n.t('kubernetes.msg.deleteFailed2'), { description: (err as { message?: string }).message });
}
};
@@ -103,10 +101,10 @@ export function MiddlewareMonitor() {
setTesting(target.id);
try {
const res = await middlewareMonitorApi.test(target.id) as { success: boolean; message: string };
if (res.success) { toast.success(t('aiops.llmTestSuccess'), { description: res.message }); loadTargets(true); }
else toast.error(t('aiops.llmTestFailed'), { description: res.message });
if (res.success) { toast.success(i18n.t('aiops.llmTestSuccess'), { description: res.message }); loadTargets(true); }
else toast.error(i18n.t('aiops.llmTestFailed'), { description: res.message });
} catch (err) {
toast.error(t('monitor.alertChannels.testFailed'), { description: (err as { message?: string }).message });
toast.error(i18n.t('monitor.alertChannels.testFailed'), { description: (err as { message?: string }).message });
} finally {
setTesting(null);
}
@@ -117,11 +115,11 @@ export function MiddlewareMonitor() {
try {
const res = await middlewareMonitorApi.seedDefaults() as { created: number; message: string; failed?: string[] };
await loadTargets();
if (res.failed?.length) toast.error(t('monitor.lbl.partialFail'), { description: res.message });
else if (res.created > 0) toast.success(t('monitor.lbl.defaultAdded'), { description: res.message });
else toast.success(t('monitor.lbl.defaultExists'));
if (res.failed?.length) toast.error(i18n.t('monitor.middlewaremonit.s669e8995'), { description: res.message });
else if (res.created > 0) toast.success(i18n.t('monitor.middlewaremonit.s11a31e75'), { description: res.message });
else toast.success(i18n.t('monitor.middlewaremonit.s7dda0fcb'));
} catch (err) {
toast.error(t('monitor.dashboard.custom.toastPanelFailed'), { description: (err as { message?: string }).message });
toast.error(i18n.t('monitor.dashboard.custom.toastPanelFailed'), { description: (err as { message?: string }).message });
} finally {
setSeeding(false);
}
@@ -130,10 +128,10 @@ export function MiddlewareMonitor() {
const handleCollectNow = async () => {
try {
const res = await middlewareMonitorApi.collectNow() as { message: string };
toast.success(t('monitor.lbl.collectDone'), { description: res.message });
toast.success(i18n.t('monitor.middlewaremonit.sc2c744ef'), { description: res.message });
loadTargets(true);
} catch (err) {
toast.error(t('monitor.lbl.deleteFail'), { description: (err as { message?: string }).message });
toast.error(i18n.t('monitor.middlewaremonit.s5665b68b'), { description: (err as { message?: string }).message });
}
};
@@ -151,39 +149,39 @@ export function MiddlewareMonitor() {
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-lg font-semibold"> {t('monitor.alertHelper.srcMiddleware')} </h2>
<h2 className="text-lg font-semibold"> {i18n.t('monitor.alertHelper.srcMiddleware')} </h2>
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">
{t('monitor.lbl.redisMysqlAutoRefresh')}
Redis 单机/集群 · MySQL · 30s 自动刷新
{lastRefreshAt && (
<span className="ml-2">
{t('monitor.lbl.lastUpdate')} {lastRefreshAt.toLocaleTimeString()}
{refreshing && t('monitor.lbl.refreshing')}
上次更新 {lastRefreshAt.toLocaleTimeString()}
{refreshing && i18n.t('monitor.middlewaremonit.s4d2bc924')}
</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleCollectNow} className="gap-1.5">
<Zap className="h-4 w-4" />{t("common.collectNow")}
<Zap className="h-4 w-4" />立即采集
</Button>
<Button variant="outline" size="sm" onClick={() => loadTargets(true)} disabled={refreshing} className="gap-1.5">
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />{t("common.refresh")}
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />刷新
</Button>
{targets.length === 0 && (
<Button variant="outline" size="sm" onClick={handleSeedDefaults} disabled={seeding} className="gap-1.5">
{seeding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Database className="h-4 w-4" />}
{t('monitor.lbl.addPlatformDefault')}
添加平台默认
</Button>
)}
<Button size="sm" onClick={() => openCreate()} className="gap-1.5">
<Plus className="h-4 w-4" />{t("common.addMonitor")}
<Plus className="h-4 w-4" />添加监控
</Button>
</div>
</div>
{loading && (
<div className="flex items-center justify-center py-12 text-sm text-[var(--dg-text-muted)]">
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("common.loading")}
<Loader2 className="h-5 w-5 animate-spin mr-2" />加载中...
</div>
)}
@@ -195,10 +193,10 @@ export function MiddlewareMonitor() {
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-12 text-center">
<Database className="h-12 w-12 mx-auto mb-4 text-[var(--dg-text-muted)]" />
<p className="text-sm text-[var(--dg-text-muted)]"> {t('monitor.middlewareMonit.emptyTargets')} </p>
<p className="text-sm text-[var(--dg-text-muted)]"> {i18n.t('monitor.middlewaremonit.sbbe62a10')} </p>
<div className="flex justify-center gap-2 mt-4">
<Button size="sm" onClick={handleSeedDefaults} disabled={seeding}> {t('monitor.middlewareMonit.addPlatformDefault')} </Button>
<Button size="sm" variant="outline" onClick={() => openCreate()}> {t('monitor.middlewareMonit.manualAdd')} </Button>
<Button size="sm" onClick={handleSeedDefaults} disabled={seeding}> {i18n.t('monitor.middlewaremonit.se5caf12b')} </Button>
<Button size="sm" variant="outline" onClick={() => openCreate()}> {i18n.t('monitor.middlewaremonit.s63edd678')} </Button>
</div>
</CardContent>
</Card>
@@ -208,9 +206,9 @@ export function MiddlewareMonitor() {
<>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{[
{ label: t('monitor.lbl.instances'), value: targets.length, icon: Activity },
{ label: t('agent.agentOnline'), value: onlineCount, icon: Database, color: 'dg-text-green' },
{ label: t('monitor.hostDashboard.alert'), value: warnCount, icon: Activity, color: warnCount > 0 ? 'dg-text-yellow' : 'dg-text-green' },
{ label: i18n.t('monitor.middlewaremonit.s35a55853'), value: targets.length, icon: Activity },
{ label: i18n.t('agent.agentOnline'), value: onlineCount, icon: Database, color: 'dg-text-green' },
{ label: i18n.t('monitor.hostDashboard.alert'), value: warnCount, icon: Activity, color: warnCount > 0 ? 'dg-text-yellow' : 'dg-text-green' },
{ label: 'Redis', value: redisTargets.length, icon: Database, color: 'dg-text-red' },
{ label: 'MySQL', value: mysqlTargets.length, icon: Server, color: 'dg-text-blue' },
].map((item) => (
@@ -252,8 +250,8 @@ export function MiddlewareMonitor() {
{currentTargets.length === 0 ? (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-8 text-center text-sm text-[var(--dg-text-muted)]">
{t('monitor.lbl.noInstancePrefix')} {tab === 'redis' ? 'Redis' : 'MySQL'} {t('monitor.lbl.instanceSuffix')},
<button type="button" className="dg-text-cyan ml-1 underline" onClick={() => openCreate(tab)}> {t('monitor.middlewareMonit.addNow')} </button>
暂无 {tab === 'redis' ? 'Redis' : 'MySQL'} 实例,
<button type="button" className="dg-text-cyan ml-1 underline" onClick={() => openCreate(tab)}> {i18n.t('monitor.middlewaremonit.s8ec8a6e8')} </button>
</CardContent>
</Card>
) : (
+6 -8
View File
@@ -7,16 +7,15 @@ import { HostMonitor } from './HostMonitor';
import { MiddlewareMonitor } from './MiddlewareMonitor';
import { SNMPMonitor } from './SNMPMonitor';
import { SiteMonitor } from './SiteMonitor';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
type TabId = 'host' | 'middleware' | 'snmp' | 'site';
const tabs: { id: TabId; name: string; description: string }[] = [
{ id: 'host', name: i18n.t('monitor.alertRulesTab.catHost'), description: i18n.t('monitor.lbl.hostMonitor') },
{ id: 'middleware', name: i18n.t('monitor.alertRulesTab.catMiddleware'), description: i18n.t('monitor.lbl.middleware') },
{ id: 'snmp', name: 'SNMP', description: i18n.t('monitor.lbl.snmpTitle') },
{ id: 'site', name: i18n.t('monitor.alertRulesTab.catSite'), description: i18n.t('monitor.lbl.siteProbe') },
{ id: 'host', name: i18n.t('monitor.alertRulesTab.catHost'), description: i18n.t('monitor.monitorcenterts.s811145d6') },
{ id: 'middleware', name: i18n.t('monitor.alertRulesTab.catMiddleware'), description: i18n.t('monitor.monitorcenterts.s757cdaf9') },
{ id: 'snmp', name: 'SNMP', description: i18n.t('monitor.monitorcenterts.s24f24e6f') },
{ id: 'site', name: i18n.t('monitor.alertRulesTab.catSite'), description: i18n.t('monitor.monitorcenterts.s5ea4cd7a') },
];
const TAB_IDS: TabId[] = ['host', 'middleware', 'snmp', 'site'];
@@ -26,7 +25,6 @@ function parseTab(raw: string | null): TabId {
}
export function MonitorCenter() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<TabId>(() => parseTab(searchParams.get('tab')));
@@ -45,8 +43,8 @@ export function MonitorCenter() {
return (
<PageShell
variant="list"
title={t('monitor.title')}
subtitle={current?.description ?? t('monitor.lbl.centerSubtitle')}
title={i18n.t('monitor.title')}
subtitle={current?.description ?? i18n.t('monitor.monitorcenterts.s771120b2')}
icon={<Activity className="h-5 w-5" />}
tabs={tabs.map((t) => ({ id: t.id, label: t.name }))}
activeTab={activeTab}
+39 -40
View File
@@ -21,7 +21,7 @@ import { SnmpOverviewCharts } from './snmp-monitor/SnmpOverviewCharts';
import { SnmpDeviceChartsPanel } from './snmp-monitor/SnmpDeviceChartsPanel';
import { SnmpTemplatesCollapsible, type SnmpTemplate } from './snmp-monitor/SnmpTemplatesCollapsible';
import type { SnmpDevice, SnmpMetric, SnmpOverviewItem } from './snmp-monitor/helpers';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface SnmpStats { total: number; online: number; abnormal: number }
@@ -38,7 +38,6 @@ const keyMetrics = [
];
export function SNMPMonitor() {
const { t } = useTranslation();
const [stats, setStats] = useState<SnmpStats>({ total: 0, online: 0, abnormal: 0 });
const [devices, setDevices] = useState<SnmpDevice[]>([]);
const [overview, setOverview] = useState<SnmpOverviewItem[]>([]);
@@ -106,7 +105,7 @@ export function SNMPMonitor() {
const handleAdd = async () => {
if (!form.name.trim() || !form.ip.trim() || !form.template_id) {
toast.error(t('monitor.lbl.fillRequired'));
toast.error(i18n.t('monitor.snmpmonitortsx.se385b8ab'));
return;
}
setSubmitting(true);
@@ -119,12 +118,12 @@ export function SNMPMonitor() {
template_id: Number(form.template_id),
vendor: tpl?.vendor || '', device_type: tpl?.device_type || 'switch',
});
toast.success(t('monitor.lbl.deviceAdded'));
toast.success(i18n.t('monitor.snmpmonitortsx.sbd007705'));
setDialogOpen(false);
setForm({ ...emptyForm });
refresh();
} catch (e) {
toast.error((e as { message?: string }).message || t('monitor.dashboard.custom.toastPanelFailed'));
toast.error((e as { message?: string }).message || i18n.t('monitor.dashboard.custom.toastPanelFailed'));
} finally {
setSubmitting(false);
}
@@ -135,10 +134,10 @@ export function SNMPMonitor() {
setCollectingId(d.id);
try {
await snmpApi.collectDevice(d.id);
toast.success(t('monitor.collectSuccess', { name: d.name }));
toast.success(`${d.name} 采集成功`);
refresh();
} catch (err) {
toast.error((err as { message?: string }).message || t('monitor.lbl.collectFail'));
toast.error((err as { message?: string }).message || i18n.t('monitor.middlewaremonit.s5665b68b'));
refresh();
} finally {
setCollectingId(null);
@@ -164,18 +163,18 @@ export function SNMPMonitor() {
e.stopPropagation();
try {
await snmpApi.deleteDevice(id);
toast.success(t('monitor.silenceRules.deleted'));
toast.success(i18n.t('monitor.silenceRules.deleted'));
if (selectedDevice?.id === id) setSelectedDevice(null);
refresh();
} catch {
toast.error(t('kubernetes.msg.deleteFailed2'));
toast.error(i18n.t('kubernetes.msg.deleteFailed2'));
}
};
const statusBadge = (status?: string) => {
if (status === 'online') return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30"> {t('agent.agentOnline')} </Badge>;
if (status === 'error') return <Badge className="bg-red-500/15 dg-text-red border-red-500/30"> {t('agent.heartbeatAbnormal')} </Badge>;
return <Badge className="bg-[var(--dg-bg-active)]/15 dg-text-secondary border-[var(--dg-border)]"> {t('agent.agentOffline')} </Badge>;
if (status === 'online') return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30"> {i18n.t('agent.agentOnline')} </Badge>;
if (status === 'error') return <Badge className="bg-red-500/15 dg-text-red border-red-500/30"> {i18n.t('agent.heartbeatAbnormal')} </Badge>;
return <Badge className="bg-[var(--dg-bg-active)]/15 dg-text-secondary border-[var(--dg-border)]"> {i18n.t('agent.agentOffline')} </Badge>;
};
return (
@@ -183,43 +182,43 @@ export function SNMPMonitor() {
{/* 工具栏 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-lg font-semibold">{t('monitor.lbl.snmpMonitorTitle')}</h2>
<p className="text-xs text-[var(--dg-text-muted)]">{t('monitor.lbl.snmpCollectInterval')}</p>
<h2 className="text-lg font-semibold">SNMP 监控</h2>
<p className="text-xs text-[var(--dg-text-muted)]">5 分钟采集 · 指标保留 7 天</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={refresh} className="h-8">
<RefreshCw className="h-3.5 w-3.5 mr-1" />{t("common.refresh")}
<RefreshCw className="h-3.5 w-3.5 mr-1" />刷新
</Button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" className="bg-cyan-500/10 dg-text-cyan border border-cyan-500/30 h-8">
<Plus className="h-3.5 w-3.5 mr-1" />{t("common.addDevice")}
<Plus className="h-3.5 w-3.5 mr-1" />添加设备
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle> {t('monitor.snmpMonitor.addDeviceTitle')} </DialogTitle>
<DialogDescription> {t('monitor.snmpMonitor.addDeviceDesc')} </DialogDescription>
<DialogTitle> {i18n.t('monitor.snmpmonitortsx.s29b90990')} </DialogTitle>
<DialogDescription> {i18n.t('monitor.snmpmonitortsx.s11ea7d0c')} </DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1.5">
<Label> {t('monitor.snmpMonitor.deviceName')} </Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder={t('monitor.lbl.demoSwitch')} />
<Label> {i18n.t('monitor.snmpmonitortsx.s9f694f60')} </Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder={i18n.t('monitor.snmpmonitortsx.s490f68f6')} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>{t('monitor.lbl.ipAddress')}</Label>
<Label>IP 地址</Label>
<Input value={form.ip} onChange={(e) => setForm({ ...form, ip: e.target.value })} placeholder="192.168.1.1" />
</div>
<div className="grid gap-1.5">
<Label> {t('system.ldapPort')} </Label>
<Label> {i18n.t('system.ldapPort')} </Label>
<Input type="number" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 161 })} />
</div>
</div>
<div className="grid gap-1.5">
<Label>{t('monitor.lbl.snmpOidTemplate')}</Label>
<Label>OID 模板</Label>
<Select value={form.template_id} onValueChange={(v) => setForm({ ...form, template_id: v })}>
<SelectTrigger><SelectValue placeholder={t('pipeline.selectTemplate')} /></SelectTrigger>
<SelectTrigger><SelectValue placeholder={i18n.t('pipeline.selectTemplate')} /></SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.id} value={String(t.id)}>{t.name} ({t.vendor})</SelectItem>
@@ -229,7 +228,7 @@ export function SNMPMonitor() {
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>{t('monitor.lbl.snmpVersion')}</Label>
<Label>SNMP 版本</Label>
<Select value={form.snmp_version} onValueChange={(v) => setForm({ ...form, snmp_version: v as 'v2c' | 'v3' })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -245,9 +244,9 @@ export function SNMPMonitor() {
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}> {t('rbac.jobRole.cancel')} </Button>
<Button variant="outline" onClick={() => setDialogOpen(false)}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button onClick={handleAdd} disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 animate-spin mr-1" />}{t('common.confirm')}
{submitting && <Loader2 className="h-4 w-4 animate-spin mr-1" />}确认
</Button>
</DialogFooter>
</DialogContent>
@@ -258,10 +257,10 @@ export function SNMPMonitor() {
{/* 紧凑统计 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{[
{ label: t('monitor.lbl.totalDevices'), value: stats.total, color: '' },
{ label: t('agent.agentOnline'), value: stats.online, color: 'dg-text-green' },
{ label: t('agent.heartbeatAbnormal'), value: stats.abnormal, color: 'dg-text-red' },
{ label: t('monitor.lbl.oidTemplate'), value: templates.length, color: 'dg-text-cyan' },
{ label: i18n.t('monitor.snmpmonitortsx.sc615660d'), value: stats.total, color: '' },
{ label: i18n.t('agent.agentOnline'), value: stats.online, color: 'dg-text-green' },
{ label: i18n.t('agent.heartbeatAbnormal'), value: stats.abnormal, color: 'dg-text-red' },
{ label: i18n.t('monitor.snmpmonitortsx.s48cfbb79'), value: templates.length, color: 'dg-text-cyan' },
].map((item) => (
<div key={item.label} className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-center">
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
@@ -272,13 +271,13 @@ export function SNMPMonitor() {
{loading ? (
<div className="py-16 text-center text-sm text-[var(--dg-text-muted)]">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />{t("common.loading")}
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...
</div>
) : devices.length === 0 ? (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="py-12 text-center text-sm text-[var(--dg-text-muted)]">
<Router className="h-10 w-10 mx-auto mb-3 opacity-40" />
{t('monitor.lbl.snmpNoDevice')}
暂无 SNMP 设备,点击「添加设备」开始监控
</CardContent>
</Card>
) : (
@@ -289,14 +288,14 @@ export function SNMPMonitor() {
<div className="grid grid-cols-1 xl:grid-cols-12 gap-4 items-start">
<Card className="xl:col-span-5 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<h3 className="text-sm font-medium mb-3">{t('common.deviceList')} ({devices.length})</h3>
<h3 className="text-sm font-medium mb-3">设备列表 ({devices.length})</h3>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs"> {t('kubernetes.secret.secretName')} </TableHead>
<TableHead className="text-xs"> {i18n.t('kubernetes.secret.secretName')} </TableHead>
<TableHead className="text-xs">IP</TableHead>
<TableHead className="text-xs"> {t('system.terminalStatus')} </TableHead>
<TableHead className="text-xs"> {i18n.t('system.terminalStatus')} </TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
@@ -348,14 +347,14 @@ export function SNMPMonitor() {
<Dialog open={metricsOpen} onOpenChange={setMetricsOpen}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto bg-[var(--dg-bg-secondary)]">
<DialogHeader>
<DialogTitle>{metricsDevice?.name} — {t('monitor.lbl.snmpCollectMetrics')}</DialogTitle>
<DialogTitle>{metricsDevice?.name} — 采集指标</DialogTitle>
<DialogDescription>{metricsDevice?.ip} · {templateName(metricsDevice?.template_id || 0)}</DialogDescription>
</DialogHeader>
{metricsLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground"> {t('monitor.logPanel.logResults.loading')} </div>
<div className="py-8 text-center text-sm text-muted-foreground"> {i18n.t('monitor.logPanel.logResults.loading')} </div>
) : metrics.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t('monitor.lbl.snmpNoMetrics')}
暂无指标,请先采集
{metricsDevice?.last_error && <p className="mt-2 text-xs dg-text-red">{metricsDevice.last_error}</p>}
</div>
) : (
@@ -373,7 +372,7 @@ export function SNMPMonitor() {
<Table>
<TableHeader>
<TableRow>
<TableHead> {t('cloud.colMetric')} </TableHead><TableHead> {t('monitor.logIndex')} </TableHead><TableHead> {t('common.newValue')} </TableHead><TableHead>OID</TableHead>
<TableHead> {i18n.t('cloud.colMetric')} </TableHead><TableHead> {i18n.t('monitor.logIndex')} </TableHead><TableHead>值</TableHead><TableHead>OID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
+43 -45
View File
@@ -23,7 +23,6 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
import type { JsonData } from '@/types/common';
import { SiteProtocolPieChart } from './site-monitor/SiteProtocolPieChart';
import { SiteTaskDetailPage } from './site-monitor/SiteTaskDetailPage';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
import {
PROBE_TYPE_OPTIONS, type ProbeType, type SiteStats, type SiteTask,
@@ -45,13 +44,12 @@ const FILTER_TYPES: Array<ProbeType | 'ALL'> = ['ALL', ...PROBE_TYPE_OPTIONS];
function alertStatusBadge(status?: string) {
if (status === 'alerting') {
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30 text-[10px]"> {i18n.t('monitor.siteMonitor.alerting')} </Badge>;
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30 text-[10px]"> {i18n.t('monitor.sitemonitortsx.sb2da5804')} </Badge>;
}
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30 text-[10px]"> {i18n.t('agent.heartbeatNormal')} </Badge>;
}
export function SiteMonitor() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const taskIdParam = searchParams.get('task');
const [stats, setStats] = useState<SiteStats | null>(null);
@@ -139,7 +137,7 @@ export function SiteMonitor() {
};
const handleSave = async () => {
if (!form.name.trim() || !form.url.trim()) { toast.error(t('monitor.lbl.nameAddrRequired')); return; }
if (!form.name.trim() || !form.url.trim()) { toast.error(i18n.t('monitor.sitemonitortsx.s8eb6b1a6')); return; }
setSaving(true);
const payload = {
name: form.name.trim(), url: form.url.trim(), type: form.type.toLowerCase(),
@@ -148,34 +146,34 @@ export function SiteMonitor() {
expected_status_code: Number(form.expected_status_code) || 200,
};
try {
if (editingTaskId) { await siteApi.updateTask(editingTaskId, payload); toast.success(t('monitor.lbl.taskUpdated')); }
else { await siteApi.createTask(payload); toast.success(t('monitor.lbl.taskCreated')); }
if (editingTaskId) { await siteApi.updateTask(editingTaskId, payload); toast.success(i18n.t('monitor.sitemonitortsx.se01498ee')); }
else { await siteApi.createTask(payload); toast.success(i18n.t('monitor.sitemonitortsx.s48723e14')); }
setFormOpen(false); setEditingTaskId(null);
loadTasks(); loadStats();
} catch (e) {
toast.error((e as JsonData).message || (editingTaskId ? t('kubernetes.deployment.imageUpdateFailed') : t('monitor.dashboard.custom.toastCreateFailed')));
toast.error((e as JsonData).message || (editingTaskId ? i18n.t('kubernetes.deployment.imageUpdateFailed') : i18n.t('monitor.dashboard.custom.toastCreateFailed')));
} finally { setSaving(false); }
};
const handleDelete = async (task: SiteTask) => {
const ok = await _confirm({ title: t('monitor.deleteProbe'), description: i18n.t('k8s.toast.confirmDeleteTask', { name: task.name }), variant: 'destructive' });
const ok = await _confirm({ title: i18n.t('monitor.deleteProbe'), description: `确定删除「${task.name}」吗?`, variant: 'destructive' });
if (!ok) return;
try {
await siteApi.deleteTask(task.id);
if (taskIdParam === String(task.id)) {
closeDetail();
}
toast.success(t('monitor.lbl.taskDeleted'));
toast.success(i18n.t('monitor.sitemonitortsx.sb2ae04aa'));
loadTasks(); loadStats();
} catch (e) { toast.error((e as JsonData).message || t('kubernetes.msg.deleteFailed2')); }
} catch (e) { toast.error((e as JsonData).message || i18n.t('kubernetes.msg.deleteFailed2')); }
};
const handleToggle = async (task: SiteTask) => {
try {
await siteApi.toggleTask(task.id, !task.enabled);
toast.success(task.enabled ? t('cloud.stopped') : t('aiops.llmEnabled'));
toast.success(task.enabled ? i18n.t('cloud.stopped') : i18n.t('aiops.llmEnabled'));
loadTasks(); loadStats();
} catch (e) { toast.error((e as JsonData).message || t('cloud.operationFailed')); }
} catch (e) { toast.error((e as JsonData).message || i18n.t('cloud.operationFailed')); }
};
const targetMeta = probeTypeMeta[form.type];
@@ -194,8 +192,8 @@ export function SiteMonitor() {
if (!detailTask) {
return (
<div className="space-y-4 text-center py-16">
<p className="text-sm text-[var(--dg-text-muted)]"> {t('monitor.siteMonitor.taskNotFound')} </p>
<Button variant="outline" size="sm" onClick={closeDetail}> {t('monitor.siteTask.backToList')} </Button>
<p className="text-sm text-[var(--dg-text-muted)]"> {i18n.t('monitor.sitemonitortsx.s62a0023a')} </p>
<Button variant="outline" size="sm" onClick={closeDetail}> {i18n.t('monitor.siteTask.backToList')} </Button>
</div>
);
}
@@ -212,15 +210,15 @@ export function SiteMonitor() {
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-lg font-semibold"> {t('monitor.alertRulesTab.catSite')} </h2>
<p className="text-xs text-[var(--dg-text-muted)]">{t('monitor.lbl.siteMonitorSubtitle')}</p>
<h2 className="text-lg font-semibold"> {i18n.t('monitor.alertRulesTab.catSite')} </h2>
<p className="text-xs text-[var(--dg-text-muted)]">HTTP Web 拨测 · SSL 证书检测 · TCP / DNS / PING</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" className="h-8" onClick={() => { loadStats(); loadTasks(); }} disabled={refreshing}>
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${refreshing ? 'animate-spin' : ''}`} />{t("common.refresh")}
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${refreshing ? 'animate-spin' : ''}`} />刷新
</Button>
<Button size="sm" className="h-8 gap-1.5" onClick={() => openCreate()}>
<Plus className="h-3.5 w-3.5" />{t("common.addTask")}
<Plus className="h-3.5 w-3.5" />添加任务
</Button>
</div>
</div>
@@ -229,11 +227,11 @@ export function SiteMonitor() {
<div className="xl:col-span-8 space-y-4">
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{[
{ label: t('monitor.lbl.totalTasks'), value: stats?.total_tasks ?? '--' },
{ label: t('agent.heartbeatNormal'), value: stats?.up_count ?? '--', color: 'dg-text-green' },
{ label: t('agent.heartbeatAbnormal'), value: stats?.down_count ?? '--', color: 'dg-text-red' },
{ label: t('monitor.lbl.availability24h'), value: availStr, color: 'dg-text-blue' },
{ label: t('monitor.lbl.totalProbes'), value: probeCountStr },
{ label: i18n.t('monitor.sitemonitortsx.s0f7991c2'), value: stats?.total_tasks ?? '--' },
{ label: i18n.t('agent.heartbeatNormal'), value: stats?.up_count ?? '--', color: 'dg-text-green' },
{ label: i18n.t('agent.heartbeatAbnormal'), value: stats?.down_count ?? '--', color: 'dg-text-red' },
{ label: i18n.t('monitor.sitemonitortsx.s72a1a913'), value: availStr, color: 'dg-text-blue' },
{ label: i18n.t('monitor.sitemonitortsx.s0fad73a6'), value: probeCountStr },
].map((item) => (
<div key={item.label} className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-center">
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
@@ -247,8 +245,8 @@ export function SiteMonitor() {
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-sm font-medium"> {t('monitor.siteMonitor.probeTaskTitle')} </h3>
<span className="text-[10px] text-[var(--dg-text-muted)]"> {t('monitor.siteMonitor.clickToObserve')} </span>
<h3 className="text-sm font-medium"> {i18n.t('monitor.sitemonitortsx.s30319e1c')} </h3>
<span className="text-[10px] text-[var(--dg-text-muted)]"> {i18n.t('monitor.sitemonitortsx.sa5e6b654')} </span>
</div>
<div className="flex flex-wrap gap-1">
{FILTER_TYPES.map((type) => {
@@ -265,7 +263,7 @@ export function SiteMonitor() {
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border border-transparent hover:text-[var(--dg-text-primary)]'
}`}
>
{type === 'ALL' ? t('system.auditAll') : type} {count > 0 && count}
{type === 'ALL' ? i18n.t('system.auditAll') : type} {count > 0 && count}
</button>
);
})}
@@ -274,19 +272,19 @@ export function SiteMonitor() {
{filteredTasks.length === 0 ? (
<div className="text-center py-10 text-sm text-[var(--dg-text-muted)]">
{tasks.length === 0 ? t('monitor.lbl.noTasks') : t('monitor.lbl.noFilteredTasks')}
{tasks.length === 0 ? i18n.t('monitor.sitemonitortsx.sdd8ebb79') : i18n.t('monitor.sitemonitortsx.s0f961fe6')}
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-xs"> {t('kubernetes.secret.secretName')} </TableHead>
<TableHead className="text-xs"> {t('system.credentialType')} </TableHead>
<TableHead className="text-xs"> {t('monitor.siteTask.frequency')} </TableHead>
<TableHead className="text-xs"> {t('common.all')} </TableHead>
<TableHead className="text-xs"> {t('common.all')} </TableHead>
<TableHead className="text-xs"> {t('system.userActive')} </TableHead>
<TableHead className="text-xs"> {i18n.t('kubernetes.secret.secretName')} </TableHead>
<TableHead className="text-xs"> {i18n.t('system.credentialType')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.siteTask.frequency')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.sitemonitortsx.sdb4470d9')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.sitemonitortsx.s4fe553c8')} </TableHead>
<TableHead className="text-xs"> {i18n.t('system.userActive')} </TableHead>
<TableHead className="text-xs w-24" />
</TableRow>
</TableHeader>
@@ -302,7 +300,7 @@ export function SiteMonitor() {
<span className="text-sm font-medium truncate">{task.name}</span>
{isSslProbeTask(task) && isSslExpiringSoon(task.ssl_days_remaining) && (
<Badge className="bg-amber-500/15 dg-text-yellow border-amber-500/30 text-[10px] shrink-0 gap-0.5">
<ShieldAlert className="h-3 w-3" />{t("common.certLabel")}
<ShieldAlert className="h-3 w-3" />证书
</Badge>
)}
</div>
@@ -321,7 +319,7 @@ export function SiteMonitor() {
<TableCell className="py-2">
<div className="flex items-center justify-end gap-0.5">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => openDetail(task)}>
<LineChart className="h-3 w-3 mr-1" />{t("common.observable")}
<LineChart className="h-3 w-3 mr-1" />可观测
</Button>
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(task)}><Pencil className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon-sm" className="dg-text-red" onClick={() => handleDelete(task)}><Trash2 className="h-3.5 w-3.5" /></Button>
@@ -345,16 +343,16 @@ export function SiteMonitor() {
<Dialog open={formOpen} onOpenChange={(open) => { setFormOpen(open); if (!open) setEditingTaskId(null); }}>
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle>{editingTaskId ? t('monitor.editProbe') : t('monitor.addProbe')}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]"> {t('monitor.siteMonitor.probeDesc')} </DialogDescription>
<DialogTitle>{editingTaskId ? i18n.t('monitor.editProbe') : i18n.t('monitor.addProbe')}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]"> {i18n.t('monitor.sitemonitortsx.s6173cf9a')} </DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-1.5">
<Label> {t('automation.cronJobName')} </Label>
<Input placeholder={t('monitor.lbl.urlPlaceholder')} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<Label> {i18n.t('automation.cronJobName')} </Label>
<Input placeholder={i18n.t('monitor.sitemonitortsx.s024a6a59')} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="grid gap-1.5">
<Label> {t('common.save')} </Label>
<Label> {i18n.t('monitor.sitemonitortsx.se825ec78')} </Label>
<Select value={form.type} onValueChange={(v) => {
const t = v as ProbeType;
setForm({ ...form, type: t, frequency_sec: editingTaskId ? form.frequency_sec : defaultProbeFrequency(t) });
@@ -372,30 +370,30 @@ export function SiteMonitor() {
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label> {t('common.save')} </Label>
<Label> {i18n.t('monitor.sitemonitortsx.s4966e6e5')} </Label>
<Input type="number" min={MIN_PROBE_FREQUENCY[form.type]} max={86400} value={form.frequency_sec}
onChange={(e) => setForm({ ...form, frequency_sec: Number(e.target.value) || defaultProbeFrequency(form.type) })} />
<p className="text-[10px] text-[var(--dg-text-muted)]">{probeFrequencyHint(form.type)}</p>
</div>
<div className="grid gap-1.5">
<Label> {t('common.save')} </Label>
<Label> {i18n.t('monitor.sitemonitortsx.s52e9bc2c')} </Label>
<Input type="number" min={1} max={300} value={form.timeout_sec}
onChange={(e) => setForm({ ...form, timeout_sec: Number(e.target.value) || 10 })} />
</div>
</div>
{form.type === 'HTTP' && (
<div className="grid gap-1.5">
<Label> {t('common.save')} </Label>
<Label> {i18n.t('monitor.sitemonitortsx.s88cbfb5c')} </Label>
<Input type="number" min={100} max={599} value={form.expected_status_code}
onChange={(e) => setForm({ ...form, expected_status_code: Number(e.target.value) || 200 })} />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setFormOpen(false); setEditingTaskId(null); }} disabled={saving}> {t('rbac.jobRole.cancel')} </Button>
<Button variant="outline" onClick={() => { setFormOpen(false); setEditingTaskId(null); }} disabled={saving}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
{editingTaskId ? t('task.script.save') : t('common.save')}
{editingTaskId ? i18n.t('task.script.save') : i18n.t('monitor.sitemonitortsx.s6ef95fe9')}
</Button>
</DialogFooter>
</DialogContent>
@@ -6,7 +6,7 @@ import { ZoomableAreaChart, type ZoomableChartHandle } from './ZoomableAreaChart
import { ChartToolbar } from './ChartToolbar';
import { MetricIconBox } from './MetricIconBox';
import type { AggType, BucketPoint } from './types';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface DualLineChartProps {
title: string;
@@ -21,7 +21,6 @@ interface DualLineChartProps {
export function DualLineChart({
title, currentRx, currentTx, rxData, txData, aggType, unit,
}: DualLineChartProps) {
const { t } = useTranslation();
const chartRef = useRef<ZoomableChartHandle>(null);
const [zoomed, setZoomed] = useState(false);
@@ -44,11 +43,11 @@ export function DualLineChart({
<div className="flex items-center gap-3 text-xs">
<span>
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-blue-400 mr-1 align-middle" />
{t('monitor.lbl.rx')} <b className="text-blue-300">{currentRx}</b>
接收 <b className="text-blue-300">{currentRx}</b>
</span>
<span>
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-emerald-400 mr-1 align-middle" />
{t('monitor.lbl.tx')} <b className="dg-text-green">{currentTx}</b>
发送 <b className="dg-text-green">{currentTx}</b>
</span>
</div>
<ChartToolbar
@@ -62,14 +61,14 @@ export function DualLineChart({
ref={chartRef}
data={chartData}
series={[
{ dataKey: 'rx', color: '#3b82f6', label: t('common.receive') },
{ dataKey: 'tx', color: '#10b981', label: t('aiops.sendMessage') },
{ dataKey: 'rx', color: '#3b82f6', label: i18n.t('common.receive') },
{ dataKey: 'tx', color: '#10b981', label: i18n.t('aiops.sendMessage') },
]}
yDomain={[0, 'auto']}
unit={unit}
chartId={`dual-${title.replace(/\s/g, '')}`}
title={title}
subtitle={t('monitor.lbl.receiveSend', { rx: currentRx, tx: currentTx })}
subtitle={`接收 ${currentRx} · 发送 ${currentTx}`}
height={208}
toolbarPlacement="header"
onZoomedChange={setZoomed}
@@ -18,7 +18,6 @@ import {
import { DEFAULT_PROBE_FORM, type ProbeForm } from './types';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface HealthProbeSectionProps {
@@ -26,7 +25,6 @@ interface HealthProbeSectionProps {
}
export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
const qc = useQueryClient();
const { data: probes = [], isFetching, refetch } = useHealthProbes(selectedHost);
@@ -71,15 +69,15 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
setProbeDialogOpen(false);
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
} catch {
toast.error(t('kubernetes.msg.saveFailed'));
toast.error(i18n.t('kubernetes.msg.saveFailed'));
}
};
const handleDeleteProbe = async (p: Record<string, unknown>) => {
const ok = await confirm({
title: t('monitor.lbl.deleteProbe'),
description: i18n.t('k8s.toast.confirmDeleteProbe', { name: p.name }),
confirmText: t('kubernetes.storage.delete2'),
title: i18n.t('monitor.healthprobesect.s9da33b04'),
description: `确定删除探针「${p.name}」?`,
confirmText: i18n.t('kubernetes.storage.delete2'),
variant: 'destructive',
});
if (!ok) return;
@@ -91,20 +89,20 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
<>
<TabsContent value="health_probes" className="mt-3 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]"> {t('monitor.lbl.unlimited')} </h3>
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]"> {i18n.t('monitor.healthprobesect.s518614fc')} </h3>
<div className="flex gap-2">
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn('w-3 h-3 mr-1', isFetching && 'animate-spin')} />{t("common.refresh")}
<RefreshCw className={cn('w-3 h-3 mr-1', isFetching && 'animate-spin')} />刷新
</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={openCreateDialog}>
<Plus className="w-3 h-3 mr-1" />{t("common.addProbe")}
<Plus className="w-3 h-3 mr-1" />添加探针
</Button>
</div>
</div>
{probes.length === 0 ? (
<Card className="bg-[var(--dg-bg-secondary)] border-dashed border-[var(--dg-border)]">
<CardContent className="p-8 text-center text-xs text-[var(--dg-text-muted)]">
{t('monitor.lbl.noProbeHint', { action: t('monitor.lbl.addProbe') })}
暂无健康探针,点击i18n.t('monitor.healthprobesect.s5bf2e80a')创建
</CardContent>
</Card>
) : (
@@ -142,11 +140,11 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
<div className="truncate font-mono">{String(p.exec_command)}</div>
)}
<div className="flex items-center gap-3">
<span> {t('monitor.lbl.unlimited')} <span className={p.last_status === 'up' ? 'dg-text-green' : p.last_status === 'down' ? 'dg-text-red' : 'dg-text-secondary'}>
{p.last_status ? String(p.last_status).toUpperCase() : t('monitor.lbl.notDetected')}
<span> {i18n.t('monitor.healthprobesect.s13b5c750')} <span className={p.last_status === 'up' ? 'dg-text-green' : p.last_status === 'down' ? 'dg-text-red' : 'dg-text-secondary'}>
{p.last_status ? String(p.last_status).toUpperCase() : i18n.t('monitor.healthprobesect.s5c6585e0')}
</span></span>
{Number(p.last_latency_ms) > 0 && <span>{t('common.latency')}: {String(p.last_latency_ms)}ms</span>}
{Number(p.fail_count) > 0 && <span className="dg-text-red">{t('common.consecutiveFail')}: {String(p.fail_count)}</span>}
{Number(p.last_latency_ms) > 0 && <span>延迟: {String(p.last_latency_ms)}ms</span>}
{Number(p.fail_count) > 0 && <span className="dg-text-red">连续失败: {String(p.fail_count)}次</span>}
{p.last_checked_at && <span className="text-[10px]">{new Date(String(p.last_checked_at)).toLocaleString('zh-CN')}</span>}
</div>
</div>
@@ -161,23 +159,23 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{editingProbe ? t('monitor.lbl.editProbe') : t('monitor.lbl.addProbe')}
{editingProbe ? i18n.t('monitor.healthprobesect.sc7c452e5') : i18n.t('monitor.healthprobesect.s5bf2e80a')}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
{editingProbe ? t('monitor.lbl.editProbe') : t('monitor.lbl.addProbe')}
{editingProbe ? i18n.t('monitor.healthprobesect.se98536d2') : i18n.t('monitor.healthprobesect.s6f43d059')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {t('monitor.lbl.probeName')} <span className="dg-text-red">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.name} onChange={e => setProbeForm({ ...probeForm, name: e.target.value })} placeholder={t('monitor.healthProbe.namePlaceholder')} />
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {i18n.t('monitor.healthprobesect.s1fdfad47')} <span className="dg-text-red">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.name} onChange={e => setProbeForm({ ...probeForm, name: e.target.value })} placeholder={i18n.t('monitor.healthprobesect.s2b576719')} />
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {t('agent.installTarget')} </label>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {i18n.t('agent.installTarget')} </label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.hostname} onChange={e => setProbeForm({ ...probeForm, hostname: e.target.value })} />
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {t('kubernetes.container.probeType')} </label>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {i18n.t('kubernetes.container.probeType')} </label>
<select className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.probe_type} onChange={e => setProbeForm({ ...probeForm, probe_type: e.target.value })}>
<option value="http_get">HTTP GET</option>
<option value="tcp_socket">TCP Socket</option>
@@ -190,9 +188,9 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">HTTP URL <span className="dg-text-red">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_url} onChange={e => setProbeForm({ ...probeForm, http_url: e.target.value })} placeholder="http://localhost:8080/health" /></div>
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {t('monitor.lbl.unlimited')} </label>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {i18n.t('monitor.sitemonitortsx.s88cbfb5c')} </label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_expect_code} onChange={e => setProbeForm({ ...probeForm, http_expect_code: Number(e.target.value) })} /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {t('monitor.lbl.unlimited')} </label>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {i18n.t('monitor.healthprobesect.s27f2c428')} </label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_expect_body} onChange={e => setProbeForm({ ...probeForm, http_expect_body: e.target.value })} /></div>
</div>
</>
@@ -200,32 +198,32 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
{probeForm.probe_type === 'tcp_socket' && (
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">{t('monitor.lbl.tcpTargetAddr')}</label>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">TCP 目标地址</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.tcp_host} onChange={e => setProbeForm({ ...probeForm, tcp_host: e.target.value })} placeholder="localhost" /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">{t('monitor.lbl.tcpPort')}</label>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">TCP 端口</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.tcp_port} onChange={e => setProbeForm({ ...probeForm, tcp_port: Number(e.target.value) })} /></div>
</div>
)}
{probeForm.probe_type === 'exec_command' && (
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {t('aiops.assistantCommand')} <span className="dg-text-red">*</span></label>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {i18n.t('aiops.assistantCommand')} <span className="dg-text-red">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs font-mono text-[var(--dg-text-primary)]" value={probeForm.exec_command} onChange={e => setProbeForm({ ...probeForm, exec_command: e.target.value })} placeholder="pgrep -x mysqld" /></div>
)}
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {t('automation.remoteTimeout')} </label>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {i18n.t('automation.remoteTimeout')} </label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.timeout} onChange={e => setProbeForm({ ...probeForm, timeout: Number(e.target.value) })} /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {t('monitor.lbl.execCommand')} </label>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {i18n.t('monitor.healthprobesect.sc7f8350a')} </label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.interval} onChange={e => setProbeForm({ ...probeForm, interval: Number(e.target.value) })} /></div>
</div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {t('aiops.llmModelDesc')} </label>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block"> {i18n.t('aiops.llmModelDesc')} </label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.description} onChange={e => setProbeForm({ ...probeForm, description: e.target.value })} /></div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => setProbeDialogOpen(false)}> {t('rbac.jobRole.cancel')} </Button>
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => setProbeDialogOpen(false)}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={handleSaveProbe} disabled={!probeForm.name || !probeForm.hostname}>
{editingProbe ? t('task.script.save') : t('rbac.jobRole.create')}
{editingProbe ? i18n.t('task.script.save') : i18n.t('rbac.jobRole.create')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,15 +1,16 @@
// 主机监控时间范围选择器 — 预设 + 自定义 // Logan.Li
import { useState, useRef, useEffect } from 'react';
import { useState } from 'react';
import { Calendar, ChevronDown, Timer } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
TIME_RANGE_PRESETS,
type HostMonitorTimeRange,
type TimeRangePresetId,
} from './types';
import { presetToRange, toDatetimeLocalValue, formatRangeSummary } from './timeRange';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface Props {
value: HostMonitorTimeRange;
@@ -17,20 +18,10 @@ interface Props {
}
export function HostTimeRangePicker({ value, onChange }: Props) {
const { t } = useTranslation();
const [customOpen, setCustomOpen] = useState(false);
const now = new Date();
const [customStart, setCustomStart] = useState(toDatetimeLocalValue(new Date(now.getTime() - 3600_000)));
const [customEnd, setCustomEnd] = useState(toDatetimeLocalValue(now));
const popRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (popRef.current && !popRef.current.contains(e.target as Node)) setCustomOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const selectPreset = (id: TimeRangePresetId) => {
if (id === 'custom') return;
@@ -46,7 +37,7 @@ export function HostTimeRangePicker({ value, onChange }: Props) {
const e = start.getTime() <= end.getTime() ? end : start;
onChange({
preset: 'custom',
label: t('aiops.providerCustom'),
label: i18n.t('aiops.providerCustom'),
start: s.toISOString(),
end: e.toISOString(),
buckets: Math.min(120, Math.max(36, Math.ceil((e.getTime() - s.getTime()) / 60_000 / 2))),
@@ -58,7 +49,7 @@ export function HostTimeRangePicker({ value, onChange }: Props) {
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] dg-text-muted hidden sm:inline-flex items-center gap-1">
<Timer className="h-3 w-3" />
{t('monitor.lbl.timeRange')}
时间范围
</span>
<div className="flex items-center gap-1 rounded-md border border-[var(--dg-border)] p-0.5 bg-[var(--dg-bg-secondary)] flex-wrap">
{TIME_RANGE_PRESETS.map((p) => (
@@ -73,24 +64,24 @@ export function HostTimeRangePicker({ value, onChange }: Props) {
{p.label}
</Button>
))}
<div className="relative" ref={popRef}>
<Button
type="button"
variant="ghost"
size="sm"
className={`h-6 text-[11px] px-2 rounded gap-1 ${value.preset === 'custom' ? 'dg-btn-primary shadow-sm' : 'dg-text-secondary hover:dg-text-primary'}`}
onClick={() => setCustomOpen((v) => !v)}
>
<Calendar className="h-3 w-3" />
{t('monitor.lbl.custom')}
<ChevronDown className={`h-3 w-3 transition-transform ${customOpen ? 'rotate-180' : ''}`} />
</Button>
{customOpen && (
<div className="absolute right-0 top-full mt-1 z-50 w-72 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 shadow-lg">
<p className="text-xs font-medium text-[var(--dg-text-primary)] mb-2"> {t('monitor.lbl.unlimited')} </p>
<Popover open={customOpen} onOpenChange={setCustomOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={`h-6 text-[11px] px-2 rounded gap-1 ${value.preset === 'custom' ? 'dg-btn-primary shadow-sm' : 'dg-text-secondary hover:dg-text-primary'}`}
>
<Calendar className="h-3 w-3" />
自定义
<ChevronDown className={`h-3 w-3 transition-transform ${customOpen ? 'rotate-180' : ''}`} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={4} className="z-[100] w-72 rounded-lg border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 shadow-lg">
<p className="text-xs font-medium text-[var(--dg-text-primary)] mb-2"> {i18n.t('monitor.hosttimerangepi.s0122e022')} </p>
<div className="space-y-2">
<div>
<label className="text-[10px] dg-text-muted"> {t('common.start')} </label>
<label className="text-[10px] dg-text-muted"> {i18n.t('common.start')} </label>
<Input
type="datetime-local"
value={customStart}
@@ -99,7 +90,7 @@ export function HostTimeRangePicker({ value, onChange }: Props) {
/>
</div>
<div>
<label className="text-[10px] dg-text-muted"> {t('monitor.lbl.unlimited')} </label>
<label className="text-[10px] dg-text-muted"> {i18n.t('monitor.hosttimerangepi.s12f1d7ef')} </label>
<Input
type="datetime-local"
value={customEnd}
@@ -110,19 +101,18 @@ export function HostTimeRangePicker({ value, onChange }: Props) {
</div>
<div className="flex justify-end gap-2 mt-3">
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setCustomOpen(false)}>
{t('common.cancel')}
取消
</Button>
<Button type="button" size="sm" className="h-7 text-xs dg-btn-primary" onClick={applyCustom}>
{t('monitor.lbl.apply')}
应用
</Button>
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
<span className="text-[10px] dg-text-muted tabular-nums hidden md:inline">
{formatRangeSummary(value)}
</span>
</div>
);
}
}
@@ -33,6 +33,8 @@ export function MetricChartCard({
const [zoomed, setZoomed] = useState(false);
const chartData = data.map((d) => ({ ...d, value: d[aggType] }));
const shortTitle = title.replace(/\(.+\)/, '').trim();
// 当前值可能已包含 %、GB、KB/s 等单位,避免卡片重复拼接。 // Logan.Li
const displayUnit = /[%a-zA-Z/]/.test(value) ? '' : unit;
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
@@ -52,7 +54,7 @@ export function MetricChartCard({
</div>
<div className="mb-2 pl-10">
<span className="text-xl font-bold text-[var(--dg-text-primary)] tabular-nums">{value}</span>
{unit && <span className="text-xs text-[var(--dg-text-muted)] ml-1">{unit}</span>}
{displayUnit && <span className="text-xs text-[var(--dg-text-muted)] ml-1">{displayUnit}</span>}
</div>
<ZoomableAreaChart
ref={chartRef}
@@ -62,7 +64,7 @@ export function MetricChartCard({
unit={unit}
chartId={safeChartId(title)}
title={title}
subtitle={`${t('monitor.metricChart.currentPrefix')} ${value}${unit ? ` ${unit}` : ''}`}
subtitle={`${t('monitor.metricChart.currentPrefix')} ${value}${displayUnit ? ` ${displayUnit}` : ''}`}
height={188}
toolbarPlacement="header"
onZoomedChange={setZoomed}
@@ -71,4 +73,4 @@ export function MetricChartCard({
</CardContent>
</Card>
);
}
}
@@ -1,7 +1,7 @@
// 端口策略对话框 // Logan.Li
import { Button } from '@/components/ui/button';
import { parsePortList } from '@/utils/validate';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
@@ -27,42 +27,41 @@ export function PortPolicyDialog({
onSave,
saving,
}: PortPolicyDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]"> {t('monitor.portPolicyDialog.title')} </DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]"> {t('monitor.portPolicyDialog.desc')} </DialogDescription>
<DialogTitle className="text-[var(--dg-text-primary)]"> {i18n.t('monitor.portpolicydialo.s868e3dd0')} </DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.portpolicydialo.scd27ad47')} </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block"> {t('monitor.portPolicyDialog.filterMode')} </label>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block"> {i18n.t('monitor.portpolicydialo.s7824bdd1')} </label>
<div className="flex gap-2">
<button
className={`flex-1 h-9 rounded text-xs font-medium border transition-colors ${portPolicyMode === 'whitelist' ? 'bg-blue-500/20 border-blue-500 dg-text-blue' : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
onClick={() => onModeChange('whitelist')}
>
{t('monitor.lbl.whitelistFullReport')}
白名单 ─ 全量上报
</button>
<button
className={`flex-1 h-9 rounded text-xs font-medium border transition-colors ${portPolicyMode === 'blacklist' ? 'bg-amber-500/20 border-amber-500 dg-text-yellow' : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
onClick={() => onModeChange('blacklist')}
>
{t('monitor.lbl.blacklistSelectedOnly')}
黑名单 ─ 仅上报选中
</button>
</div>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1.5 leading-relaxed">
{portPolicyMode === 'whitelist'
? t('monitor.lbl.allPortsReport')
: t('monitor.lbl.selectedOnlyReport')}
? i18n.t('monitor.portpolicydialo.sf5cd9d7c')
: i18n.t('monitor.portpolicydialo.sf3bf3319')}
</p>
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block">
{t('monitor.lbl.portListLabel')} <span className="text-[var(--dg-text-muted)]">{t('monitor.lbl.portListHint')}</span>
{portPolicyMode === 'whitelist' && <span className="dg-text-blue ml-1">{t('monitor.lbl.whitelistNoConfig')}</span>}
端口列表 <span className="text-[var(--dg-text-muted)]">(逗号分隔,如: 80,443,3306,6379)</span>
{portPolicyMode === 'whitelist' && <span className="dg-text-blue ml-1">— 白名单模式无需配置</span>}
</label>
<textarea
className={`w-full h-20 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 py-1.5 text-xs font-mono resize-none ${
@@ -74,24 +73,24 @@ export function PortPolicyDialog({
const val = e.target.value;
onWhitelistChange(parsePortList(val));
}}
placeholder={portPolicyMode === 'whitelist' ? t('monitor.lbl.whitelistAutoReport') : '80,443,3306,6379'}
placeholder={portPolicyMode === 'whitelist' ? i18n.t('monitor.portpolicydialo.s51b8453b') : '80,443,3306,6379'}
readOnly={portPolicyMode === 'whitelist'}
/>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">
{t('monitor.lbl.portsEntered', { count: portWhitelist.length })}
已输入 {portWhitelist.length} 个端口
</p>
</div>
<div className="bg-amber-500/5 border border-amber-500/20 rounded p-2.5">
<p className="text-[10px] text-amber-300/80 leading-relaxed">
{t('monitor.portPolicyDialog.systemProcessDesc', { neverReport: t('monitor.portPolicyDialog.neverReport') })}
⚠️ 无论选择哪种模式,以下系统进程端口<b> {i18n.t('monitor.portpolicydialo.s2ff12d51')} </b>:sshd、rpcbind、master、systemd、dbus-daemon、rsyslogd 等。Agent 采集时自动跳过。
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => onOpenChange(false)}> {t('rbac.jobRole.cancel')} </Button>
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => onOpenChange(false)}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={onSave} disabled={saving}>
{saving ? t('monitor.silenceDialog.saving') : t('monitor.lbl.saving')}
{saving ? i18n.t('monitor.silenceDialog.saving') : i18n.t('monitor.portpolicydialo.s2d3f52b4')}
</Button>
</DialogFooter>
</DialogContent>
@@ -13,7 +13,7 @@ import {
import { ChartToolbar } from './ChartToolbar';
import { MonitorChartTooltip } from '../shared/monitorChartTheme';
import { ChartRangeHint } from '../shared/ChartRangeHint';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
export interface ChartSeries {
dataKey: string;
@@ -192,7 +192,6 @@ export const ZoomableAreaChart = forwardRef<ZoomableChartHandle, Props>(function
onZoomedChange,
referenceLines,
}, ref) {
const { t } = useTranslation();
const uid = useId().replace(/:/g, '');
const gradPrefix = `grad-${chartId}-${uid}`;
@@ -209,7 +208,6 @@ export const ZoomableAreaChart = forwardRef<ZoomableChartHandle, Props>(function
}, [data, xDataKey]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setZoomRange(null);
setRefLeft('');
setRefRight('');
@@ -286,7 +284,7 @@ export const ZoomableAreaChart = forwardRef<ZoomableChartHandle, Props>(function
className="flex items-center justify-center text-xs dg-text-muted border border-dashed border-[var(--dg-border)] rounded-md w-full"
style={{ height }}
>
{t('monitor.lbl.noData')}
暂无数据
</div>
);
}
@@ -317,11 +315,11 @@ export const ZoomableAreaChart = forwardRef<ZoomableChartHandle, Props>(function
<DialogContent className={dialogClass}>
<DialogHeader className="shrink-0 flex-row items-start justify-between gap-4 space-y-0 pr-10">
<div className="min-w-0">
<DialogTitle className="text-base truncate">{title || t('monitor.lbl.metricDetail')}</DialogTitle>
<DialogTitle className="text-base truncate">{title || i18n.t('monitor.zoomableareacha.sdaf53351')}</DialogTitle>
{subtitle && (
<p className="text-xs dg-text-muted mt-0.5 truncate">{subtitle}</p>
)}
<DialogDescription className="sr-only"> {t('monitor.lbl.selectHost')} </DialogDescription>
<DialogDescription className="sr-only"> {i18n.t('monitor.zoomableareacha.s365f8fe8')} </DialogDescription>
</div>
<ChartToolbar
size="md"
@@ -4,11 +4,11 @@ import i18n from '@/i18n';
/** 预设时间范围(分钟粒度,支持 15/30 分钟) // Logan.Li */
export const TIME_RANGE_PRESETS = [
{ id: '15m', label: i18n.t('monitor.traceMgr.min15'), minutes: 15, buckets: 30 },
{ id: '30m', label: i18n.t('monitor.hostMonitorTypes.systemInfo'), minutes: 30, buckets: 36 },
{ id: '30m', label: i18n.t('monitor.typests.s751a79af'), minutes: 30, buckets: 36 },
{ id: '1h', label: i18n.t('monitor.traceMgr.hour1'), minutes: 60, buckets: 60 },
{ id: '6h', label: i18n.t('monitor.traceMgr.hour6'), minutes: 360, buckets: 72 },
{ id: '12h', label: i18n.t('monitor.hostMonitorTypes.systemInfo'), minutes: 720, buckets: 72 },
{ id: '1d', label: i18n.t('monitor.hostMonitorTypes.systemInfo'), minutes: 1440, buckets: 96 },
{ id: '12h', label: i18n.t('monitor.typests.sd04708fe'), minutes: 720, buckets: 72 },
{ id: '1d', label: i18n.t('monitor.typests.s7db888de'), minutes: 1440, buckets: 96 },
{ id: '7d', label: i18n.t('monitor.traceMgr.day7'), minutes: 10080, buckets: 84 },
] as const;
@@ -70,13 +70,6 @@ export interface ProbeForm {
description: string;
}
/** 百分比参考线常量(80%/90%)用于 CPU/内存/磁盘等图表 */
export interface RefLine { value: number; label: string; color: string; }
export const PCT_REF_LINES: RefLine[] = [
{ value: 80, label: '80%', color: 'var(--dg-yellow)' },
{ value: 90, label: '90%', color: 'var(--dg-red)' },
];
export const DEFAULT_PROBE_FORM = (hostname = ''): ProbeForm => ({
name: '',
hostname,
@@ -1,5 +1,4 @@
// 主机监控工具函数 // Logan.Li
import i18n from '@/i18n';
import type { BucketPoint, HostMonitorTimeRange } from './types';
import type { JsonData } from '@/types/common';
/** 按时间跨度格式化 X 轴标签(避免 7 天范围只显示时分) // Logan.Li */
@@ -176,7 +175,7 @@ export function getActualDataSpan(history: JsonData[], _range: HostMonitorTimeRa
const min = Math.min(...ts);
const max = Math.max(...ts);
const spanMin = Math.max(1, Math.round((max - min) / 60));
return i18n.t('monitor.lbl.pointCount', { min: formatAxisTime(min, spanMin), max: formatAxisTime(max, spanMin), count: history.length });
return `${formatAxisTime(min, spanMin)} — ${formatAxisTime(max, spanMin)} · ${history.length} 点`;
}
export function getCurrentVal(latest: JsonData, netInfo: JsonData, diskIOInfo: JsonData, swapInfo: JsonData, fdInfo: JsonData,
@@ -10,7 +10,7 @@ import { Loader2 } from 'lucide-react';
import { middlewareMonitorApi } from '@/api';
import { toast } from 'sonner';
import type { MwTarget } from './helpers';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface Props {
open: boolean;
@@ -20,7 +20,6 @@ interface Props {
}
export function MiddlewareDialog({ open, onOpenChange, editTarget, onSuccess }: Props) {
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
name: '', type: 'redis' as 'redis' | 'mysql', ip: '', port: 6379,
@@ -47,7 +46,7 @@ export function MiddlewareDialog({ open, onOpenChange, editTarget, onSuccess }:
const handleSubmit = async () => {
if (!form.name.trim() || !form.ip.trim()) {
toast.error(t('monitor.lbl.nameIpRequired'));
toast.error(i18n.t('monitor.middlewaredialo.s0fa2360e'));
return;
}
setSaving(true);
@@ -64,15 +63,15 @@ export function MiddlewareDialog({ open, onOpenChange, editTarget, onSuccess }:
}
if (editTarget) {
await middlewareMonitorApi.update(editTarget.id, payload);
toast.success(t('monitor.lbl.updateSuccess'));
toast.success(i18n.t('monitor.middlewaredialo.s55aa6366'));
} else {
await middlewareMonitorApi.create(payload);
toast.success(t('monitor.dashboard.custom.toastCreateSuccess'));
toast.success(i18n.t('monitor.dashboard.custom.toastCreateSuccess'));
}
onOpenChange(false);
onSuccess();
} catch (err) {
toast.error(editTarget ? t('kubernetes.deployment.imageUpdateFailed') : t('monitor.dashboard.custom.toastCreateFailed'), { description: (err as { message?: string }).message });
toast.error(editTarget ? i18n.t('kubernetes.deployment.imageUpdateFailed') : i18n.t('monitor.dashboard.custom.toastCreateFailed'), { description: (err as { message?: string }).message });
} finally {
setSaving(false);
}
@@ -82,12 +81,12 @@ export function MiddlewareDialog({ open, onOpenChange, editTarget, onSuccess }:
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md">
<DialogHeader>
<DialogTitle>{editTarget ? t('monitor.lbl.editMw') : t('monitor.lbl.addMw')}</DialogTitle>
<DialogDescription> {t('monitor.lbl.instances')} </DialogDescription>
<DialogTitle>{editTarget ? i18n.t('monitor.middlewaredialo.sc38ddef8') : i18n.t('monitor.middlewaredialo.sa24241b6')}</DialogTitle>
<DialogDescription> {i18n.t('monitor.middlewaredialo.s6ee71754')} </DialogDescription>
</DialogHeader>
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
<div className="space-y-2">
<Label> {t('system.credentialType')} </Label>
<Label> {i18n.t('system.credentialType')} </Label>
<div className="flex gap-2">
{(['redis', 'mysql'] as const).map((t) => (
<button key={t} type="button" onClick={() => setForm((f) => ({ ...f, type: t, port: t === 'redis' ? 6379 : 3306 }))}
@@ -99,9 +98,9 @@ export function MiddlewareDialog({ open, onOpenChange, editTarget, onSuccess }:
</div>
{form.type === 'redis' && (
<div className="space-y-2">
<Label> {t('deploy.form.deployMode')} </Label>
<Label> {i18n.t('deploy.form.deployMode')} </Label>
<div className="flex gap-2">
{[{ value: 'standalone' as const, label: t('monitor.lbl.standalone') }, { value: 'cluster' as const, label: t('k8s.topology.typeCluster') }].map((opt) => (
{[{ value: 'standalone' as const, label: i18n.t('monitor.helpersts.s047499fc') }, { value: 'cluster' as const, label: i18n.t('k8s.topology.typeCluster') }].map((opt) => (
<button key={opt.value} type="button" onClick={() => setForm((f) => ({ ...f, redis_mode: opt.value }))}
className={`flex-1 py-2 rounded-lg text-sm ${form.redis_mode === opt.value ? 'bg-blue-600 text-white' : 'bg-[var(--dg-bg-tertiary)]'}`}>
{opt.label}
@@ -111,7 +110,7 @@ export function MiddlewareDialog({ open, onOpenChange, editTarget, onSuccess }:
</div>
)}
<div className="space-y-2">
<Label> {t('deploy.form.nameStar')} </Label>
<Label> {i18n.t('deploy.form.nameStar')} </Label>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
</div>
<div className="grid grid-cols-3 gap-2">
@@ -120,32 +119,32 @@ export function MiddlewareDialog({ open, onOpenChange, editTarget, onSuccess }:
<Input value={form.ip} onChange={(e) => setForm((f) => ({ ...f, ip: e.target.value }))} />
</div>
<div className="space-y-2">
<Label> {t('system.ldapPort')} </Label>
<Label> {i18n.t('system.ldapPort')} </Label>
<Input type="number" value={form.port} onChange={(e) => setForm((f) => ({ ...f, port: Number(e.target.value) }))} />
</div>
</div>
<div className="space-y-2">
<Label>{t('common.password')} {editTarget && <span className="text-xs text-muted-foreground">{t('common.passwordOptional')}</span>}</Label>
<Label>密码 {editTarget && <span className="text-xs text-muted-foreground">(留空不修改)</span>}</Label>
<Input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
</div>
{form.type === 'mysql' && (
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label> {t('system.userName')} </Label>
<Label> {i18n.t('system.userName')} </Label>
<Input value={form.username} onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))} />
</div>
<div className="space-y-2">
<Label> {t('k8s.topology.typeDatabase')} </Label>
<Label> {i18n.t('k8s.topology.typeDatabase')} </Label>
<Input value={form.database} onChange={(e) => setForm((f) => ({ ...f, database: e.target.value }))} />
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> {t('rbac.jobRole.cancel')} </Button>
<Button variant="outline" onClick={() => onOpenChange(false)}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button onClick={handleSubmit} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editTarget ? t('task.script.save') : t('rbac.jobRole.create')}
{editTarget ? i18n.t('task.script.save') : i18n.t('rbac.jobRole.create')}
</Button>
</DialogFooter>
</DialogContent>
@@ -10,7 +10,7 @@ import type { MwTarget } from './helpers';
import { fmtHitRate, fmtVal } from './helpers';
import { ChartExpandShell } from '../shared/ChartExpandShell';
import { MonitorChartTooltip, MONITOR_PIE_STROKE, MonitorPieTooltip } from '../shared/monitorChartTheme';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface Props {
targets: MwTarget[];
@@ -21,40 +21,39 @@ function shortName(name: string) {
}
export function MwOverviewCharts({ targets }: Props) {
const { t } = useTranslation();
const statusData = useMemo(() => {
const online = targets.filter((tg) => tg.status === 'online').length;
const online = targets.filter((t) => t.status === 'online').length;
const offline = targets.length - online;
return [
{ name: t('agent.agentOnline'), value: online, fill: 'var(--dg-green)' },
{ name: t('agent.agentOffline'), value: offline, fill: 'var(--dg-red)' },
{ name: i18n.t('agent.agentOnline'), value: online, fill: 'var(--dg-green)' },
{ name: i18n.t('agent.agentOffline'), value: offline, fill: 'var(--dg-red)' },
].filter((d) => d.value > 0);
}, [targets, t]);
}, [targets]);
const redisHitData = useMemo(() => targets
.filter((tg) => tg.description === 'redis' && tg.latest_metrics?.redis_hit_rate > 0)
.map((tg) => ({
name: shortName(tg.name), fullName: tg.name,
value: (tg.latest_metrics.redis_hit_rate || 0) * 100,
fill: (tg.latest_metrics.redis_hit_rate || 0) >= 0.9 ? 'var(--dg-green)' : 'var(--dg-yellow)',
.filter((t) => t.description === 'redis' && t.latest_metrics?.redis_hit_rate > 0)
.map((t) => ({
name: shortName(t.name), fullName: t.name,
value: (t.latest_metrics.redis_hit_rate || 0) * 100,
fill: (t.latest_metrics.redis_hit_rate || 0) >= 0.9 ? 'var(--dg-green)' : 'var(--dg-yellow)',
}))
.sort((a, b) => a.value - b.value), [targets]);
const redisQpsData = useMemo(() => targets
.filter((tg) => tg.description === 'redis' && tg.latest_metrics?.redis_qps > 0)
.map((tg) => ({ name: shortName(tg.name), fullName: tg.name, value: tg.latest_metrics.redis_qps, fill: 'var(--dg-blue)' }))
.filter((t) => t.description === 'redis' && t.latest_metrics?.redis_qps > 0)
.map((t) => ({ name: shortName(t.name), fullName: t.name, value: t.latest_metrics.redis_qps, fill: 'var(--dg-blue)' }))
.sort((a, b) => b.value - a.value), [targets]);
const mysqlConnData = useMemo(() => targets
.filter((tg) => tg.description === 'mysql' && tg.latest_metrics?.mysql_connections > 0)
.map((tg) => ({ name: shortName(tg.name), fullName: tg.name, value: tg.latest_metrics.mysql_connections, fill: 'var(--dg-blue)' }))
.filter((t) => t.description === 'mysql' && t.latest_metrics?.mysql_connections > 0)
.map((t) => ({ name: shortName(t.name), fullName: t.name, value: t.latest_metrics.mysql_connections, fill: 'var(--dg-blue)' }))
.sort((a, b) => b.value - a.value), [targets]);
if (targets.length === 0) return null;
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
<OverviewChartCard title={t('monitor.lbl.instanceStatus')} icon={<PieChartIcon className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={statusData.length === 0}>
<OverviewChartCard title={i18n.t('monitor.mwoverviewchart.s2eeb27f9')} icon={<PieChartIcon className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={statusData.length === 0}>
{(h) => (
<ResponsiveContainer width="100%" height={h}>
<PieChart>
@@ -68,13 +67,13 @@ export function MwOverviewCharts({ targets }: Props) {
)}
</OverviewChartCard>
<OverviewChartCard title={t('common.online')} icon={<Percent className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={redisHitData.length === 0}>
<OverviewChartCard title={i18n.t('monitor.mwoverviewchart.s29d98d86')} icon={<Percent className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={redisHitData.length === 0}>
{(h) => (
<ResponsiveContainer width="100%" height={h}>
<BarChart data={redisHitData} layout="vertical" margin={{ left: 0, right: 8 }}>
<XAxis type="number" domain={[0, 100]} tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} tickFormatter={(v) => `${v}%`} />
<YAxis type="category" dataKey="name" width={64} tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} tickLine={false} axisLine={false} />
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => fmtHitRate(v / 100)} nameFormatter={() => t('monitor.lbl.hitRate')} />} />
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => fmtHitRate(v / 100)} nameFormatter={() => i18n.t('monitor.mwtargetchartsp.s6787355b')} />} />
<Bar dataKey="value" radius={[0, 3, 3, 0]} maxBarSize={14} isAnimationActive={false}>
{redisHitData.map((e) => <Cell key={e.fullName} fill={e.fill} />)}
</Bar>
@@ -98,13 +97,13 @@ export function MwOverviewCharts({ targets }: Props) {
)}
</OverviewChartCard>
<OverviewChartCard title={t('monitor.lbl.mysqlConnections')} icon={<BarChart3 className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={mysqlConnData.length === 0}>
<OverviewChartCard title={i18n.t('monitor.mwoverviewchart.s07d3e014')} icon={<BarChart3 className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={mysqlConnData.length === 0}>
{(h) => (
<ResponsiveContainer width="100%" height={h}>
<BarChart data={mysqlConnData} layout="vertical" margin={{ left: 0, right: 8 }}>
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} />
<YAxis type="category" dataKey="name" width={64} tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} tickLine={false} axisLine={false} />
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => fmtVal(v)} nameFormatter={() => t('monitor.lbl.connections')} />} />
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => fmtVal(v)} nameFormatter={() => i18n.t('monitor.mwoverviewchart.s30f7dd4e')} />} />
<Bar dataKey="value" radius={[0, 3, 3, 0]} maxBarSize={14} isAnimationActive={false}>
{mysqlConnData.map((e) => <Cell key={e.fullName} fill={e.fill} />)}
</Bar>
@@ -18,7 +18,7 @@ import { MwTrendChart } from './MwTrendChart';
import { MwGaugeBar } from './MwGaugeBar';
import { MwHealthStrip } from './MwHealthStrip';
import { ChartExpandShell } from '../shared/ChartExpandShell';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface Props {
target: MwTarget | null;
@@ -27,7 +27,6 @@ interface Props {
}
export function MwTargetChartsPanel({ target, chartDefs, accent }: Props) {
const { t } = useTranslation();
const [timeRange, setTimeRange] = useState<MwTimeRange>('6h');
const [historyMap, setHistoryMap] = useState<Record<string, Array<{ timestamp: number; value: number }>>>({});
const [loading, setLoading] = useState(false);
@@ -67,7 +66,7 @@ export function MwTargetChartsPanel({ target, chartDefs, accent }: Props) {
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-full">
<CardContent className="p-8 text-center text-sm text-[var(--dg-text-muted)]">
{t('monitor.lbl.selectInstanceHint')}
选择左侧实例查看趋势与健康度
</CardContent>
</Card>
);
@@ -84,28 +83,28 @@ export function MwTargetChartsPanel({ target, chartDefs, accent }: Props) {
<div className="flex items-center justify-between gap-2 mb-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<Activity className="h-4 w-4 text-[var(--dg-text-muted)] shrink-0" />
<h3 className="text-sm font-medium truncate">{t('common.observable')} · {target.name}</h3>
<h3 className="text-sm font-medium truncate">可观测 · {target.name}</h3>
<Badge variant="outline" className="text-[10px] shrink-0">{target.ip}:{target.port}</Badge>
{accent === 'redis' && (
<Badge variant="outline" className="text-[10px] shrink-0">{redisModeLabel(target.config_public?.redis_mode)}</Badge>
)}
<span className="text-[10px] text-[var(--dg-text-muted)] shrink-0">{t('common.updated')} {fmtLastSeen(target.last_seen_at)}</span>
<span className="text-[10px] text-[var(--dg-text-muted)] shrink-0">更新 {fmtLastSeen(target.last_seen_at)}</span>
</div>
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setShowAll((v) => !v)}>
{showAll ? t('monitor.lbl.coreMetrics') : t('monitor.lbl.allMetrics')}
{showAll ? i18n.t('monitor.mwtargetchartsp.s9e75621e') : i18n.t('monitor.mwtargetchartsp.s9dea79ec')}
</Button>
<Select value={timeRange} onValueChange={(v) => setTimeRange(v as MwTimeRange)}>
<SelectTrigger className="h-7 w-[92px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1h"> {t('monitor.helpers.last1h')} </SelectItem>
<SelectItem value="6h"> {t('monitor.siteProtocol.rangeDefault')} </SelectItem>
<SelectItem value="24h"> {t('monitor.helpers.last24h')} </SelectItem>
<SelectItem value="7d"> {t('monitor.helpers.last7d')} </SelectItem>
<SelectItem value="1h"> {i18n.t('monitor.helpersts.sf972b7b1')} </SelectItem>
<SelectItem value="6h"> {i18n.t('monitor.siteProtocol.rangeDefault')} </SelectItem>
<SelectItem value="24h"> {i18n.t('monitor.helpersts.s6e99b786')} </SelectItem>
<SelectItem value="7d"> {i18n.t('monitor.helpersts.sd765efc4')} </SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={loadCharts} disabled={loading}>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />{t("common.refresh")}
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
</Button>
</div>
</div>
@@ -117,47 +116,47 @@ export function MwTargetChartsPanel({ target, chartDefs, accent }: Props) {
{accent === 'redis' && (
<>
<MwGaugeBar
label={t('monitor.lbl.hitRate')}
label={i18n.t('monitor.mwtargetchartsp.s6787355b')}
value={(m.redis_hit_rate || 0) * 100}
display={fmtHitRate(m.redis_hit_rate)}
level={m.redis_hit_rate > 0 ? gaugeToneLow((m.redis_hit_rate || 0) * 100, 90, 80) : 'ok'}
/>
<MwGaugeBar
label={t('monitor.lbl.hitRate')}
label={i18n.t('monitor.mwtargetchartsp.s44cdb3f7')}
value={memPct ?? (m.redis_used_memory > 0 ? 50 : 0)}
display={fmtMemory(m.redis_used_memory)}
level={memPct != null ? gaugeTone(memPct, 80, 90) : 'ok'}
hint={memPct != null ? t('monitor.lbl.usedHint', { value: `${memPct.toFixed(0)}%` }) : t('monitor.lbl.noMaxMemory')}
hint={memPct != null ? `已用 ${memPct.toFixed(0)}%` : i18n.t('monitor.mwtargetchartsp.s1c1ce805')}
/>
<MwGaugeBar
label={t('kubernetes.topology.connections')}
label={i18n.t('kubernetes.topology.connections')}
value={Math.min(100, (m.redis_connected_clients || 0) / 2)}
display={fmtVal(m.redis_connected_clients)}
level={(m.redis_blocked_clients || 0) > 0 ? 'warn' : 'ok'}
hint={m.redis_cluster_known_nodes ? t('monitor.lbl.clusterNodesHint', { value: fmtVal(m.redis_cluster_known_nodes) }) : undefined}
hint={m.redis_cluster_known_nodes ? `集群 ${fmtVal(m.redis_cluster_known_nodes)} 节点` : undefined}
/>
</>
)}
{accent === 'mysql' && (
<>
<MwGaugeBar
label={t('monitor.lbl.hitRate')}
label={i18n.t('monitor.mwtargetchartsp.sa2d02e56')}
value={(m.mysql_buffer_hit_rate || 0) * 100}
display={fmtHitRate(m.mysql_buffer_hit_rate)}
level={m.mysql_buffer_hit_rate > 0 ? gaugeToneLow((m.mysql_buffer_hit_rate || 0) * 100, 95, 90) : 'ok'}
/>
<MwGaugeBar
label={t('monitor.lbl.hitRate')}
label={i18n.t('monitor.mwtargetchartsp.se84e4bc5')}
value={connPct ?? 0}
display={`${fmtVal(m.mysql_connections)} / ${fmtVal(m.mysql_max_connections)}`}
level={connPct != null ? gaugeTone(connPct, 80, 90) : 'ok'}
/>
<MwGaugeBar
label={t('monitor.lbl.hitRate')}
label={i18n.t('monitor.mwtargetchartsp.se0f6abb7')}
value={Math.min(100, (m.mysql_threads_running || 0) * 5)}
display={fmtVal(m.mysql_threads_running)}
level={(m.mysql_threads_running || 0) > 20 ? 'warn' : 'ok'}
hint={t('monitor.lbl.slowQueriesHint', { value: fmtVal(m.mysql_slow_queries) })}
hint={`慢查询 ${fmtVal(m.mysql_slow_queries)}`}
/>
</>
)}
@@ -168,23 +167,23 @@ export function MwTargetChartsPanel({ target, chartDefs, accent }: Props) {
{accent === 'redis' ? (
<>
<SummaryTile label="QPS" value={fmtVal(m.redis_qps, 1)} />
<SummaryTile label={t('monitor.lbl.fragmentationRate')} value={fmtVal(m.redis_db_size)} />
<SummaryTile label={t('monitor.lbl.fragmentationRate')} value={fmtVal(m.redis_evicted_keys)} warn={(m.redis_evicted_keys || 0) > 0} />
<SummaryTile label={t('monitor.lbl.evictedKeys')} value={m.redis_mem_fragmentation_ratio ? fmtVal(m.redis_mem_fragmentation_ratio, 2) : '--'} />
<SummaryTile label={i18n.t('monitor.mwtargetchartsp.s196eef1f')} value={fmtVal(m.redis_db_size)} />
<SummaryTile label={i18n.t('monitor.mwtargetchartsp.s0a5e7a61')} value={fmtVal(m.redis_evicted_keys)} warn={(m.redis_evicted_keys || 0) > 0} />
<SummaryTile label={i18n.t('monitor.mwtargetchartsp.s3bc8dd1f')} value={m.redis_mem_fragmentation_ratio ? fmtVal(m.redis_mem_fragmentation_ratio, 2) : '--'} />
</>
) : (
<>
<SummaryTile label="QPS" value={fmtVal(m.mysql_qps, 1)} />
<SummaryTile label={t('monitor.lbl.trafficSent')} value={fmtMemory(m.mysql_bytes_received)} />
<SummaryTile label={t('monitor.lbl.trafficSent')} value={fmtMemory(m.mysql_bytes_sent)} />
<SummaryTile label={t('agent.agentUptime')} value={m.mysql_uptime ? `${Math.floor(m.mysql_uptime / 3600)}h` : '--'} />
<SummaryTile label={i18n.t('monitor.mwtargetchartsp.s3e54c81c')} value={fmtMemory(m.mysql_bytes_received)} />
<SummaryTile label={i18n.t('monitor.mwtargetchartsp.s90d6afb7')} value={fmtMemory(m.mysql_bytes_sent)} />
<SummaryTile label={i18n.t('agent.agentUptime')} value={m.mysql_uptime ? `${Math.floor(m.mysql_uptime / 3600)}h` : '--'} />
</>
)}
</div>
{loading ? (
<div className="py-10 flex items-center justify-center text-sm text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-2" />{t("common.loadTrend")}
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载趋势...
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
@@ -1,6 +1,5 @@
// 中间件指标趋势图 — 光标拖拽选区 // Logan.Li
import { useMemo } from 'react';
import i18n from '@/i18n';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
ReferenceArea,
@@ -42,7 +41,7 @@ export function MwTrendChart({
if (chartData.length === 0) {
return (
<div className="flex items-center justify-center text-xs text-[var(--dg-text-muted)]" style={{ height }}>
{i18n.t('monitor.lbl.noTrendData')}
暂无趋势数据
</div>
);
}
@@ -59,20 +59,20 @@ export const MYSQL_PRIMARY_KEYS = ['mysql_qps', 'mysql_connections', 'mysql_buff
export const REDIS_CHARTS: MwChartDef[] = [
{ key: 'redis_qps', label: 'QPS', color: 'var(--dg-blue)', unit: 'ops/s' },
{ key: 'redis_used_memory', label: i18n.t('monitor.lbl.mwMemUsage'), color: 'var(--dg-red)', formatter: fmtMemory },
{ key: 'redis_hit_rate', label: i18n.t('monitor.lbl.hitRate'), color: 'var(--dg-green)', percent: true },
{ key: 'redis_used_memory', label: i18n.t('monitor.mwtargetchartsp.s44cdb3f7'), color: 'var(--dg-red)', formatter: fmtMemory },
{ key: 'redis_hit_rate', label: i18n.t('monitor.mwtargetchartsp.s6787355b'), color: 'var(--dg-green)', percent: true },
{ key: 'redis_connected_clients', label: i18n.t('kubernetes.topology.connections'), color: 'var(--dg-cyan)' },
{ key: 'redis_db_size', label: i18n.t('monitor.lbl.mwSlowQueryRate'), color: 'var(--dg-purple)' },
{ key: 'redis_blocked_clients', label: i18n.t('monitor.lbl.mwBlockedClients'), color: 'var(--dg-orange)' },
{ key: 'redis_db_size', label: i18n.t('monitor.helpersts.sc5f65eec'), color: 'var(--dg-purple)' },
{ key: 'redis_blocked_clients', label: i18n.t('monitor.helpersts.s2ad27e85'), color: 'var(--dg-orange)' },
];
export const MYSQL_CHARTS: MwChartDef[] = [
{ key: 'mysql_qps', label: 'QPS', color: 'var(--dg-blue)', unit: 'q/s' },
{ key: 'mysql_connections', label: i18n.t('kubernetes.topology.connections'), color: 'var(--dg-cyan)' },
{ key: 'mysql_threads_running', label: i18n.t('monitor.lbl.mwActiveThreads'), color: 'var(--dg-purple)' },
{ key: 'mysql_buffer_hit_rate', label: i18n.t('monitor.lbl.mwBufferHitRate'), color: 'var(--dg-green)', percent: true },
{ key: 'mysql_slow_queries', label: i18n.t('monitor.lbl.mwSlowQueryRate'), color: 'var(--dg-orange)', unit: i18n.t('monitor.lbl.mwPerCycle'), counterDelta: true },
{ key: 'mysql_bytes_sent', label: i18n.t('monitor.lbl.mwBufferHitRate'), color: 'var(--dg-amber)', formatter: fmtMemory },
{ key: 'mysql_threads_running', label: i18n.t('monitor.mwtargetchartsp.se0f6abb7'), color: 'var(--dg-purple)' },
{ key: 'mysql_buffer_hit_rate', label: i18n.t('monitor.mwtargetchartsp.sa2d02e56'), color: 'var(--dg-green)', percent: true },
{ key: 'mysql_slow_queries', label: i18n.t('monitor.helpersts.sbd73bca3'), color: 'var(--dg-orange)', unit: i18n.t('monitor.helpersts.s3878fe68'), counterDelta: true },
{ key: 'mysql_bytes_sent', label: i18n.t('monitor.mwtargetchartsp.s90d6afb7'), color: 'var(--dg-amber)', formatter: fmtMemory },
];
export function fmtMemory(bytes: number): string {
@@ -139,7 +139,7 @@ export function statusBg(status: string): string {
}
export function redisModeLabel(mode?: string): string {
return mode === 'cluster' ? i18n.t('k8s.topology.typeCluster') : i18n.t('monitor.lbl.mwCluster');
return mode === 'cluster' ? i18n.t('k8s.topology.typeCluster') : i18n.t('monitor.helpersts.s047499fc');
}
export function redisMemoryPct(metrics: Record<string, number>): number | null {
@@ -209,27 +209,27 @@ export function evalMwHealth(target: MwTarget): MwHealthIssue[] {
const m = target.latest_metrics || {};
const issues: MwHealthIssue[] = [];
if (target.status !== 'online') {
issues.push({ level: 'crit', message: i18n.t('monitor.lbl.mwInstanceOffline') });
issues.push({ level: 'crit', message: i18n.t('monitor.helpersts.s4ace6502') });
return issues;
}
if (target.description === 'redis') {
if (m.redis_hit_rate > 0 && m.redis_hit_rate < 0.8) issues.push({ level: 'crit', message: i18n.t('monitor.lbl.mwHitRateBelow80') });
else if (m.redis_hit_rate > 0 && m.redis_hit_rate < 0.9) issues.push({ level: 'warn', message: i18n.t('monitor.lbl.mwHitRateBelow90') });
if (m.redis_hit_rate > 0 && m.redis_hit_rate < 0.8) issues.push({ level: 'crit', message: i18n.t('monitor.helpersts.sff1d979b') });
else if (m.redis_hit_rate > 0 && m.redis_hit_rate < 0.9) issues.push({ level: 'warn', message: i18n.t('monitor.helpersts.s11902306') });
const memPct = redisMemoryPct(m);
if (memPct != null && memPct > 90) issues.push({ level: 'crit', message: i18n.t('monitor.lbl.mwMemAbove90') });
else if (memPct != null && memPct > 80) issues.push({ level: 'warn', message: i18n.t('monitor.lbl.mwMemAbove80') });
if (m.redis_blocked_clients > 0) issues.push({ level: 'warn', message: i18n.t('monitor.lbl.mwBlockedClients') });
if (m.redis_evicted_keys > 0) issues.push({ level: 'warn', message: i18n.t('monitor.lbl.mwKeyEviction') });
if (memPct != null && memPct > 90) issues.push({ level: 'crit', message: i18n.t('monitor.helpersts.s07eee3b9') });
else if (memPct != null && memPct > 80) issues.push({ level: 'warn', message: i18n.t('monitor.helpersts.sbb098add') });
if (m.redis_blocked_clients > 0) issues.push({ level: 'warn', message: i18n.t('monitor.helpersts.s2ad27e85') });
if (m.redis_evicted_keys > 0) issues.push({ level: 'warn', message: i18n.t('monitor.helpersts.sc4db73a4') });
} else if (target.description === 'mysql') {
if (m.mysql_buffer_hit_rate > 0 && m.mysql_buffer_hit_rate < 0.9) {
issues.push({ level: 'crit', message: i18n.t('monitor.lbl.mwBufferHitBelow90') });
issues.push({ level: 'crit', message: i18n.t('monitor.helpersts.s99844184') });
} else if (m.mysql_buffer_hit_rate > 0 && m.mysql_buffer_hit_rate < 0.95) {
issues.push({ level: 'warn', message: i18n.t('monitor.lbl.mwBufferHitBelow95') });
issues.push({ level: 'warn', message: i18n.t('monitor.helpersts.s41ec17d6') });
}
const connPct = mysqlConnPct(m);
if (connPct != null && connPct > 90) issues.push({ level: 'crit', message: i18n.t('monitor.lbl.mwConnAbove90') });
else if (connPct != null && connPct > 80) issues.push({ level: 'warn', message: i18n.t('monitor.lbl.mwConnAbove80') });
if (m.mysql_threads_running > 20) issues.push({ level: 'warn', message: i18n.t('monitor.lbl.mwTooManyThreads') });
if (connPct != null && connPct > 90) issues.push({ level: 'crit', message: i18n.t('monitor.helpersts.s5b95d546') });
else if (connPct != null && connPct > 80) issues.push({ level: 'warn', message: i18n.t('monitor.helpersts.se0b952ab') });
if (m.mysql_threads_running > 20) issues.push({ level: 'warn', message: i18n.t('monitor.helpersts.s56174d14') });
}
return issues;
}
@@ -245,8 +245,8 @@ export function fmtLastSeen(iso?: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '--';
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
if (diff < 60) return i18n.t('common.duration.secAgo', { count: diff });
if (diff < 3600) return i18n.t('common.duration.minAgo', { count: Math.floor(diff / 60) });
if (diff < 86400) return i18n.t('common.duration.hourAgo', { count: Math.floor(diff / 3600) });
if (diff < 60) return `${diff}s 前`;
if (diff < 3600) return `${Math.floor(diff / 60)}m 前`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h 前`;
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
@@ -1,7 +1,7 @@
// 图表选区提示 — 仅缩放后显示恢复入口 // Logan.Li
import { ZoomOut } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface Props {
label: string | null;
@@ -10,13 +10,12 @@ interface Props {
}
export function ChartRangeHint({ label, zoomed, onReset }: Props) {
const { t } = useTranslation();
if (!zoomed) return null;
return (
<div className="flex items-center justify-between text-[10px] text-[var(--dg-text-muted)] mt-1 px-0.5">
<span> {t('monitor.chartRangeHint.selectedRange')} <span className="text-[var(--dg-text-primary)] font-mono">{label}</span></span>
<span> {i18n.t('monitor.chartrangehintt.s39ce9bad')} <span className="text-[var(--dg-text-primary)] font-mono">{label}</span></span>
<Button type="button" variant="ghost" size="sm" className="h-6 text-[10px] px-2" onClick={onReset}>
<ZoomOut className="h-3 w-3 mr-1" />{t("common.restoreFull")}
<ZoomOut className="h-3 w-3 mr-1" />恢复全量
</Button>
</div>
);
@@ -20,7 +20,6 @@ export function useChartRangeZoom<T extends Record<string, unknown>>(
}, [data, xKey]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setZoomRange(null);
setRefLeft('');
setRefRight('');
@@ -6,7 +6,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { PieChart as PieChartIcon } from 'lucide-react';
import { PROBE_TYPE_OPTIONS, normalizeProbeType } from './helpers';
import type { SiteTask } from './helpers';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface Props {
tasks: SiteTask[];
@@ -21,7 +21,6 @@ const PROTOCOL_COLORS: Record<string, string> = {
};
export function SiteProtocolPieChart({ tasks }: Props) {
const { t } = useTranslation();
const protocolData = useMemo(() => {
const counts: Record<string, number> = {};
for (const t of PROBE_TYPE_OPTIONS) counts[t] = 0;
@@ -37,7 +36,7 @@ export function SiteProtocolPieChart({ tasks }: Props) {
if (tasks.length === 0) {
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-6 text-center text-sm text-[var(--dg-text-muted)]"> {t('monitor.lbl.availability24h')} </CardContent>
<CardContent className="p-6 text-center text-sm text-[var(--dg-text-muted)]"> {i18n.t('monitor.siteprotocolpie.s0589e591')} </CardContent>
</Card>
);
}
@@ -47,8 +46,8 @@ export function SiteProtocolPieChart({ tasks }: Props) {
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<PieChartIcon className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]"> {t('monitor.lbl.availability24h')} </h3>
<span className="text-[10px] text-[var(--dg-text-muted)]">{t('common.itemsCount', { count: tasks.length })}</span>
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]"> {i18n.t('monitor.siteprotocolpie.s20721c88')} </h3>
<span className="text-[10px] text-[var(--dg-text-muted)]">共 {tasks.length} 个任务</span>
</div>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
@@ -19,7 +19,6 @@ import {
SITE_TIME_RANGE_HOURS, SITE_TIME_RANGE_LABELS,
} from './helpers';
import { SiteProtocolObs } from './observability/SiteProtocolObs';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface Props {
@@ -49,7 +48,6 @@ function httpCodeBadge(code?: number, expected = 200) {
}
export function SiteResultsPanel({ task, onProbed, fullPage = false }: Props) {
const { t } = useTranslation();
const [results, setResults] = useState<SiteResult[]>([]);
const [loading, setLoading] = useState(false);
const [probing, setProbing] = useState(false);
@@ -79,11 +77,11 @@ export function SiteResultsPanel({ task, onProbed, fullPage = false }: Props) {
setProbing(true);
try {
await siteApi.probeNow(task.id);
toast.success(t('monitor.lbl.probeDone'));
toast.success(i18n.t('monitor.siteresultspane.sb8649f2b'));
loadResults();
onProbed();
} catch (e) {
toast.error((e as { message?: string }).message || t('monitor.lbl.probeFail'));
toast.error((e as { message?: string }).message || i18n.t('monitor.siteresultspane.s3f60d8f1'));
} finally {
setProbing(false);
}
@@ -98,7 +96,7 @@ export function SiteResultsPanel({ task, onProbed, fullPage = false }: Props) {
if (!task) {
const empty = (
<div className="p-8 text-center text-sm text-[var(--dg-text-muted)]">
{t('monitor.lbl.selectTaskHint')}
选择任务查看协议专属可观测指标
</div>
);
return (
@@ -119,41 +117,41 @@ export function SiteResultsPanel({ task, onProbed, fullPage = false }: Props) {
case 'HTTP':
return (
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-xs"> {t('system.auditTime')} </TableHead>
<TableHead className="text-xs"> {t('system.terminalStatus')} </TableHead>
<TableHead className="text-xs"> {t('monitor.siteProtocol.http.statusCodeName')} </TableHead>
<TableHead className="text-xs"> {t('monitor.siteProtocol.latency.latencyName')} </TableHead>
<TableHead className="text-xs"> {t('cloud.colDescription')} </TableHead>
<TableHead className="text-xs"> {i18n.t('system.auditTime')} </TableHead>
<TableHead className="text-xs"> {i18n.t('system.terminalStatus')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.siteProtocol.http.statusCodeName')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.siteProtocol.latency.latencyName')} </TableHead>
<TableHead className="text-xs"> {i18n.t('cloud.colDescription')} </TableHead>
</TableRow>
);
case 'SSL':
return (
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-xs"> {t('system.auditTime')} </TableHead>
<TableHead className="text-xs"> {t('monitor.lbl.editTask')} </TableHead>
<TableHead className="text-xs"> {t('monitor.lbl.editTask')} </TableHead>
<TableHead className="text-xs"> {t('monitor.siteProtocol.ssl.daysName')} </TableHead>
<TableHead className="text-xs"> {t('monitor.siteProtocol.ssl.issuer')} </TableHead>
<TableHead className="text-xs"> {i18n.t('system.auditTime')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.siteresultspane.s3f04bb50')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.siteresultspane.s499b9a62')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.siteProtocol.ssl.daysName')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.siteProtocol.ssl.issuer')} </TableHead>
</TableRow>
);
case 'TCP':
return (
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-xs"> {t('system.auditTime')} </TableHead>
<TableHead className="text-xs"> {t('monitor.siteProtocol.tcp.connected')} </TableHead>
<TableHead className="text-xs"> {t('monitor.siteProtocol.latency.latencyName')} </TableHead>
<TableHead className="text-xs"> {t('monitor.lbl.editTask')} </TableHead>
<TableHead className="text-xs"> {t('cloud.colDescription')} </TableHead>
<TableHead className="text-xs"> {i18n.t('system.auditTime')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.siteProtocol.tcp.connected')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.siteProtocol.latency.latencyName')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.siteresultspane.sa7d1436b')} </TableHead>
<TableHead className="text-xs"> {i18n.t('cloud.colDescription')} </TableHead>
</TableRow>
);
default:
return (
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-xs"> {t('system.auditTime')} </TableHead>
<TableHead className="text-xs"> {t('system.terminalStatus')} </TableHead>
<TableHead className="text-xs"> {t('monitor.siteProtocol.latency.latencyName')} </TableHead>
<TableHead className="text-xs"> {t('monitor.lbl.editTask')} </TableHead>
<TableHead className="text-xs"> {t('cloud.colDescription')} </TableHead>
<TableHead className="text-xs"> {i18n.t('system.auditTime')} </TableHead>
<TableHead className="text-xs"> {i18n.t('system.terminalStatus')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.siteProtocol.latency.latencyName')} </TableHead>
<TableHead className="text-xs"> {i18n.t('monitor.siteresultspane.s3197ed7a')} </TableHead>
<TableHead className="text-xs"> {i18n.t('cloud.colDescription')} </TableHead>
</TableRow>
);
}
@@ -190,7 +188,7 @@ export function SiteResultsPanel({ task, onProbed, fullPage = false }: Props) {
<TableCell>{statusBadge(r.status)}</TableCell>
<TableCell className="text-xs">{fmtMs(r.response_time || r.total_time)}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] truncate max-w-[120px]">{r.remote_ip || '--'}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[160px] truncate">{r.error_message || (r.status === 'success' ? t('monitor.siteProtocol.tcp.connected') : '--')}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[160px] truncate">{r.error_message || (r.status === 'success' ? i18n.t('monitor.siteProtocol.tcp.connected') : '--')}</TableCell>
</TableRow>
);
default:
@@ -213,29 +211,29 @@ export function SiteResultsPanel({ task, onProbed, fullPage = false }: Props) {
{!fullPage && (
<>
<Activity className="h-4 w-4 text-[var(--dg-text-muted)] shrink-0" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{t('common.observable')} · {task.name}</h3>
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] truncate">可观测 · {task.name}</h3>
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)] shrink-0">{probeType}</Badge>
</>
)}
<span className="text-[10px] text-[var(--dg-text-muted)] shrink-0">
{rangeLabel} · {results.length} {t('monitor.lbl.times')} · {t('monitor.lbl.availabilityRate')} {availSummary}
{rangeLabel} · {results.length} 次 · 可用率 {availSummary}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Select value={timeRange} onValueChange={(v) => setTimeRange(v as SiteTimeRange)}>
<SelectTrigger className="h-7 w-[92px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1h"> {t('monitor.lbl.totalProbes')} </SelectItem>
<SelectItem value="6h"> {t('monitor.siteProtocol.rangeDefault')} </SelectItem>
<SelectItem value="24h"> {t('monitor.lbl.totalProbes')} </SelectItem>
<SelectItem value="7d"> {t('monitor.lbl.totalProbes')} </SelectItem>
<SelectItem value="1h"> {i18n.t('monitor.helpersts.sf972b7b1')} </SelectItem>
<SelectItem value="6h"> {i18n.t('monitor.siteProtocol.rangeDefault')} </SelectItem>
<SelectItem value="24h"> {i18n.t('monitor.helpersts.s6e99b786')} </SelectItem>
<SelectItem value="7d"> {i18n.t('monitor.helpersts.sd765efc4')} </SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={loadResults} disabled={loading}>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />{t("common.refresh")}
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
</Button>
<Button size="sm" className="h-7 text-xs" onClick={handleProbeNow} disabled={probing}>
{probing ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Zap className="h-3 w-3 mr-1" />}{t('monitor.lbl.probeNow')}
{probing ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Zap className="h-3 w-3 mr-1" />}立即拨测
</Button>
</div>
</div>
@@ -244,9 +242,9 @@ export function SiteResultsPanel({ task, onProbed, fullPage = false }: Props) {
<div className="mb-4 flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-xs">
<ShieldAlert className="h-4 w-4 shrink-0 mt-0.5 dg-text-yellow" />
<div>
<p className="font-medium text-[var(--dg-text-primary)]">{t('monitor.lbl.sslCertWarning')}</p>
<p className="font-medium text-[var(--dg-text-primary)]">SSL 证书预警</p>
<p className="mt-0.5 text-[var(--dg-text-muted)]">
{t('monitor.lbl.sslExpiring')} <span className="font-mono">{task.ssl_cert_expiry?.slice(0, 10) || '--'}</span>,{t('monitor.lbl.sslRemaining')} <span className={sslInfo.tone}>{sslInfo.text}</span>({t('monitor.lbl.sslThreshold')} {SSL_EXPIRY_WARN_DAYS} {t('monitor.lbl.sslDays')})
到期 <span className="font-mono">{task.ssl_cert_expiry?.slice(0, 10) || '--'}</span>,剩余 <span className={sslInfo.tone}>{sslInfo.text}</span>(阈值 {SSL_EXPIRY_WARN_DAYS} 天)
</p>
</div>
</div>
@@ -261,7 +259,7 @@ export function SiteResultsPanel({ task, onProbed, fullPage = false }: Props) {
<SiteProtocolObs task={task} results={results} rangeLabel={rangeLabel} />
<div className={`mt-4 overflow-x-auto ${fullPage ? 'max-h-80' : 'max-h-56'}`}>
{results.length === 0 ? (
<p className="text-center py-6 text-sm text-[var(--dg-text-muted)]"> {t('monitor.lbl.availability24h')} </p>
<p className="text-center py-6 text-sm text-[var(--dg-text-muted)]"> {i18n.t('monitor.siteresultspane.s592b56ff')} </p>
) : (
<Table>
<TableHeader>{renderTableHead()}</TableHeader>
@@ -57,10 +57,10 @@ export const SITE_TIME_RANGE_HOURS: Record<SiteTimeRange, number> = {
};
export const SITE_TIME_RANGE_LABELS: Record<SiteTimeRange, string> = {
'1h': i18n.t('monitor.lbl.siteRecentHour'),
'1h': i18n.t('monitor.helpersts.sf972b7b1'),
'6h': i18n.t('monitor.siteProtocol.rangeDefault'),
'24h': i18n.t('monitor.lbl.siteRecent24h'),
'7d': i18n.t('monitor.lbl.siteRecent7d'),
'24h': i18n.t('monitor.helpersts.s6e99b786'),
'7d': i18n.t('monitor.helpersts.sd765efc4'),
};
/** 各协议推荐默认拨测间隔(秒)// Logan.Li */
@@ -86,24 +86,24 @@ export function defaultProbeFrequency(type: ProbeType): number {
}
export const probeTypeMeta: Record<ProbeType, { label: string; placeholder: string; hint: string }> = {
HTTP: { label: i18n.t('monitor.lbl.siteTargetUrl'), placeholder: 'https://example.com/health', hint: i18n.t('monitor.lbl.siteHttpHint') },
SSL: { label: i18n.t('monitor.lbl.siteSslAddr'), placeholder: i18n.t('monitor.lbl.siteSslPlaceholder'), hint: i18n.t('monitor.lbl.siteSslHint') },
TCP: { label: i18n.t('monitor.lbl.siteHostPort'), placeholder: i18n.t('monitor.lbl.siteTcpPlaceholder'), hint: i18n.t('monitor.lbl.siteTcpHint') },
DNS: { label: i18n.t('cloud.domainName'), placeholder: 'example.com', hint: i18n.t('monitor.helpers.dnsHint') },
PING: { label: i18n.t('monitor.lbl.siteHostAddr'), placeholder: i18n.t('monitor.lbl.sitePingPlaceholder'), hint: i18n.t('monitor.lbl.sitePingHint') },
HTTP: { label: i18n.t('monitor.helpersts.s277c74b4'), placeholder: 'https://example.com/health', hint: i18n.t('monitor.helpersts.s953f0b93') },
SSL: { label: i18n.t('monitor.helpersts.s8e3a1ea5'), placeholder: i18n.t('monitor.helpersts.s8a6cb9ae'), hint: i18n.t('monitor.helpersts.sefe596b0') },
TCP: { label: i18n.t('monitor.helpersts.seeda3abc'), placeholder: i18n.t('monitor.helpersts.sd315f501'), hint: i18n.t('monitor.helpersts.s5a87d291') },
DNS: { label: i18n.t('cloud.domainName'), placeholder: 'example.com', hint: i18n.t('monitor.helpersts.sd3fd8f1d') },
PING: { label: i18n.t('monitor.helpersts.saeb5271e'), placeholder: i18n.t('monitor.helpersts.sd6bbd624'), hint: i18n.t('monitor.helpersts.se850c8e9') },
};
export function probeFrequencyHint(type: ProbeType): string {
const sec = defaultProbeFrequency(type);
return i18n.t('monitor.siteMonitor.recommendFreq', { freq: fmtFrequency(sec), minFreq: fmtFrequency(MIN_PROBE_FREQUENCY[type]) });
return `推荐 ${fmtFrequency(sec)},最小 ${fmtFrequency(MIN_PROBE_FREQUENCY[type])}`;
}
/** 拨测频率展示 // Logan.Li */
export function fmtFrequency(sec?: number): string {
if (!sec) return '--';
if (sec < 60) return i18n.t('monitor.siteMonitor.seconds', { count: sec });
if (sec < 3600) return i18n.t('monitor.siteMonitor.minutes', { count: Math.round(sec / 60) });
return i18n.t('monitor.siteMonitor.hours', { count: (sec / 3600).toFixed(1) });
if (sec < 60) return `${sec}秒`;
if (sec < 3600) return `${Math.round(sec / 60)}分钟`;
return `${(sec / 3600).toFixed(1)}小时`;
}
/** 可用率 / 响应时间组合展示 // Logan.Li */
@@ -147,9 +147,9 @@ export function isSslExpiringSoon(days?: number, threshold = SSL_EXPIRY_WARN_DAY
export function fmtSslDays(days?: number): { text: string; tone: string } {
if (days == null) return { text: '--', tone: 'text-[var(--dg-text-muted)]' };
if (days < 0) return { text: i18n.t('system.expired2'), tone: 'dg-text-red' };
if (days <= 7) return { text: i18n.t('monitor.siteMonitor.certDays', { count: days }), tone: 'dg-text-red' };
if (days <= 30) return { text: i18n.t('monitor.siteMonitor.certDays', { count: days }), tone: 'dg-text-yellow' };
return { text: i18n.t('monitor.siteMonitor.certDays', { count: days }), tone: 'dg-text-green' };
if (days <= 7) return { text: `${days}天`, tone: 'dg-text-red' };
if (days <= 30) return { text: `${days}天`, tone: 'dg-text-yellow' };
return { text: `${days}天`, tone: 'dg-text-green' };
}
/** 归一化拨测协议类型 // Logan.Li */
@@ -93,16 +93,16 @@ export function analyzeHttpResults(results: SiteResult[], task: SiteTask): HttpO
}
if (code >= 500) {
err5xx++;
pushEvent(events, r, `HTTP ${code}`, 'error', r.error_message || i18n.t('monitor.lbl.siteServerError'));
pushEvent(events, r, `HTTP ${code}`, 'error', r.error_message || i18n.t('monitor.analyticsts.s71de8af8'));
} else if (code >= 400) {
err4xx++;
pushEvent(events, r, `HTTP ${code}`, 'warn', r.error_message || i18n.t('monitor.lbl.siteClientError'));
pushEvent(events, r, `HTTP ${code}`, 'warn', r.error_message || i18n.t('monitor.analyticsts.sdc751cd0'));
} else if (r.status === 'timeout') {
otherErr++;
pushEvent(events, r, i18n.t('common.timeoutError'), 'error', r.error_message);
} else {
otherErr++;
pushEvent(events, r, code > 0 ? `HTTP ${code}` : i18n.t('monitor.lbl.siteRequestFailed'), 'error', r.error_message);
pushEvent(events, r, code > 0 ? `HTTP ${code}` : i18n.t('monitor.analyticsts.sf50bf418'), 'error', r.error_message);
}
}
const latest = results[0];
@@ -128,9 +128,9 @@ export function analyzeSslResults(results: SiteResult[], task: SiteTask): SslObs
const events: ProbeEvent[] = [];
for (const r of results) {
if (r.status !== 'success') {
pushEvent(events, r, i18n.t('monitor.lbl.siteRequestTimeout'), 'error', r.error_message);
pushEvent(events, r, i18n.t('monitor.analyticsts.sddbae5b7'), 'error', r.error_message);
} else if (r.ssl_days_remaining != null && r.ssl_days_remaining < 30) {
pushEvent(events, r, i18n.t('monitor.siteMonitor.certRemaining', { days: r.ssl_days_remaining }), r.ssl_days_remaining <= 7 ? 'error' : 'warn', expiry);
pushEvent(events, r, `证书剩余 ${r.ssl_days_remaining} 天`, r.ssl_days_remaining <= 7 ? 'error' : 'warn', expiry);
}
}
const chart = tooltipOrder(results)
@@ -158,10 +158,10 @@ function analyzeLatencyResults(results: SiteResult[], label: string): LatencyObs
const latency = r.response_time ?? r.total_time ?? 0;
const failed = r.status !== 'success';
const spike = !failed && latency > spikeThreshold;
if (failed) pushEvent(events, r, i18n.t('monitor.siteMonitor.checkFailed', { label }), 'error', r.error_message || r.remote_ip);
if (failed) pushEvent(events, r, `${label}失败`, 'error', r.error_message || r.remote_ip);
else if (spike) {
spikeCount++;
pushEvent(events, r, i18n.t('monitor.siteMonitor.latencyFluctuation', { label }), 'warn', fmtMs(latency));
pushEvent(events, r, `${label}延迟波动`, 'warn', fmtMs(latency));
}
return { time: fmtTime(r.checked_at).slice(-5), latency, failed, spike };
});
@@ -186,7 +186,7 @@ export function analyzeTcpResults(results: SiteResult[]): ConnectivityObsSummary
const failed = r.status !== 'success';
if (failed) {
lastFailAt = r.checked_at;
pushEvent(events, r, i18n.t('monitor.lbl.siteConnFailed'), 'error', r.error_message || i18n.t('aiops.llmTestFailed'));
pushEvent(events, r, i18n.t('monitor.analyticsts.s0614a3ce'), 'error', r.error_message || i18n.t('aiops.llmTestFailed'));
}
return { time: fmtTime(r.checked_at).slice(-5), up: failed ? 0 : 100, failed };
});
@@ -15,7 +15,7 @@ import {
SNMP_TIME_RANGE_HOURS, fmtMetricValue, isSnmpCounterMetric, metricColor, pickSeriesByCategory,
} from './helpers';
import { SnmpMetricTrendChart } from './SnmpMetricTrendChart';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface Props {
device: SnmpDevice | null;
@@ -30,7 +30,6 @@ interface ChartBlock {
}
export function SnmpDeviceChartsPanel({ device, templateName }: Props) {
const { t } = useTranslation();
const [timeRange, setTimeRange] = useState<SnmpTimeRange>('24h');
const [series, setSeries] = useState<SnmpMetricSeries[]>([]);
const [charts, setCharts] = useState<ChartBlock[]>([]);
@@ -88,7 +87,7 @@ export function SnmpDeviceChartsPanel({ device, templateName }: Props) {
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-8 text-center text-sm text-[var(--dg-text-muted)]">
{t('monitor.lbl.clickDeviceHint')}
点击设备行查看 CPU / 内存 / 温度 / 风扇趋势图
</CardContent>
</Card>
);
@@ -104,7 +103,7 @@ export function SnmpDeviceChartsPanel({ device, templateName }: Props) {
<div className="flex items-center gap-2 min-w-0">
<Activity className="h-4 w-4 text-[var(--dg-text-muted)] shrink-0" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] truncate">
{t('monitor.lbl.observability')} · {device.name}
可观测 · {device.name}
</h3>
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)] shrink-0">
{templateName}
@@ -116,23 +115,23 @@ export function SnmpDeviceChartsPanel({ device, templateName }: Props) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="6h"> {t('monitor.siteProtocol.rangeDefault')} </SelectItem>
<SelectItem value="24h"> {t('monitor.lbl.selectTemplate')} </SelectItem>
<SelectItem value="7d"> {t('monitor.lbl.selectTemplate')} </SelectItem>
<SelectItem value="6h"> {i18n.t('monitor.siteProtocol.rangeDefault')} </SelectItem>
<SelectItem value="24h"> {i18n.t('monitor.helpersts.s6e99b786')} </SelectItem>
<SelectItem value="7d"> {i18n.t('monitor.helpersts.sd765efc4')} </SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={loadCharts} disabled={loading}>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />
{t('common.refresh')}
刷新
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-3 mb-4">
{[
{ label: t('monitor.lbl.totalDevices'), value: latestCpu ? fmtMetricValue(latestCpu.latest_value, latestCpu.unit || '%') : '--' },
{ label: t('monitor.lbl.totalDevices'), value: latestMem ? fmtMetricValue(latestMem.latest_value, latestMem.unit || '%') : '--' },
{ label: t('monitor.lbl.totalDevices'), value: t('monitor.lbl.totalDevices') },
{ label: i18n.t('monitor.snmpdevicechart.s53a04150'), value: latestCpu ? fmtMetricValue(latestCpu.latest_value, latestCpu.unit || '%') : '--' },
{ label: i18n.t('monitor.snmpdevicechart.s33962aa4'), value: latestMem ? fmtMetricValue(latestMem.latest_value, latestMem.unit || '%') : '--' },
{ label: i18n.t('monitor.snmpdevicechart.s26bddc00'), value: i18n.t('monitor.snmpdevicechart.sfdef8c23') },
].map((item) => (
<div key={item.label} className="rounded-lg bg-[var(--dg-bg-tertiary)] p-3 text-center">
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
@@ -143,11 +142,11 @@ export function SnmpDeviceChartsPanel({ device, templateName }: Props) {
{loading ? (
<div className="py-12 flex items-center justify-center text-sm text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-2" />{t("common.loadTrend")}
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载趋势...
</div>
) : charts.length === 0 ? (
<div className="py-12 text-center text-sm text-[var(--dg-text-muted)]">
{t('monitor.lbl.noMetricsHint')}
暂无可视化指标,请先完成至少一次采集
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
@@ -1,6 +1,5 @@
// SNMP 指标趋势折线图 — 光标拖拽选区 // Logan.Li
import { useMemo } from 'react';
import i18n from '@/i18n';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
ReferenceArea,
@@ -31,7 +30,7 @@ export function SnmpMetricTrendChart({ data, color, unit, height = 160 }: Props)
if (chartData.length === 0) {
return (
<div className="flex items-center justify-center text-xs text-[var(--dg-text-muted)]" style={{ height }}>
{i18n.t('monitor.lbl.noTrendDataWaiting')}
暂无趋势数据,等待后台采集
</div>
);
}
@@ -9,7 +9,7 @@ import { BarChart3, PieChart as PieChartIcon } from 'lucide-react';
import type { SnmpDevice, SnmpOverviewItem } from './helpers';
import { fmtMetricValue, statusColor } from './helpers';
import { MonitorChartTooltip, MONITOR_PIE_STROKE, MonitorPieTooltip } from '../shared/monitorChartTheme';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface Props {
devices: SnmpDevice[];
@@ -17,17 +17,16 @@ interface Props {
}
export function SnmpOverviewCharts({ devices, overview }: Props) {
const { t } = useTranslation();
const statusData = useMemo(() => {
const online = devices.filter((d) => d.status === 'online').length;
const error = devices.filter((d) => d.status === 'error').length;
const offline = devices.length - online - error;
return [
{ name: t('agent.agentOnline'), value: online, fill: 'var(--dg-green)' },
{ name: t('agent.heartbeatAbnormal'), value: error, fill: 'var(--dg-red)' },
{ name: t('agent.agentOffline'), value: offline, fill: 'var(--dg-text-muted)' },
{ name: i18n.t('agent.agentOnline'), value: online, fill: 'var(--dg-green)' },
{ name: i18n.t('agent.heartbeatAbnormal'), value: error, fill: 'var(--dg-red)' },
{ name: i18n.t('agent.agentOffline'), value: offline, fill: 'var(--dg-text-muted)' },
].filter((d) => d.value > 0);
}, [devices, t]);
}, [devices]);
const cpuData = useMemo(() => {
return overview
@@ -49,10 +48,10 @@ export function SnmpOverviewCharts({ devices, overview }: Props) {
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<BarChart3 className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]"> {t('monitor.lbl.deviceCpuCompare')} </h3>
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]"> {i18n.t('monitor.snmpoverviewcha.s64f5a4da')} </h3>
</div>
{cpuData.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-8 text-center"> {t('monitor.lbl.noCpuMetrics')} </p>
<p className="text-xs text-[var(--dg-text-muted)] py-8 text-center"> {i18n.t('monitor.snmpoverviewcha.s1f441890')} </p>
) : (
<ResponsiveContainer width="100%" height={Math.max(160, cpuData.length * 36)}>
<BarChart data={cpuData} layout="vertical" margin={{ top: 0, right: 16, left: 0, bottom: 0 }}>
@@ -100,7 +99,7 @@ export function SnmpOverviewCharts({ devices, overview }: Props) {
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<PieChartIcon className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]"> {t('monitor.lbl.deviceStatus')} </h3>
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]"> {i18n.t('monitor.snmpoverviewcha.sbb0ae97f')} </h3>
</div>
<ResponsiveContainer width="100%" height={180}>
<PieChart>