feat: 全站 PageShell 设计系统与专业 UI 统一
- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架 - index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩) - 全站路由页接入 PageShell,云账单三 Tab 为标杆 - 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化 - 登录页/侧栏/顶栏去渐变光球,企业克制风 - 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
This commit is contained in:
1 parent
1acb0c49af
commit
e363ded445
213 files changed
+7148
-6818
No files matched your search
@@ -1,7 +1,6 @@
|
||||
// 审批分类通用页面 — 资源申请/应用申请/权限审批三个子页面共用
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -13,9 +12,12 @@ import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Plus, Search, RefreshCw, Clock, CheckCircle, XCircle, FileText, Loader2, Eye, Check, XIcon, User, ChevronRight, Play } from 'lucide-react';
|
||||
import { Plus, Search, RefreshCw, Clock, CheckCircle, XCircle, FileText, Eye, Check, XIcon, User, ChevronRight, Play } from 'lucide-react';
|
||||
import { approvalApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export interface FormField {
|
||||
@@ -42,9 +44,9 @@ interface ProcessItem {
|
||||
|
||||
const statusBadge = (status: string, t: (k: string) => string) => {
|
||||
switch (status) {
|
||||
case 'approved': return <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30"><CheckCircle className="h-3 w-3 mr-1" />{t('approval.status.approved')}</Badge>;
|
||||
case 'rejected': return <Badge className="bg-red-500/20 text-red-400 border-red-500/30"><XCircle className="h-3 w-3 mr-1" />{t('approval.status.rejected')}</Badge>;
|
||||
default: return <Badge className="bg-amber-500/20 text-amber-400 border-amber-500/30"><Clock className="h-3 w-3 mr-1" />{t('approval.status.pending')}</Badge>;
|
||||
case 'approved': return <Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30"><CheckCircle className="h-3 w-3 mr-1" />{t('approval.status.approved')}</Badge>;
|
||||
case 'rejected': return <Badge className="bg-red-500/20 dg-text-red border-red-500/30"><XCircle className="h-3 w-3 mr-1" />{t('approval.status.rejected')}</Badge>;
|
||||
default: return <Badge className="bg-amber-500/20 dg-text-yellow border-amber-500/30"><Clock className="h-3 w-3 mr-1" />{t('approval.status.pending')}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,50 +155,55 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
|
||||
const filtered = processes.filter((p) => !keyword || (p.title || '').toLowerCase().includes(keyword.toLowerCase()));
|
||||
|
||||
// 工具栏:搜索 + 状态筛选 // Logan.Li
|
||||
const toolbar = (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input placeholder={t('approval.flow.searchRequest')} value={keyword} onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 dg-input" />
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-32 h-9 dg-input">
|
||||
<SelectValue placeholder={t('common.all')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('common.all')}</SelectItem>
|
||||
<SelectItem value="pending">{t('approval.status.pending')}</SelectItem>
|
||||
<SelectItem value="approved">{t('approval.status.approved')}</SelectItem>
|
||||
<SelectItem value="rejected">{t('approval.status.rejected')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchProcesses}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">{title}</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{description}</p>
|
||||
</div>
|
||||
<Button onClick={openCreateDialog} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/90">
|
||||
<Plus className="h-4 w-4 mr-2" />{t('approval.newRequest')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<Input placeholder={t('approval.flow.searchRequest')} value={keyword} onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectValue placeholder={t('common.all')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('common.all')}</SelectItem>
|
||||
<SelectItem value="pending">{t('approval.status.pending')}</SelectItem>
|
||||
<SelectItem value="approved">{t('approval.status.approved')}</SelectItem>
|
||||
<SelectItem value="rejected">{t('approval.status.rejected')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)]" onClick={fetchProcesses}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-blue)]" /></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]">
|
||||
<FileText className="h-10 w-10 mb-3 opacity-30" />
|
||||
<p className="text-sm">{t('approval.noRecords')}</p>
|
||||
<Button variant="link" className="mt-2" onClick={openCreateDialog}>{t('approval.applyNow')}</Button>
|
||||
</div>
|
||||
<>
|
||||
<PageShell
|
||||
variant="list"
|
||||
title={title}
|
||||
subtitle={description}
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
toolbar={toolbar}
|
||||
loading={loading}
|
||||
actions={(
|
||||
<Button onClick={openCreateDialog} className="dg-btn-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />{t('approval.newRequest')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<ContentPanel noPadding>
|
||||
{filtered.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<FileText className="h-10 w-10" />}
|
||||
title={t('approval.noRecords')}
|
||||
action={(
|
||||
<Button variant="link" onClick={openCreateDialog}>{t('approval.applyNow')}</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -221,10 +228,10 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
<div className="flex items-center gap-1">
|
||||
{p.status === 'pending' ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-red-400 hover:bg-red-500/10" onClick={() => handleReject(p.id)} disabled={acting}>
|
||||
<Button variant="ghost" size="sm" className="h-8 dg-text-red hover:bg-red-500/10" onClick={() => handleReject(p.id)} disabled={acting}>
|
||||
<XIcon className="h-3.5 w-3.5 mr-1" />{t('common.reject')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-emerald-400 hover:bg-emerald-500/10" onClick={() => handleApprove(p.id)} disabled={acting}>
|
||||
<Button variant="ghost" size="sm" className="h-8 dg-text-green hover:bg-emerald-500/10" onClick={() => handleApprove(p.id)} disabled={acting}>
|
||||
<Check className="h-3.5 w-3.5 mr-1" />{t('common.approve')}
|
||||
</Button>
|
||||
</>
|
||||
@@ -240,8 +247,8 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ContentPanel>
|
||||
</PageShell>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
@@ -253,7 +260,7 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
<div className="space-y-4 py-2">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)] text-sm">{field.label}{field.required && <span className="text-red-400 ml-0.5">*</span>}</Label>
|
||||
<Label className="text-[var(--dg-text-primary)] text-sm">{field.label}{field.required && <span className="dg-text-red ml-0.5">*</span>}</Label>
|
||||
{field.type === 'select' ? (
|
||||
<Select value={formData[field.key] || ''} onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"><SelectValue placeholder={field.placeholder || `${t('approval.editor.pleaseSelect')}${field.label}`} /></SelectTrigger>
|
||||
@@ -272,8 +279,8 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="outline" className="border-[var(--dg-border)]" onClick={() => setDialogOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleCreate} disabled={submitting} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/90">
|
||||
{submitting ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('approval.submitting')}</> : t('approval.submitRequest')}
|
||||
<Button onClick={handleCreate} disabled={submitting} className="dg-btn-primary">
|
||||
{submitting ? t('approval.submitting') : t('approval.submitRequest')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -281,7 +288,7 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
|
||||
{/* Detail Dialog — operation timeline */}
|
||||
<DetailTimelineDialog item={detailItem} onClose={() => setDetailItem(null)} fields={fields} onStatusChange={fetchProcesses} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -326,9 +333,9 @@ function DetailTimelineDialog({ item, onClose, fields, onStatusChange }: {
|
||||
};
|
||||
|
||||
const stepStatusColor = (s: StepItem) => {
|
||||
if (s.status === 'approved') return 'text-emerald-400';
|
||||
if (s.status === 'rejected') return 'text-red-400';
|
||||
if (s.step === item.current_step) return 'text-blue-400';
|
||||
if (s.status === 'approved') return 'dg-text-green';
|
||||
if (s.status === 'rejected') return 'dg-text-red';
|
||||
if (s.step === item.current_step) return 'dg-text-blue';
|
||||
return 'text-[var(--dg-text-muted)]';
|
||||
};
|
||||
|
||||
@@ -364,7 +371,7 @@ function DetailTimelineDialog({ item, onClose, fields, onStatusChange }: {
|
||||
{/* 1. Submit node */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-7 h-7 rounded-full bg-blue-500/20 flex items-center justify-center"><User className="w-3.5 h-3.5 text-blue-400" /></div>
|
||||
<div className="w-7 h-7 rounded-full bg-blue-500/20 flex items-center justify-center"><User className="w-3.5 h-3.5 dg-text-blue" /></div>
|
||||
<div className="w-0.5 flex-1 bg-[var(--dg-border)] my-1" />
|
||||
</div>
|
||||
<div className="flex-1 pb-4">
|
||||
@@ -405,11 +412,11 @@ function DetailTimelineDialog({ item, onClose, fields, onStatusChange }: {
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center ${iconColor}`}>
|
||||
{s.status === 'approved' ? <Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
: s.status === 'rejected' ? <XIcon className="w-3.5 h-3.5 text-red-400" />
|
||||
{s.status === 'approved' ? <Check className="w-3.5 h-3.5 dg-text-green" />
|
||||
: s.status === 'rejected' ? <XIcon className="w-3.5 h-3.5 dg-text-red" />
|
||||
: isExecute
|
||||
? <Play className={`w-3.5 h-3.5 ${s.step === item.current_step ? 'text-green-400' : 'text-[var(--dg-text-muted)]'}`} />
|
||||
: <ChevronRight className={`w-3.5 h-3.5 ${s.step === item.current_step ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />}
|
||||
? <Play className={`w-3.5 h-3.5 ${s.step === item.current_step ? 'dg-text-green' : 'text-[var(--dg-text-muted)]'}`} />
|
||||
: <ChevronRight className={`w-3.5 h-3.5 ${s.step === item.current_step ? 'dg-text-blue' : 'text-[var(--dg-text-muted)]'}`} />}
|
||||
</div>
|
||||
{!isLast && <div className="w-0.5 flex-1 bg-[var(--dg-border)] my-1" />}
|
||||
</div>
|
||||
@@ -470,7 +477,7 @@ function DetailTimelineDialog({ item, onClose, fields, onStatusChange }: {
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
{item.status === 'pending' && (
|
||||
<>
|
||||
<Button variant="outline" className="border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={handleReject} disabled={acting}>
|
||||
<Button variant="outline" className="border-red-500/30 dg-text-red hover:bg-red-500/10" onClick={handleReject} disabled={acting}>
|
||||
<XIcon className="h-4 w-4 mr-1.5" />{t('common.reject')}
|
||||
</Button>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700" onClick={handleApprove} disabled={acting}>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Button } from '@/components/ui/button';
|
||||
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';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
@@ -53,9 +55,9 @@ interface StepDraft {
|
||||
}
|
||||
|
||||
const STEP_TYPE_COLORS: Record<string, string> = {
|
||||
submit: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||
audit: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
|
||||
execute: 'bg-green-500/15 text-green-400 border-green-500/30',
|
||||
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> = {
|
||||
@@ -114,9 +116,9 @@ function UserSelectInline({
|
||||
{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 text-blue-400">
|
||||
<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:text-red-400"><X className="w-3 h-3" /></button>
|
||||
<button onClick={() => onToggle(uid)} className="hover:dg-text-red"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -128,11 +130,11 @@ function UserSelectInline({
|
||||
<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] text-blue-400 hover:text-blue-300">
|
||||
<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] text-red-400 hover:text-red-300">{t('common.clear')}</button>
|
||||
<button onClick={onClear} className="text-[11px] dg-text-red hover:dg-text-red">{t('common.clear')}</button>
|
||||
)}
|
||||
</div>
|
||||
{users.map((u) => {
|
||||
@@ -333,7 +335,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-5 space-y-4 pt-4">
|
||||
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
|
||||
{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>
|
||||
@@ -362,7 +364,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
<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 text-blue-400 hover:bg-blue-500/20 rounded transition-colors">
|
||||
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>
|
||||
))}
|
||||
@@ -397,7 +399,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
<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:text-red-400 transition-colors">
|
||||
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>
|
||||
@@ -541,47 +543,40 @@ export default function ApprovalWorkflowPage() {
|
||||
[users]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]">
|
||||
<p className="text-sm">{error}</p>
|
||||
<Button variant="outline" onClick={fetchWorkflows} className="mt-3">{t('approval.retry')}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--dg-text-primary)]">{t('approval.workflowTitle')}</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
|
||||
{t('approval.workflowSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setEditorWorkflow(null)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />{t('approval.newWorkflow')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="dg-card p-12 text-center">
|
||||
<GitBranch className="w-12 h-12 mx-auto mb-3 opacity-50 text-[var(--dg-text-muted)]" />
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mb-4">{t('approval.noWorkflows')}</p>
|
||||
<Button variant="outline" onClick={() => setEditorWorkflow(null)}>{t('approval.createFirst')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Workflow list */}
|
||||
<>
|
||||
<PageShell
|
||||
variant="list"
|
||||
title={t('approval.workflowTitle')}
|
||||
subtitle={t('approval.workflowSubtitle')}
|
||||
icon={<GitBranch className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
actions={(
|
||||
<Button className="dg-btn-primary" onClick={() => setEditorWorkflow(null)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />{t('approval.newWorkflow')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<PageEmpty
|
||||
title={error}
|
||||
action={(
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={fetchWorkflows}>
|
||||
{t('approval.retry')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<GitBranch className="w-12 h-12" />}
|
||||
title={t('approval.noWorkflows')}
|
||||
action={(
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={() => setEditorWorkflow(null)}>
|
||||
{t('approval.createFirst')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((wf) => {
|
||||
const isExpanded = expandedId === wf.id;
|
||||
@@ -592,11 +587,11 @@ export default function ApprovalWorkflowPage() {
|
||||
<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 text-yellow-400 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 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{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 text-blue-400">
|
||||
<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>
|
||||
@@ -604,7 +599,7 @@ export default function ApprovalWorkflowPage() {
|
||||
<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] || 'bg-gray-500/15 text-gray-400 border-gray-500/30'}`}>
|
||||
<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 && (
|
||||
@@ -622,21 +617,21 @@ export default function ApprovalWorkflowPage() {
|
||||
</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:text-yellow-400 transition-colors" title={t('approval.msg.setDefaultSuccess')}>
|
||||
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 text-green-400 hover:text-red-400' : 'hover:bg-green-500/10 text-red-400 hover:text-green-400'}`}
|
||||
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:text-blue-400 transition-colors" title={t('common.edit')}>
|
||||
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:text-red-400 transition-colors" title={t('common.delete')}>
|
||||
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>
|
||||
@@ -654,21 +649,21 @@ export default function ApprovalWorkflowPage() {
|
||||
<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] || 'bg-gray-500/15 text-gray-400 border-gray-500/30'}`}>
|
||||
<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 text-blue-400">
|
||||
<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] text-red-400">{t('approval.detail.noApproverLinked')}</span>
|
||||
<span className="text-[11px] dg-text-red">{t('approval.detail.noApproverLinked')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -681,8 +676,8 @@ export default function ApprovalWorkflowPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</PageShell>
|
||||
|
||||
{editorWorkflow !== undefined && (
|
||||
<WorkflowEditorModal
|
||||
@@ -692,6 +687,6 @@ export default function ApprovalWorkflowPage() {
|
||||
onSaved={() => { setEditorWorkflow(undefined); fetchWorkflows(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
X,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
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';
|
||||
@@ -106,11 +111,11 @@ export function AuditOperationsTab() {
|
||||
|
||||
const getLogResultBadge = (result: OperationLog['result']) => {
|
||||
return result === 'success' ? (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">
|
||||
{t('common.success')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
|
||||
{t('common.failed')}
|
||||
</Badge>
|
||||
);
|
||||
@@ -158,84 +163,80 @@ export function AuditOperationsTab() {
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
const totalHeight = virtualizer.getTotalSize();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder={t('audit.operations.searchPlaceholder')}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
const successCount = filteredLogs.filter(l => l.result === 'success').length;
|
||||
const failCount = filteredLogs.filter(l => l.result === 'failure').length;
|
||||
|
||||
{/* Module filter */}
|
||||
<select
|
||||
value={moduleFilter}
|
||||
onChange={(e) => setModuleFilter(e.target.value)}
|
||||
className="h-9 px-3 text-sm rounded-md bg-slate-800/50 border border-slate-700 text-slate-300 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">{t('audit.operations.allModules')}</option>
|
||||
{modules.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Action filter */}
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
className="h-9 px-3 text-sm rounded-md bg-slate-800/50 border border-slate-700 text-slate-300 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">{t('audit.operations.allActions')}</option>
|
||||
{actions.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => refetchLogs()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t('common.export')}
|
||||
</Button>
|
||||
const toolbar = (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
placeholder={t('audit.operations.searchPlaceholder')}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 dg-input"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={moduleFilter}
|
||||
onChange={(e) => setModuleFilter(e.target.value)}
|
||||
className="h-9 px-3 text-sm rounded-md dg-input"
|
||||
>
|
||||
<option value="">{t('audit.operations.allModules')}</option>
|
||||
{modules.map((m) => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
className="h-9 px-3 text-sm rounded-md dg-input"
|
||||
>
|
||||
<option value="">{t('audit.operations.allActions')}</option>
|
||||
{actions.map((a) => (
|
||||
<option key={a.value} value={a.value}>{a.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Logs Table — 虚拟滚动 */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Search className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">{t('audit.operations.noData')}</p>
|
||||
</div>
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title={t('audit.operations.title', { defaultValue: '操作审计' })}
|
||||
subtitle={t('audit.operations.subtitle', { defaultValue: '系统操作日志筛选、导出与详情查看' })}
|
||||
icon={<ClipboardList className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
metrics={(
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: t('audit.operations.allModules'), value: filteredLogs.length, color: 'blue' },
|
||||
{ label: t('common.success'), value: successCount, color: 'emerald' },
|
||||
{ label: t('common.failed'), value: failCount, color: 'red' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
toolbar={toolbar}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchLogs()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
|
||||
</Button>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={handleExport}>
|
||||
<Download className="h-4 w-4 mr-2" />{t('common.export')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<ContentPanel noPadding>
|
||||
<div>
|
||||
{!loading && filteredLogs.length === 0 ? (
|
||||
<PageEmpty icon={<Search className="h-8 w-8" />} title={t('audit.operations.noData')} />
|
||||
) : (
|
||||
<>
|
||||
{/* 表头 */}
|
||||
<div
|
||||
className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400 sticky top-0 z-10"
|
||||
className="grid gap-2 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-xs font-medium dg-text-secondary sticky top-0 z-10"
|
||||
style={{ gridTemplateColumns: GRID_COLS }}
|
||||
>
|
||||
<span>{t('audit.operations.col.time')}</span>
|
||||
@@ -269,28 +270,28 @@ export function AuditOperationsTab() {
|
||||
gridTemplateColumns: GRID_COLS,
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
className="px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50"
|
||||
className="px-4 py-2.5 border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"
|
||||
>
|
||||
<span className="text-sm text-slate-400 truncate">
|
||||
<span className="text-sm dg-text-secondary truncate">
|
||||
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-slate-200 truncate">{log.username || '--'}</span>
|
||||
<span className="text-sm text-slate-300 truncate">{log.module || '--'}</span>
|
||||
<span className="text-sm text-[var(--dg-text-primary)] truncate">{log.username || '--'}</span>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] truncate">{log.module || '--'}</span>
|
||||
<div>
|
||||
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
|
||||
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10">
|
||||
{log.action || '--'}
|
||||
</Badge>
|
||||
</div>
|
||||
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded truncate">
|
||||
<code className="text-xs dg-text-secondary bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded truncate">
|
||||
{log.resource || '--'}
|
||||
</code>
|
||||
<span className="text-sm text-slate-400 truncate">{log.ip || '--'}</span>
|
||||
<span className="text-sm dg-text-secondary truncate">{log.ip || '--'}</span>
|
||||
<div>{getLogResultBadge(log.result)}</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
||||
className="h-8 w-8 dg-text-secondary hover:dg-text-blue hover:bg-[var(--dg-bg-hover)]"
|
||||
onClick={() => viewDetail(log)}
|
||||
title={t('audit.operations.viewDetail')}
|
||||
>
|
||||
@@ -304,14 +305,13 @@ export function AuditOperationsTab() {
|
||||
</div>
|
||||
|
||||
{/* 行数统计 */}
|
||||
<div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800">
|
||||
<div className="text-center py-2 text-xs dg-text-muted border-t border-[var(--dg-border)]">
|
||||
{t('audit.operations.totalLogs', { count: filteredLogs.length })} {filteredLogs.length > 200 && `· ${t('audit.operations.virtualScroll')}`}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ContentPanel>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<LogDetailDialog log={detailLog} onClose={() => setDetailLog(null)} />
|
||||
@@ -323,7 +323,7 @@ export function AuditOperationsTab() {
|
||||
loading={statsLoading}
|
||||
stats={statistics}
|
||||
/>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -338,13 +338,13 @@ function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose:
|
||||
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 bg-slate-900 border border-slate-800 rounded-lg p-6 max-w-lg w-full mx-4 shadow-2xl">
|
||||
<div className="relative dg-card rounded-lg p-6 max-w-lg w-full mx-4 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-100">{t('audit.operations.detailTitle')}</h3>
|
||||
<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 text-slate-400 hover:text-slate-100"
|
||||
className="h-7 w-7 dg-text-secondary hover:text-[var(--dg-text-primary)]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -368,8 +368,8 @@ function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose:
|
||||
variant="outline"
|
||||
className={
|
||||
log.result === 'success'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
? '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')}
|
||||
@@ -382,7 +382,7 @@ function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose:
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="dg-btn-secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('common.close')}
|
||||
@@ -396,8 +396,8 @@ function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose:
|
||||
function DetailRow({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-slate-500 w-20 flex-shrink-0">{label}</span>
|
||||
<span className={mono ? 'text-slate-300 font-mono text-xs break-all' : 'text-slate-200 break-all'}>
|
||||
<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>
|
||||
@@ -425,13 +425,13 @@ function StatisticsDialog({
|
||||
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 bg-slate-900 border border-slate-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||
<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-slate-100">{t('audit.operations.statsTitle')}</h3>
|
||||
<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 text-slate-400 hover:text-slate-100"
|
||||
className="h-7 w-7 dg-text-secondary hover:text-[var(--dg-text-primary)]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -453,12 +453,12 @@ function StatisticsDialog({
|
||||
|
||||
{stats.top_modules && stats.top_modules.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-400 mb-2">{t('audit.operations.topModules')}</h4>
|
||||
<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-slate-300">{m.module}</span>
|
||||
<span className="text-slate-500">{m.count}</span>
|
||||
<span className="text-[var(--dg-text-secondary)]">{m.module}</span>
|
||||
<span className="dg-text-muted">{m.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -467,12 +467,12 @@ function StatisticsDialog({
|
||||
|
||||
{stats.top_users && stats.top_users.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-400 mb-2">{t('audit.operations.topUsers')}</h4>
|
||||
<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-slate-300">{u.username}</span>
|
||||
<span className="text-slate-500">{u.count}</span>
|
||||
<span className="text-[var(--dg-text-secondary)]">{u.username}</span>
|
||||
<span className="dg-text-muted">{u.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -480,13 +480,13 @@ function StatisticsDialog({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-center py-10 text-sm">{t('audit.operations.noStats')}</p>
|
||||
<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="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="dg-btn-secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('common.close')}
|
||||
@@ -499,13 +499,13 @@ function StatisticsDialog({
|
||||
|
||||
function StatCard({ label, value, color }: { label: string; value: string; color: 'blue' | 'emerald' | 'red' }) {
|
||||
const colorMap = {
|
||||
blue: 'border-blue-500/20 bg-blue-500/5 text-blue-400',
|
||||
emerald: 'border-emerald-500/20 bg-emerald-500/5 text-emerald-400',
|
||||
red: 'border-red-500/20 bg-red-500/5 text-red-400',
|
||||
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 text-slate-500">{label}</div>
|
||||
<div className="text-xs dg-text-muted">{label}</div>
|
||||
<div className="text-xl font-bold mt-0.5">{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// 登录日志 Tab — 用户登录记录列表(参考 opsx-web-lite)
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
|
||||
import { RefreshCw, LogIn, Monitor, Globe } from 'lucide-react';
|
||||
import { loginLogApi } from '@/api';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
interface LoginLog {
|
||||
@@ -85,7 +87,7 @@ export function LoginLogsTab() {
|
||||
{
|
||||
key: 'login_type', header: t('audit.login.col.loginType'), width: 100,
|
||||
render: (r) => (
|
||||
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10 text-xs">
|
||||
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10 text-xs">
|
||||
{getLoginTypeLabel(r.login_type)}
|
||||
</Badge>
|
||||
),
|
||||
@@ -119,54 +121,64 @@ export function LoginLogsTab() {
|
||||
{
|
||||
key: 'status', header: t('audit.login.col.status'), sortable: true, width: 80, align: 'center',
|
||||
render: (r) => r.status === 'success' ? (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">{t('common.success')}</Badge>
|
||||
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">{t('common.success')}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10" title={r.fail_reason || undefined}>
|
||||
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10" title={r.fail_reason || undefined}>
|
||||
{r.fail_reason || t('common.failed')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Status Filter + Refresh */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
{[
|
||||
{ value: '', label: t('audit.login.all') },
|
||||
{ value: 'success', label: t('common.success') },
|
||||
{ value: 'fail', label: t('common.failed') },
|
||||
].map(({ value, label }) => (
|
||||
<Button
|
||||
key={value || 'all'}
|
||||
variant={statusFilter === value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={
|
||||
statusFilter === value
|
||||
? 'h-8 bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}
|
||||
onClick={() => setStatusFilter(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
onClick={fetchLogs}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
const successCount = logs.filter(l => l.status === 'success').length;
|
||||
const failCount = logs.filter(l => l.status === 'fail').length;
|
||||
|
||||
{/* DataTable */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
const toolbar = (
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
{[
|
||||
{ value: '', label: t('audit.login.all') },
|
||||
{ value: 'success', label: t('common.success') },
|
||||
{ value: 'fail', label: t('common.failed') },
|
||||
].map(({ value, label }) => (
|
||||
<Button
|
||||
key={value || 'all'}
|
||||
variant={statusFilter === value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={statusFilter === value ? 'h-8 dg-btn-primary' : 'h-8 dg-btn-secondary'}
|
||||
onClick={() => setStatusFilter(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title={t('audit.login.title', { defaultValue: '登录日志' })}
|
||||
subtitle={t('audit.login.subtitle', { defaultValue: '用户登录记录与失败原因' })}
|
||||
icon={<LogIn className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
metrics={(
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: t('audit.login.all'), value: logs.length, color: 'blue' },
|
||||
{ label: t('common.success'), value: successCount, color: 'emerald' },
|
||||
{ label: t('common.failed'), value: failCount, color: 'red' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
toolbar={toolbar}
|
||||
actions={(
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchLogs}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<ContentPanel>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={logs}
|
||||
@@ -189,8 +201,7 @@ export function LoginLogsTab() {
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ContentPanel>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -2,48 +2,35 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Activity, Monitor, Search } from 'lucide-react';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { ActiveSessionsPanel } from './terminal-audit/ActiveSessionsPanel';
|
||||
import { HistorySessionsPanel } from './terminal-audit/HistorySessionsPanel';
|
||||
import { SessionCommandsPanel } from './terminal-audit/SessionCommandsPanel';
|
||||
import type { SubTabId } from './terminal-audit/types';
|
||||
|
||||
const subTabIds: SubTabId[] = ['active', 'history', 'commands'];
|
||||
const subTabIcons: Record<SubTabId, React.ElementType> = {
|
||||
active: Activity,
|
||||
history: Monitor,
|
||||
commands: Search,
|
||||
};
|
||||
|
||||
export function TerminalAuditTab() {
|
||||
const { t } = useTranslation();
|
||||
const [subTab, setSubTab] = useState<SubTabId>('active');
|
||||
const [userToggledOff, setUserToggledOff] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] p-1 rounded-lg border border-[var(--dg-border)] w-fit">
|
||||
{subTabIds.map((id) => {
|
||||
const Icon = subTabIcons[id];
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setSubTab(id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all',
|
||||
subTab === id
|
||||
? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] shadow-sm'
|
||||
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{t(`audit.terminal.tab.${id}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
const pageTabs = subTabIds.map((id) => ({
|
||||
id,
|
||||
label: t(`audit.terminal.tab.${id}`),
|
||||
}));
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title={t('audit.terminal.title', { defaultValue: '终端审计' })}
|
||||
subtitle={t('audit.terminal.subtitle', { defaultValue: '活跃会话、历史记录与命令审计' })}
|
||||
icon={<Terminal className="h-5 w-5" />}
|
||||
tabs={pageTabs}
|
||||
activeTab={subTab}
|
||||
onTabChange={(id) => setSubTab(id as SubTabId)}
|
||||
>
|
||||
{subTab === 'active' && (
|
||||
<ActiveSessionsPanel
|
||||
userToggledOff={userToggledOff}
|
||||
@@ -52,6 +39,6 @@ export function TerminalAuditTab() {
|
||||
)}
|
||||
{subTab === 'history' && <HistorySessionsPanel />}
|
||||
{subTab === 'commands' && <SessionCommandsPanel />}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -53,29 +53,29 @@ function ActiveSessionRow({
|
||||
const elapsed = useElapsedTimer(session.start_time);
|
||||
|
||||
return (
|
||||
<TableRow className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{session.id}</span>
|
||||
<span className="text-sm dg-text-secondary">{session.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{session.username || '--'}</span>
|
||||
<span className="text-sm text-[var(--dg-text-primary)]">{session.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-slate-200">{session.server_name || '--'}</span>
|
||||
<span className="text-xs text-slate-500">{session.server_ip}</span>
|
||||
<span className="text-sm text-[var(--dg-text-primary)]">{session.server_name || '--'}</span>
|
||||
<span className="text-xs dg-text-muted">{session.server_ip}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{session.server_port}</span>
|
||||
<span className="text-sm dg-text-secondary">{session.server_port}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
<span className="text-sm dg-text-secondary">
|
||||
{session.start_time ? new Date(session.start_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-emerald-400 font-mono">{formatDuration(elapsed)}</span>
|
||||
<span className="text-sm dg-text-green font-mono">{formatDuration(elapsed)}</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(session.status)}</TableCell>
|
||||
<TableCell>
|
||||
@@ -83,7 +83,7 @@ function ActiveSessionRow({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400 hover:bg-slate-800"
|
||||
className="h-8 w-8 dg-text-secondary hover:dg-text-red hover:bg-[var(--dg-bg-hover)]"
|
||||
onClick={() => onKill(session.id, session.username)}
|
||||
title={t('audit.terminal.killTitle')}
|
||||
>
|
||||
@@ -92,7 +92,7 @@ function ActiveSessionRow({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
||||
className="h-8 w-8 dg-text-secondary hover:dg-text-blue hover:bg-[var(--dg-bg-hover)]"
|
||||
onClick={() => onMonitor(session.id)}
|
||||
title={t('audit.terminal.liveMonitor')}
|
||||
>
|
||||
@@ -165,17 +165,17 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
placeholder={t('audit.terminal.searchUserServer')}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
className="pl-9 h-9 dg-input"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
className="dg-btn-secondary"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
@@ -186,7 +186,7 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
|
||||
className={
|
||||
autoRefresh
|
||||
? 'bg-emerald-600 hover:bg-emerald-700 text-white'
|
||||
: 'border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800'
|
||||
: 'dg-btn-secondary'
|
||||
}
|
||||
onClick={() => {
|
||||
if (autoRefresh) onUserToggledOff();
|
||||
@@ -208,30 +208,30 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
<div className="border-t border-[var(--dg-border)]">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<div className="flex flex-col items-center justify-center py-16 dg-text-muted">
|
||||
<Activity className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">{t('audit.terminal.noActive')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.id')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.user')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.targetServer')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.port')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.startTime')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.duration')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.status')}</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">{t('audit.terminal.col.actions')}</TableHead>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.id')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.user')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.targetServer')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.port')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.startTime')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.duration')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.status')}</TableHead>
|
||||
<TableHead className="dg-text-secondary text-right">{t('audit.terminal.col.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -94,13 +94,13 @@ export function HistorySessionsPanel() {
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="relative w-52">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
placeholder={t('audit.terminal.searchUserServerIp')}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200"
|
||||
className="pl-9 h-9 dg-input"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
@@ -108,39 +108,39 @@ export function HistorySessionsPanel() {
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200"
|
||||
className="w-32 h-9 dg-input"
|
||||
/>
|
||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
<span className="text-slate-600 text-sm">—</span>
|
||||
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 h-9" onClick={() => { const r = presetAuditRange(1); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="w-36 h-9 dg-input" />
|
||||
<span className="dg-text-muted text-sm">—</span>
|
||||
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="w-36 h-9 dg-input" />
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => { const r = presetAuditRange(1); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
{t('audit.terminal.presetToday')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 h-9" onClick={() => { const r = presetAuditRange(7); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => { const r = presetAuditRange(7); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
{t('audit.terminal.preset7d')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 h-9" onClick={() => { const r = presetAuditRange(30); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => { const r = presetAuditRange(30); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
{t('audit.terminal.preset30d')}
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer">
|
||||
<label className="flex items-center gap-1.5 text-xs dg-text-secondary cursor-pointer">
|
||||
<input type="checkbox" checked={hasRecordOnly} onChange={(e) => setHasRecordOnly(e.target.checked)} className="rounded" />
|
||||
{t('audit.terminal.hasRecordOnly')}
|
||||
</label>
|
||||
<Button size="sm" className="h-9" onClick={applyFilters}>{t('audit.terminal.applyFilter')}</Button>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300 h-9" onClick={() => refetch()}>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<div className="flex flex-col items-center justify-center py-16 dg-text-muted">
|
||||
<Monitor className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">{t('audit.terminal.noHistory')}</p>
|
||||
</div>
|
||||
@@ -148,47 +148,47 @@ export function HistorySessionsPanel() {
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.id')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.user')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.targetServer')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.startTime')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.duration')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.recordSize')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.status')}</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">{t('audit.terminal.col.actions')}</TableHead>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.id')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.user')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.targetServer')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.startTime')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.duration')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.recordSize')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('audit.terminal.col.status')}</TableHead>
|
||||
<TableHead className="dg-text-secondary text-right">{t('audit.terminal.col.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((s) => (
|
||||
<TableRow key={s.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell><span className="text-sm text-slate-400">{s.id}</span></TableCell>
|
||||
<TableCell><span className="text-sm text-slate-200">{s.username || '--'}</span></TableCell>
|
||||
<TableRow key={s.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<TableCell><span className="text-sm dg-text-secondary">{s.id}</span></TableCell>
|
||||
<TableCell><span className="text-sm text-[var(--dg-text-primary)]">{s.username || '--'}</span></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-slate-200">{s.server_name || '--'}</span>
|
||||
<span className="text-xs text-slate-500">{s.server_ip}</span>
|
||||
<span className="text-sm text-[var(--dg-text-primary)]">{s.server_name || '--'}</span>
|
||||
<span className="text-xs dg-text-muted">{s.server_ip}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
<span className="text-sm dg-text-secondary">
|
||||
{s.start_time ? new Date(s.start_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell><span className="text-sm text-slate-300">{formatDuration(s.duration)}</span></TableCell>
|
||||
<TableCell><span className="text-sm text-[var(--dg-text-secondary)]">{formatDuration(s.duration)}</span></TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{s.record_size > 0 ? formatSize(s.record_size) : '--'}</span>
|
||||
<span className="text-sm dg-text-secondary">{s.record_size > 0 ? formatSize(s.record_size) : '--'}</span>
|
||||
</TableCell>
|
||||
<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 text-slate-400 hover:text-blue-400" onClick={() => openReplay(s.id)} disabled={!s.record_size} title={t('audit.terminal.replay')}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-blue" onClick={() => openReplay(s.id)} disabled={!s.record_size} title={t('audit.terminal.replay')}>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-amber-400" onClick={() => handleExportReport(s.id)} title={t('audit.terminal.exportReport')}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-yellow" onClick={() => handleExportReport(s.id)} title={t('audit.terminal.exportReport')}>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-green-400" onClick={() => handleDownload(s.id)} disabled={!s.record_size} title={t('audit.terminal.downloadRecord')}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-secondary hover:dg-text-green" onClick={() => handleDownload(s.id)} disabled={!s.record_size} title={t('audit.terminal.downloadRecord')}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -197,13 +197,13 @@ export function HistorySessionsPanel() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
|
||||
<span className="text-xs text-slate-500">{t('audit.terminal.totalSessions', { count: total })}</span>
|
||||
<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('audit.terminal.totalSessions', { count: total })}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-slate-400">{page} / {totalPages}</span>
|
||||
<span className="text-xs dg-text-secondary">{page} / {totalPages}</span>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -113,13 +113,13 @@ export function SessionCommandsPanel() {
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="relative w-52">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
placeholder={t('audit.terminal.searchUserCommand')}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
className="pl-9 h-9 dg-input"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
@@ -127,31 +127,31 @@ export function SessionCommandsPanel() {
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="w-28 h-9 bg-slate-800/50 border-slate-700 text-slate-200"
|
||||
className="w-28 h-9 dg-input"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('audit.terminal.sessionIdPlaceholder')}
|
||||
value={sessionIdInput}
|
||||
onChange={(e) => setSessionIdInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
className="w-32 h-9 dg-input"
|
||||
/>
|
||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
<span className="text-slate-600 text-sm">—</span>
|
||||
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 h-9" onClick={() => { const r = presetAuditRange(1); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="w-36 h-9 dg-input" />
|
||||
<span className="dg-text-muted text-sm">—</span>
|
||||
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="w-36 h-9 dg-input" />
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => { const r = presetAuditRange(1); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
{t('audit.terminal.presetToday')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 h-9" onClick={() => { const r = presetAuditRange(7); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => { const r = presetAuditRange(7); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
{t('audit.terminal.preset7d')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 h-9" onClick={() => { const r = presetAuditRange(30); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary h-9" onClick={() => { const r = presetAuditRange(30); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
{t('audit.terminal.preset30d')}
|
||||
</Button>
|
||||
<Button size="sm" className="h-9" onClick={applyFilters}>{t('audit.terminal.applyFilter')}</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800 h-9"
|
||||
className="dg-btn-secondary h-9"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
@@ -159,7 +159,7 @@ export function SessionCommandsPanel() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-amber-400 h-9"
|
||||
className="dg-btn-secondary hover:text-[var(--dg-orange)] h-9"
|
||||
onClick={handleExportCsv}
|
||||
disabled={exporting || total === 0}
|
||||
title={t('audit.terminal.exportCommandsCsv')}
|
||||
@@ -169,22 +169,22 @@ export function SessionCommandsPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
<div className="border-t border-[var(--dg-border)]">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : commands.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<div className="flex flex-col items-center justify-center py-16 dg-text-muted">
|
||||
<Search className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">{t('audit.terminal.noCommands')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400"
|
||||
className="grid gap-2 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-xs font-medium dg-text-secondary"
|
||||
style={{ gridTemplateColumns: GRID_COLS }}
|
||||
>
|
||||
<span>{t('audit.terminal.col.startTime')}</span>
|
||||
@@ -215,29 +215,29 @@ export function SessionCommandsPanel() {
|
||||
gridTemplateColumns: GRID_COLS,
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
className="px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50"
|
||||
className="px-4 py-2.5 border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"
|
||||
>
|
||||
<span className="text-sm text-slate-400 truncate">
|
||||
<span className="text-sm dg-text-secondary truncate">
|
||||
{c.created_at ? new Date(c.created_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-slate-200 truncate">{c.username || '--'}</span>
|
||||
<span className="text-sm text-[var(--dg-text-primary)] truncate">{c.username || '--'}</span>
|
||||
<code className={cn(
|
||||
'text-xs bg-slate-800 px-2 py-0.5 rounded max-w-[400px] truncate block',
|
||||
isHighRiskCommand(c.command || '') ? 'text-red-400' : 'text-slate-300',
|
||||
'text-xs bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded max-w-[400px] truncate block',
|
||||
isHighRiskCommand(c.command || '') ? 'dg-text-red' : 'text-[var(--dg-text-secondary)]',
|
||||
)}>
|
||||
{c.command || '--'}
|
||||
</code>
|
||||
<span className="text-sm text-slate-400">{formatCommandOutput(c.output_size)}</span>
|
||||
<span className="text-sm dg-text-secondary">{formatCommandOutput(c.output_size)}</span>
|
||||
<span className={cn(
|
||||
'text-xs',
|
||||
c.session_record_size ? 'text-slate-500' : 'text-amber-500/80',
|
||||
c.session_record_size ? 'dg-text-muted' : 'text-amber-500/80',
|
||||
)} title={!c.session_record_size ? t('audit.terminal.replayPlayer.noRecord') : undefined}>
|
||||
#{c.session_id}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-blue-400 disabled:opacity-30"
|
||||
className="h-7 w-7 dg-text-secondary hover:dg-text-blue disabled:opacity-30"
|
||||
onClick={() => openReplayAtCommand(c)}
|
||||
disabled={!c.session_record_size}
|
||||
title={c.session_record_size
|
||||
@@ -252,7 +252,7 @@ export function SessionCommandsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800">
|
||||
<div className="text-center py-2 text-xs dg-text-muted border-t border-[var(--dg-border)]">
|
||||
{t('audit.terminal.totalCommands', { count: total })}
|
||||
{commands.length < total && ` · ${t('audit.terminal.showingLoaded', { loaded: commands.length })}`}
|
||||
{commands.length > 200 && ` · ${t('audit.terminal.virtualScroll')}`}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function formatCommandOutput(bytes?: number): string {
|
||||
export function getStatusBadge(status: string) {
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 inline-block animate-pulse" />
|
||||
{i18n.t('audit.terminal.status.active')}
|
||||
</Badge>
|
||||
@@ -51,13 +51,13 @@ export function getStatusBadge(status: string) {
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
|
||||
{i18n.t('audit.terminal.status.failed')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-tertiary)]">
|
||||
{i18n.t('audit.terminal.status.closed')}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -8,11 +8,13 @@ import { useHostList, useHostHistory, usePortPolicy } from '@/hooks/api/useHostM
|
||||
import { queryKeys } from '@/hooks/api/queryKeys';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
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 {
|
||||
Cpu, HardDrive, Network, Activity, Server, Loader2,
|
||||
Cpu, HardDrive, Network, Activity, Server,
|
||||
Info, Monitor, Disc, Clock, List, HeartPulse, RefreshCw, XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
@@ -27,7 +29,7 @@ import { PortPolicyDialog } from './host-monitor/PortPolicyDialog';
|
||||
import { SystemInfoSection } from './host-monitor/SystemInfoSection';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export function HostMonitor() {
|
||||
export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
const urlHost = searchParams.get('host') || '';
|
||||
@@ -95,111 +97,108 @@ export function HostMonitor() {
|
||||
const currentVal = (key: string, fallback = '--') =>
|
||||
getCurrentVal(latest, netInfo, diskIOInfo, swapInfo, fdInfo, key, fallback);
|
||||
|
||||
if (loading && !history.length) {
|
||||
return <div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-slate-500" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-blue-400" />
|
||||
<Select value={selectedHost} onValueChange={setSelectedHost}>
|
||||
<SelectTrigger className="w-52 h-8 text-xs bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<SelectValue placeholder="选择主机..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
{hosts.map((h: JsonData) => (
|
||||
<SelectItem key={h.hostname} value={h.hostname}>{h.hostname}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{serverInfo && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)] ml-2">
|
||||
{String(serverInfo.os || '')}{serverInfo.cpu_cores ? ` | ${serverInfo.cpu_cores as number | string} 核` : ''}{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 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectItem value="avg">平均值</SelectItem>
|
||||
<SelectItem value="max">最大值</SelectItem>
|
||||
<SelectItem value="min">最小值</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1">
|
||||
{TIME_RANGES.map(tr => (
|
||||
<Button key={tr.hours} variant={rangeHours === tr.hours ? 'default' : 'outline'} size="sm"
|
||||
className="h-7 text-xs px-2.5" onClick={() => setRangeHours(tr.hours)}>{tr.label}</Button>
|
||||
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="选择主机..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
{hosts.map((h: JsonData) => (
|
||||
<SelectItem key={h.hostname} value={h.hostname}>{h.hostname}</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{serverInfo && (
|
||||
<span className="text-xs dg-text-muted">
|
||||
{String(serverInfo.os || '')}{serverInfo.cpu_cores ? ` | ${serverInfo.cpu_cores as number | string} 核` : ''}{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>
|
||||
<SelectContent className="dg-input">
|
||||
<SelectItem value="avg">平均值</SelectItem>
|
||||
<SelectItem value="max">最大值</SelectItem>
|
||||
<SelectItem value="min">最小值</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1">
|
||||
{TIME_RANGES.map(tr => (
|
||||
<Button key={tr.hours} variant={rangeHours === tr.hours ? 'default' : 'outline'} size="sm"
|
||||
className={`h-7 text-xs px-2.5 ${rangeHours === tr.hours ? 'dg-btn-primary' : 'dg-btn-secondary'}`}
|
||||
onClick={() => setRangeHours(tr.hours)}>{tr.label}</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="h-8 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] gap-0 overflow-x-auto">
|
||||
{[
|
||||
{ id: 'basic', label: '基础监控', icon: <Monitor className="w-3 h-3" /> },
|
||||
{ id: 'disk', label: '磁盘监控', icon: <HardDrive className="w-3 h-3" /> },
|
||||
{ id: 'network', label: '网络监控', icon: <Network className="w-3 h-3" /> },
|
||||
{ id: 'ports', label: '端口监控', icon: <Server className="w-3 h-3" /> },
|
||||
{ id: 'process', label: '进程监控', icon: <List className="w-3 h-3" /> },
|
||||
{ id: 'system', label: '系统信息', icon: <Info className="w-3 h-3" /> },
|
||||
{ id: 'health_probes', label: '健康探针', icon: <HeartPulse className="w-3 h-3" /> },
|
||||
].map(tab => (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="text-[11px] h-7 gap-1 data-[state=active]:bg-[var(--dg-bg-primary)]">
|
||||
{tab.icon}{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
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">
|
||||
{[
|
||||
{ id: 'basic', label: '基础监控', icon: <Monitor className="w-3 h-3" /> },
|
||||
{ id: 'disk', label: '磁盘监控', icon: <HardDrive className="w-3 h-3" /> },
|
||||
{ id: 'network', label: '网络监控', icon: <Network className="w-3 h-3" /> },
|
||||
{ id: 'ports', label: '端口监控', icon: <Server className="w-3 h-3" /> },
|
||||
{ id: 'process', label: '进程监控', icon: <List className="w-3 h-3" /> },
|
||||
{ id: 'system', label: '系统信息', icon: <Info className="w-3 h-3" /> },
|
||||
{ id: 'health_probes', label: '健康探针', icon: <HeartPulse className="w-3 h-3" /> },
|
||||
].map(tab => (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="text-[11px] h-7 gap-1 data-[state=active]:bg-[var(--dg-bg-primary)]">
|
||||
{tab.icon}{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<MetricChartCard title="CPU 使用率(%)" value={currentVal('cpu')} icon={<Cpu className="h-4 w-4 text-blue-400" />}
|
||||
<TabsContent value="basic" className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<MetricChartCard title="CPU 使用率(%)" value={currentVal('cpu')} icon={<Cpu className="h-4 w-4 dg-text-blue" />}
|
||||
data={metrics.cpu} color="#3b82f6" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="CPU IO等待(%)" value={latest?.cpu_iowait != null ? `${(latest.cpu_iowait as number).toFixed(1)}%` : '--'} icon={<Clock className="h-4 w-4 text-orange-400" />}
|
||||
<MetricChartCard title="CPU IO等待(%)" value={latest?.cpu_iowait != null ? `${(latest.cpu_iowait as number).toFixed(1)}%` : '--'} icon={<Clock className="h-4 w-4 dg-text-orange" />}
|
||||
data={metrics.iowait} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="内存使用率(%)" value={currentVal('mem')} icon={<Server className="h-4 w-4 text-emerald-400" />}
|
||||
<MetricChartCard title="内存使用率(%)" value={currentVal('mem')} icon={<Server className="h-4 w-4 dg-text-green" />}
|
||||
data={metrics.mem} color="#10b981" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="可用内存(GB)" value={latest?.mem_available != null ? `${(latest.mem_available as number).toFixed(1)}GB` : '--'} icon={<Server className="h-4 w-4 text-emerald-400" />}
|
||||
<MetricChartCard title="可用内存(GB)" value={latest?.mem_available != null ? `${(latest.mem_available as number).toFixed(1)}GB` : '--'} icon={<Server className="h-4 w-4 dg-text-green" />}
|
||||
data={metrics.memAvail} color="#34d399" unit="GB" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="Swap 使用率(%)" value={currentVal('swap')} icon={<HardDrive className="h-4 w-4 text-red-400" />}
|
||||
<MetricChartCard title="Swap 使用率(%)" value={currentVal('swap')} icon={<HardDrive className="h-4 w-4 dg-text-red" />}
|
||||
data={metrics.swap} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="系统负载(1m)" value={currentVal('load1')} icon={<Activity className="h-4 w-4 text-purple-400" />}
|
||||
<MetricChartCard title="系统负载(1m)" value={currentVal('load1')} icon={<Activity className="h-4 w-4 dg-text-purple" />}
|
||||
data={metrics.load1} color="#8b5cf6" unit="" domain={[0, 'auto']} aggType={aggType} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="disk" className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<MetricChartCard title="磁盘使用率(%)" value={currentVal('disk')} icon={<HardDrive className="h-4 w-4 text-orange-400" />}
|
||||
<MetricChartCard title="磁盘使用率(%)" value={currentVal('disk')} icon={<HardDrive className="h-4 w-4 dg-text-orange" />}
|
||||
data={metrics.disk} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="Inode 使用率(%)" value={(() => {
|
||||
const dArr = Array.isArray(parseJSON(latest?.disk_info)) ? parseJSON(latest?.disk_info) : [];
|
||||
const root = dArr.find((d: JsonData) => d.mount === '/') || dArr[0];
|
||||
return root?.inode_usage_pct != null ? `${root.inode_usage_pct.toFixed(1)}%` : '--';
|
||||
})()} icon={<HardDrive className="h-4 w-4 text-purple-400" />}
|
||||
})()} icon={<HardDrive className="h-4 w-4 dg-text-purple" />}
|
||||
data={metrics.inode} color="#8b5cf6" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="磁盘读取(KB/s)" value={currentVal('ioR')} icon={<Disc className="h-4 w-4 text-blue-400" />}
|
||||
<MetricChartCard title="磁盘读取(KB/s)" value={currentVal('ioR')} icon={<Disc className="h-4 w-4 dg-text-blue" />}
|
||||
data={metrics.ioRead} color="#3b82f6" unit="KB/s" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="磁盘写入(KB/s)" value={currentVal('ioW')} icon={<Disc className="h-4 w-4 text-emerald-400" />}
|
||||
<MetricChartCard title="磁盘写入(KB/s)" value={currentVal('ioW')} icon={<Disc className="h-4 w-4 dg-text-green" />}
|
||||
data={metrics.ioWrite} color="#10b981" unit="KB/s" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="磁盘IO繁忙度(%)" value={diskIOInfo?.util != null ? `${diskIOInfo.util.toFixed(1)}%` : '--'} icon={<Disc className="h-4 w-4 text-red-400" />}
|
||||
<MetricChartCard title="磁盘IO繁忙度(%)" value={diskIOInfo?.util != null ? `${diskIOInfo.util.toFixed(1)}%` : '--'} icon={<Disc className="h-4 w-4 dg-text-red" />}
|
||||
data={metrics.ioUtil} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network" className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<MetricChartCard title="TCP EST" value={currentVal('tcp')} icon={<Network className="h-4 w-4 text-purple-400" />}
|
||||
<MetricChartCard title="TCP EST" value={currentVal('tcp')} icon={<Network className="h-4 w-4 dg-text-purple" />}
|
||||
data={metrics.tcpEst} color="#8b5cf6" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="TCP TIME_WAIT" value={currentVal('tw')} icon={<Clock className="h-4 w-4 text-amber-400" />}
|
||||
<MetricChartCard title="TCP TIME_WAIT" value={currentVal('tw')} icon={<Clock className="h-4 w-4 dg-text-yellow" />}
|
||||
data={metrics.tcpTW} color="#f59e0b" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="TCP CLOSE_WAIT" value={netInfo?.tcp_close_wait != null ? String(netInfo.tcp_close_wait) : '--'} icon={<Clock className="h-4 w-4 text-red-400" />}
|
||||
<MetricChartCard title="TCP CLOSE_WAIT" value={netInfo?.tcp_close_wait != null ? String(netInfo.tcp_close_wait) : '--'} icon={<Clock className="h-4 w-4 dg-text-red" />}
|
||||
data={metrics.tcpCW} color="#ef4444" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="TCP 总连接" value={netInfo ? String((netInfo.tcp_established||0)+(netInfo.tcp_time_wait||0)+(netInfo.tcp_close_wait||0)) : '--'} icon={<Network className="h-4 w-4 text-cyan-400" />}
|
||||
<MetricChartCard title="TCP 总连接" value={netInfo ? String((netInfo.tcp_established||0)+(netInfo.tcp_time_wait||0)+(netInfo.tcp_close_wait||0)) : '--'} icon={<Network className="h-4 w-4 dg-text-cyan" />}
|
||||
data={metrics.connTotal} color="#22d3ee" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
</div>
|
||||
<DualLineChart
|
||||
@@ -210,9 +209,9 @@ export function HostMonitor() {
|
||||
aggType={aggType} unit="KB/s"
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
<MetricChartCard title="网卡接收错误" value={netInfo?.net_rx_errors != null ? String(netInfo.net_rx_errors) : '--'} icon={<XCircle className="h-4 w-4 text-red-400" />}
|
||||
<MetricChartCard title="网卡接收错误" value={netInfo?.net_rx_errors != null ? String(netInfo.net_rx_errors) : '--'} icon={<XCircle className="h-4 w-4 dg-text-red" />}
|
||||
data={metrics.rxErr} color="#ef4444" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="网卡发送错误" value={netInfo?.net_tx_errors != null ? String(netInfo.net_tx_errors) : '--'} icon={<XCircle className="h-4 w-4 text-orange-400" />}
|
||||
<MetricChartCard title="网卡发送错误" value={netInfo?.net_tx_errors != null ? String(netInfo.net_tx_errors) : '--'} icon={<XCircle className="h-4 w-4 dg-text-orange" />}
|
||||
data={metrics.txErr} color="#f97316" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -221,15 +220,15 @@ export function HostMonitor() {
|
||||
<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 text-emerald-400" />监听端口 {portInfo.length}
|
||||
<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] text-amber-400 border-amber-500/30">
|
||||
<Badge variant="outline" className="text-[10px] dg-text-yellow border-amber-500/30">
|
||||
黑名单: {portWhitelist.join(',')}
|
||||
</Badge>
|
||||
)}
|
||||
{portPolicyMode === 'whitelist' && (
|
||||
<Badge variant="outline" className="text-[10px] text-blue-400 border-blue-500/30">
|
||||
<Badge variant="outline" className="text-[10px] dg-text-blue border-blue-500/30">
|
||||
白名单模式
|
||||
</Badge>
|
||||
)}
|
||||
@@ -256,7 +255,7 @@ export function HostMonitor() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{portInfo.map((p: JsonData, i: number) => (
|
||||
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-slate-800/30 transition-colors">
|
||||
<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">
|
||||
@@ -265,7 +264,7 @@ export function HostMonitor() {
|
||||
</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 text-emerald-400 border-emerald-500/30">
|
||||
<Badge variant="default" className="text-[10px] bg-emerald-500/20 dg-text-green border-emerald-500/30">
|
||||
LISTEN
|
||||
</Badge>
|
||||
</td>
|
||||
@@ -283,7 +282,7 @@ export function HostMonitor() {
|
||||
<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 text-cyan-400" />进程 TOP {processTop.length}
|
||||
<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">
|
||||
@@ -307,14 +306,14 @@ export function HostMonitor() {
|
||||
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-slate-800/30 transition-colors">
|
||||
<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:text-cyan-400 shrink-0"
|
||||
className="opacity-0 group-hover/cmd:opacity-100 transition-opacity p-0.5 hover:dg-text-cyan shrink-0"
|
||||
title="复制命令行"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -337,7 +336,7 @@ export function HostMonitor() {
|
||||
</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' : 'text-cyan-400'}>
|
||||
<span className={memp != null && memp > 50 ? 'text-rose-400' : 'dg-text-cyan'}>
|
||||
{memp != null ? memp.toFixed(2) : '--'}
|
||||
</span>
|
||||
</td>
|
||||
@@ -361,7 +360,7 @@ export function HostMonitor() {
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-violet-400" />FD 文件句柄
|
||||
<HardDrive className="h-4 w-4 dg-text-purple" />FD 文件句柄
|
||||
</h3>
|
||||
{fdInfo?.allocated != null ? (
|
||||
<>
|
||||
@@ -376,7 +375,7 @@ export function HostMonitor() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--dg-text-muted)]">使用率 </span>
|
||||
<span className={`font-mono font-semibold ${fdInfo.usage_pct > 80 ? 'text-rose-400' : 'text-violet-400'}`}>
|
||||
<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>
|
||||
@@ -410,21 +409,21 @@ export function HostMonitor() {
|
||||
<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 text-emerald-400" />上下文切换
|
||||
<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 text-emerald-400">
|
||||
<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 text-amber-400">
|
||||
<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>
|
||||
@@ -432,8 +431,8 @@ export function HostMonitor() {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-emerald-400">CTX</span>
|
||||
<span className="text-amber-400">IRQ</span>
|
||||
<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 && (
|
||||
@@ -463,19 +462,46 @@ export function HostMonitor() {
|
||||
portInfoCount={portInfo.length}
|
||||
/>
|
||||
|
||||
<HealthProbeSection selectedHost={selectedHost} />
|
||||
</Tabs>
|
||||
<HealthProbeSection selectedHost={selectedHost} />
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
<PortPolicyDialog
|
||||
open={portPolicyDialogOpen}
|
||||
onOpenChange={setPortPolicyDialogOpen}
|
||||
portPolicyMode={portPolicyMode}
|
||||
onModeChange={setPortPolicyMode}
|
||||
portWhitelist={portWhitelist}
|
||||
onWhitelistChange={setPortWhitelist}
|
||||
onSave={handleSavePortPolicy}
|
||||
saving={portPolicySaving}
|
||||
/>
|
||||
</div>
|
||||
const dialogs = (
|
||||
<PortPolicyDialog
|
||||
open={portPolicyDialogOpen}
|
||||
onOpenChange={setPortPolicyDialogOpen}
|
||||
portPolicyMode={portPolicyMode}
|
||||
onModeChange={setPortPolicyMode}
|
||||
portWhitelist={portWhitelist}
|
||||
onWhitelistChange={setPortWhitelist}
|
||||
onSave={handleSavePortPolicy}
|
||||
saving={portPolicySaving}
|
||||
/>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{toolbar}
|
||||
{loading && !history.length ? <PageLoading /> : monitorBody}
|
||||
{dialogs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="workspace"
|
||||
title="主机监控"
|
||||
subtitle={selectedHost ? `当前主机: ${selectedHost}` : '选择主机查看监控指标'}
|
||||
icon={<Server className="h-5 w-5" />}
|
||||
toolbar={toolbar}
|
||||
loading={loading && !history.length}
|
||||
loadingLabel="加载监控数据..."
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
{monitorBody}
|
||||
{dialogs}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -6,14 +6,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Activity, Search, Filter, BarChart3, Cpu, HardDrive, Network, Database, Plus } from 'lucide-react';
|
||||
import { metricsApi } from '@/api';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
interface _MetricName {
|
||||
name: string;
|
||||
category: string;
|
||||
data_source: string;
|
||||
}
|
||||
|
||||
interface TargetInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -49,7 +45,6 @@ export function MetricsExplorer() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchKw, setSearchKw] = useState('');
|
||||
|
||||
// Load targets
|
||||
const loadTargets = useCallback(async () => {
|
||||
try {
|
||||
const res: JsonData = await metricsApi.getTargets({ page_size: 200 });
|
||||
@@ -58,7 +53,6 @@ export function MetricsExplorer() {
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
// Load metric names
|
||||
const loadMetricNames = useCallback(async () => {
|
||||
try {
|
||||
const names: string[] = await metricsApi.getNames();
|
||||
@@ -66,16 +60,15 @@ export function MetricsExplorer() {
|
||||
} catch { /* empty */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadTargets(); loadMetricNames(); }, []);
|
||||
useEffect(() => { loadTargets(); loadMetricNames(); }, [loadTargets, loadMetricNames]);
|
||||
|
||||
// Query metric data when target + metrics selected
|
||||
useEffect(() => {
|
||||
if (!selectedTarget || selectedMetrics.length === 0) return;
|
||||
const target = targets.find(t => String(t.id) === selectedTarget);
|
||||
if (!target) return;
|
||||
|
||||
const to = Math.floor(Date.now() / 1000);
|
||||
const from = to - 3600; // last hour
|
||||
const from = to - 3600;
|
||||
|
||||
Promise.all(
|
||||
selectedMetrics.map(mn =>
|
||||
@@ -88,15 +81,12 @@ export function MetricsExplorer() {
|
||||
});
|
||||
}, [selectedTarget, selectedMetrics, targets]);
|
||||
|
||||
// Filter metric names by search
|
||||
const filteredMetrics = searchKw
|
||||
? metricNames.filter(n => n.toLowerCase().includes(searchKw.toLowerCase()))
|
||||
: metricNames;
|
||||
|
||||
// Selected target info
|
||||
const target = targets.find(t => String(t.id) === selectedTarget);
|
||||
|
||||
// Group metrics by category
|
||||
const grouped = filteredMetrics.reduce((acc: Record<string, string[]>, name) => {
|
||||
const cat = name.split('_')[0] || 'other';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
@@ -104,7 +94,6 @@ export function MetricsExplorer() {
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Merge query results into time-aligned data
|
||||
const timeMap: Record<number, Record<string, number>> = {};
|
||||
queryResults.forEach(pt => {
|
||||
if (!timeMap[pt.timestamp]) timeMap[pt.timestamp] = {};
|
||||
@@ -115,61 +104,68 @@ export function MetricsExplorer() {
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#f97316'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Select value={selectedTarget} onValueChange={setSelectedTarget}>
|
||||
<SelectTrigger className="w-64 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<SelectValue placeholder={loading ? '加载中...' : '选择监控目标...'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200 max-h-80">
|
||||
{targets.map(t => (
|
||||
<SelectItem key={t.id} value={String(t.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full ${t.status === 'online' ? 'bg-emerald-400' : 'bg-slate-500'}`} />
|
||||
{t.name} ({t.ip || t.type})
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
className="pl-8 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9"
|
||||
placeholder="搜索指标..."
|
||||
value={searchKw}
|
||||
onChange={(e) => setSearchKw(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setSelectedMetrics([]); setQueryResults([]); }}>
|
||||
清除
|
||||
</Button>
|
||||
const toolbar = (
|
||||
<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 ? '加载中...' : '选择监控目标...'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input max-h-80">
|
||||
{targets.map(t => (
|
||||
<SelectItem key={t.id} value={String(t.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full ${t.status === 'online' ? 'bg-emerald-400' : 'bg-[var(--dg-bg-active)]'}`} />
|
||||
{t.name} ({t.ip || t.type})
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
className="pl-8 dg-input h-9"
|
||||
placeholder="搜索指标..."
|
||||
value={searchKw}
|
||||
onChange={(e) => setSearchKw(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => { setSelectedMetrics([]); setQueryResults([]); }}>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="workspace"
|
||||
title="指标浏览器"
|
||||
subtitle="按分类/主机浏览所有指标,支持叠加对比"
|
||||
icon={<Activity className="h-5 w-5" />}
|
||||
toolbar={toolbar}
|
||||
loading={loading}
|
||||
loadingLabel="加载监控目标..."
|
||||
contentClassName="space-y-6"
|
||||
>
|
||||
{!selectedTarget ? (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Filter className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
||||
<p className="text-[var(--dg-text-muted)]">请先选择监控目标,然后勾选要查看的指标</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PageEmpty
|
||||
icon={<Filter className="h-12 w-12" />}
|
||||
title="请选择监控目标"
|
||||
description="选择目标主机后,从左侧勾选要查看的指标"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Metric selector */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] lg:col-span-1">
|
||||
<Card className="dg-card lg:col-span-1">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BarChart3 className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<BarChart3 className="h-4 w-4 dg-text-muted" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">指标列表</h3>
|
||||
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">{selectedMetrics.length} 已选</span>
|
||||
<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]) => (
|
||||
<div key={cat}>
|
||||
<div className="flex items-center gap-1.5 mb-1.5 text-xs text-[var(--dg-text-muted)]">
|
||||
<div className="flex items-center gap-1.5 mb-1.5 text-xs dg-text-muted">
|
||||
{METRIC_CATEGORIES[cat]?.icon}
|
||||
<span>{METRIC_CATEGORIES[cat]?.label || cat}</span>
|
||||
</div>
|
||||
@@ -177,10 +173,10 @@ export function MetricsExplorer() {
|
||||
{names.map(name => {
|
||||
const selected = selectedMetrics.includes(name);
|
||||
return (
|
||||
<label key={name} className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer text-xs transition-colors ${selected ? 'bg-blue-500/10 text-blue-300' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}>
|
||||
<label key={name} className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer text-xs transition-colors ${selected ? 'bg-[var(--dg-bg-active)] dg-text-blue' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-slate-600 bg-slate-800 accent-blue-500"
|
||||
className="rounded border-[var(--dg-border-hover)] bg-[var(--dg-bg-tertiary)] accent-[var(--dg-blue)]"
|
||||
checked={selected}
|
||||
onChange={() => {
|
||||
setSelectedMetrics(prev =>
|
||||
@@ -199,34 +195,24 @@ export function MetricsExplorer() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Chart area */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] lg:col-span-2">
|
||||
<Card className="dg-card lg:col-span-2">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<Activity className="h-4 w-4 dg-text-muted" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
{target?.name || ''} — 最近 1 小时
|
||||
</h3>
|
||||
</div>
|
||||
{selectedMetrics.length === 0 ? (
|
||||
<div className="h-64 flex items-center justify-center text-sm text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
<div className="text-center">
|
||||
<Plus className="h-8 w-8 text-slate-600 mx-auto mb-2" />
|
||||
请从左侧勾选指标
|
||||
</div>
|
||||
</div>
|
||||
<PageEmpty icon={<Plus className="h-8 w-8" />} title="请从左侧勾选指标" className="py-16" />
|
||||
) : sortedTimes.length === 0 ? (
|
||||
<div className="h-64 flex items-center justify-center text-sm text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
暂无数据
|
||||
</div>
|
||||
<PageEmpty title="暂无数据" 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]">
|
||||
{/* Grid lines */}
|
||||
{[0, 25, 50, 75, 100].map(pct => (
|
||||
<line key={pct} x1="0" y1={300 - (pct / 100) * 260} x2={Math.max(sortedTimes.length * 4, 400)} y2={300 - (pct / 100) * 260} stroke="#1e293b" strokeDasharray="3 3" />
|
||||
))}
|
||||
{/* Lines per metric */}
|
||||
{selectedMetrics.map((mn, mi) => {
|
||||
const points = sortedTimes
|
||||
.map((t, i) => ({ x: i * 4 + 2, y: 300 - ((timeMap[t][mn] ?? 0) / maxVal) * 260 }))
|
||||
@@ -239,7 +225,6 @@ export function MetricsExplorer() {
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* X axis time labels */}
|
||||
{sortedTimes.filter((_, i) => i % 15 === 0).map((t, i) => (
|
||||
<text key={t} x={i * 60 + 2} y="295" fill="#475569" fontSize="9">
|
||||
{new Date(t * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
@@ -248,7 +233,6 @@ export function MetricsExplorer() {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* Legend */}
|
||||
{selectedMetrics.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3 mt-3">
|
||||
{selectedMetrics.map((mn, mi) => (
|
||||
@@ -264,12 +248,11 @@ export function MetricsExplorer() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target metrics overview */}
|
||||
{target && Object.keys(target.latest_metrics || {}).length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{Object.entries(target.latest_metrics).map(([key, val]) => (
|
||||
<div key={key} className="bg-[var(--dg-bg-secondary)] rounded-lg p-3 border border-[var(--dg-border)] text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] truncate">{key}</p>
|
||||
<p className="text-[10px] dg-text-muted truncate">{key}</p>
|
||||
<p className="text-lg font-bold text-[var(--dg-text-primary)]">
|
||||
{typeof val === 'number' ? val.toFixed(1) : '--'}
|
||||
</p>
|
||||
@@ -277,8 +260,8 @@ export function MetricsExplorer() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricsExplorer;
|
||||
export default MetricsExplorer;
|
||||
@@ -54,11 +54,11 @@ function fmtVal(v: number, precision = 0): string {
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'online': return 'text-emerald-400';
|
||||
case 'warning': return 'text-amber-400';
|
||||
case 'online': return 'dg-text-green';
|
||||
case 'warning': return 'dg-text-yellow';
|
||||
case 'error':
|
||||
case 'offline': return 'text-red-400';
|
||||
default: return 'text-slate-500';
|
||||
case 'offline': return 'dg-text-red';
|
||||
default: return 'dg-text-muted';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ function statusBg(status: string): string {
|
||||
case 'warning': return 'bg-amber-400';
|
||||
case 'error':
|
||||
case 'offline': return 'bg-red-400';
|
||||
default: return 'bg-slate-500';
|
||||
default: return 'bg-[var(--dg-bg-active)]';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ export function MiddlewareMonitor() {
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500 mr-2" />
|
||||
<Loader2 className="h-6 w-6 animate-spin dg-text-muted mr-2" />
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">正在加载中间件指标...</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -182,8 +182,8 @@ export function MiddlewareMonitor() {
|
||||
{error && !loading && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-red-500/30">
|
||||
<CardContent className="p-6 text-center">
|
||||
<Activity className="h-8 w-8 text-red-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<Activity className="h-8 w-8 dg-text-red mx-auto mb-2" />
|
||||
<p className="text-sm dg-text-red">{error}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">请确认后端服务可用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -192,7 +192,7 @@ export function MiddlewareMonitor() {
|
||||
{!loading && !error && targets.length === 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Database className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
||||
<Database className="h-12 w-12 dg-text-muted mx-auto mb-4" />
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">暂无中间件监控目标</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">点击「添加监控」配置 Redis / MySQL 连接</p>
|
||||
<Button size="sm" className="mt-4 gap-1.5" onClick={() => { setEditTarget(null); setDialogOpen(true); }}>
|
||||
@@ -208,7 +208,7 @@ export function MiddlewareMonitor() {
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Database className="h-4 w-4 text-red-400" />
|
||||
<Database className="h-4 w-4 dg-text-red" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">Redis 监控</h3>
|
||||
{redisTarget && (
|
||||
<Badge variant="outline" className="ml-auto text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
@@ -237,7 +237,7 @@ export function MiddlewareMonitor() {
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Server className="h-4 w-4 text-blue-400" />
|
||||
<Server className="h-4 w-4 dg-text-blue" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">MySQL 监控</h3>
|
||||
{mysqlTarget && (
|
||||
<Badge variant="outline" className="ml-auto text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
@@ -358,7 +358,7 @@ function InstanceCard({
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] capitalize ${target.status === 'online' ? 'border-emerald-400/50 text-emerald-400' : 'border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
|
||||
className={`text-[10px] capitalize ${target.status === 'online' ? 'border-emerald-400/50 dg-text-green' : 'border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
|
||||
>
|
||||
{target.status}
|
||||
</Badge>
|
||||
@@ -393,7 +393,7 @@ function InstanceCard({
|
||||
<Pencil className="h-3 w-3" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1 text-red-400 hover:text-red-300" onClick={onDelete}>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1 dg-text-red hover:dg-text-red" onClick={onDelete}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
删除
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 监控中心 — 4个监控标签页的容器
|
||||
// 监控中心 — 4个监控标签页的容器 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Cpu, Database, Radio, Globe } from 'lucide-react';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { HostMonitor } from './HostMonitor';
|
||||
import { MiddlewareMonitor } from './MiddlewareMonitor';
|
||||
import { SNMPMonitor } from './SNMPMonitor';
|
||||
@@ -9,61 +9,32 @@ import { SiteMonitor } from './SiteMonitor';
|
||||
|
||||
type TabId = 'host' | 'middleware' | 'snmp' | 'site';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
name: string;
|
||||
icon: React.ElementType;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'host', name: '主机监控', icon: Cpu, description: 'CPU / 内存 / 磁盘 / 网络 / 磁盘IO' },
|
||||
{ id: 'middleware', name: '中间件监控', icon: Database, description: 'Redis / MySQL 指标采集' },
|
||||
{ id: 'snmp', name: 'SNMP 监控', icon: Radio, description: '网络设备 / 服务器硬件 (OID 模板)' },
|
||||
{ id: 'site', name: '站点拨测', icon: Globe, description: 'HTTP / PING / DNS / TCP / WS 拨测' },
|
||||
const tabs: { id: TabId; name: string; description: string }[] = [
|
||||
{ id: 'host', name: '主机监控', description: 'CPU / 内存 / 磁盘 / 网络 / 磁盘IO' },
|
||||
{ id: 'middleware', name: '中间件监控', description: 'Redis / MySQL 指标采集' },
|
||||
{ id: 'snmp', name: 'SNMP 监控', description: '网络设备 / 服务器硬件 (OID 模板)' },
|
||||
{ id: 'site', name: '站点拨测', description: 'HTTP / PING / DNS / TCP / WS 拨测' },
|
||||
];
|
||||
|
||||
export function MonitorCenter() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('host');
|
||||
const current = tabs.find((t) => t.id === activeTab);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">监控中心</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">全面的基础设施与应用监控</p>
|
||||
</div>
|
||||
|
||||
{/* Tab Bar */}
|
||||
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] p-1 rounded-lg border border-[var(--dg-border)]">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-md text-sm font-medium transition-all',
|
||||
activeTab === tab.id
|
||||
? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] shadow-sm'
|
||||
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'
|
||||
)}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{tab.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Description */}
|
||||
<p className="text-sm text-[var(--dg-text-muted)] -mt-4">
|
||||
{tabs.find(t => t.id === activeTab)?.description}
|
||||
</p>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="min-h-[400px]">
|
||||
{activeTab === 'host' && <HostMonitor />}
|
||||
{activeTab === 'middleware' && <MiddlewareMonitor />}
|
||||
{activeTab === 'snmp' && <SNMPMonitor />}
|
||||
{activeTab === 'site' && <SiteMonitor />}
|
||||
</div>
|
||||
</div>
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="监控中心"
|
||||
subtitle={current?.description ?? '全面的基础设施与应用监控'}
|
||||
icon={<Cpu className="h-5 w-5" />}
|
||||
tabs={tabs.map((t) => ({ id: t.id, label: t.name }))}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => setActiveTab(id as TabId)}
|
||||
contentClassName="min-h-[400px]"
|
||||
>
|
||||
{activeTab === 'host' && <HostMonitor embedded />}
|
||||
{activeTab === 'middleware' && <MiddlewareMonitor />}
|
||||
{activeTab === 'snmp' && <SNMPMonitor />}
|
||||
{activeTab === 'site' && <SiteMonitor />}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -143,12 +143,12 @@ export function SNMPMonitor() {
|
||||
// ---------- status badge ----------
|
||||
const statusBadge = (status?: string) => {
|
||||
if (status === 'online') {
|
||||
return <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">在线</Badge>;
|
||||
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">在线</Badge>;
|
||||
}
|
||||
if (status === 'abnormal') {
|
||||
return <Badge className="bg-red-500/15 text-red-400 border-red-500/30">异常</Badge>;
|
||||
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30">异常</Badge>;
|
||||
}
|
||||
return <Badge className="bg-slate-500/15 text-slate-400 border-slate-500/30">离线</Badge>;
|
||||
return <Badge className="bg-[var(--dg-bg-active)]/15 dg-text-secondary border-[var(--dg-border)]">离线</Badge>;
|
||||
};
|
||||
|
||||
// ---------- render ----------
|
||||
@@ -159,7 +159,7 @@ export function SNMPMonitor() {
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Router className="h-4 w-4 text-cyan-400" />
|
||||
<Router className="h-4 w-4 dg-text-cyan" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">网络设备</h3>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">交换机 / 路由器 / 防火墙</p>
|
||||
@@ -170,7 +170,7 @@ export function SNMPMonitor() {
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">在线</span>
|
||||
<span className="text-emerald-400 font-mono">{stats.online}</span>
|
||||
<span className="dg-text-green font-mono">{stats.online}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -179,14 +179,14 @@ export function SNMPMonitor() {
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="h-4 w-4 text-purple-400" />
|
||||
<HardDrive className="h-4 w-4 dg-text-purple" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">服务器硬件</h3>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">iLO / iDRAC / IPMI</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">异常</span>
|
||||
<span className="text-red-400 font-mono">{stats.abnormal}</span>
|
||||
<span className="dg-text-red font-mono">{stats.abnormal}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -221,7 +221,7 @@ export function SNMPMonitor() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Router className="h-4 w-4 text-cyan-400" />
|
||||
<Router className="h-4 w-4 dg-text-cyan" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
SNMP 设备列表
|
||||
{!loading && (
|
||||
@@ -235,12 +235,12 @@ export function SNMPMonitor() {
|
||||
{/* Add Device Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 border border-cyan-500/30">
|
||||
<Button size="sm" className="bg-cyan-500/10 dg-text-cyan hover:bg-cyan-500/20 border border-cyan-500/30">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加设备
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-slate-900 border-[var(--dg-border)] text-[var(--dg-text-primary)] sm:max-w-md">
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">添加 SNMP 设备</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
@@ -307,7 +307,7 @@ export function SNMPMonitor() {
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectContent className="dg-input">
|
||||
<SelectItem value="v2c">v2c</SelectItem>
|
||||
<SelectItem value="v3">v3</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -348,14 +348,14 @@ export function SNMPMonitor() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-muted)] hover:bg-slate-800"
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)]"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={submitting || !form.name.trim() || !form.ip.trim()}
|
||||
className="bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 border border-cyan-500/30"
|
||||
className="bg-cyan-500/10 dg-text-cyan hover:bg-cyan-500/20 border border-cyan-500/30"
|
||||
>
|
||||
{submitting ? '添加中...' : '确认添加'}
|
||||
</Button>
|
||||
@@ -410,7 +410,7 @@ export function SNMPMonitor() {
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleDelete(d.id)}
|
||||
className="text-slate-500 hover:text-red-400 hover:bg-red-500/10"
|
||||
className="dg-text-muted hover:dg-text-red hover:bg-red-500/10"
|
||||
title="删除设备"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
@@ -49,11 +49,11 @@ import type { JsonData } from '@/types/common';
|
||||
|
||||
// 拨测协议类型(与后端 model 对齐:http, https, tcp, dns, ping)
|
||||
const probeTypes = [
|
||||
{ icon: Globe, name: 'HTTP', color: 'text-blue-400', desc: 'HTTP URL 状态码 & 响应时间' },
|
||||
{ icon: Globe, name: 'HTTPS', color: 'text-emerald-400', desc: 'HTTPS SSL 证书 & 响应时间' },
|
||||
{ icon: Server, name: 'TCP', color: 'text-orange-400', desc: 'TCP 端口连通性检测' },
|
||||
{ icon: Globe, name: 'DNS', color: 'text-cyan-400', desc: '域名解析延迟 & 结果验证' },
|
||||
{ icon: ArrowUpDown, name: 'PING', color: 'text-purple-400', desc: 'ICMP 连通性 & 延迟' },
|
||||
{ icon: Globe, name: 'HTTP', color: 'dg-text-blue', desc: 'HTTP URL 状态码 & 响应时间' },
|
||||
{ icon: Globe, name: 'HTTPS', color: 'dg-text-green', desc: 'HTTPS SSL 证书 & 响应时间' },
|
||||
{ icon: Server, name: 'TCP', color: 'dg-text-orange', desc: 'TCP 端口连通性检测' },
|
||||
{ icon: Globe, name: 'DNS', color: 'dg-text-cyan', desc: '域名解析延迟 & 结果验证' },
|
||||
{ icon: ArrowUpDown, name: 'PING', color: 'dg-text-purple', desc: 'ICMP 连通性 & 延迟' },
|
||||
] as const;
|
||||
|
||||
const PROBE_TYPE_OPTIONS = ['HTTP', 'HTTPS', 'TCP', 'DNS', 'PING'] as const;
|
||||
@@ -104,22 +104,22 @@ const emptyForm: CreateTaskForm = {
|
||||
function statusIcon(status?: string) {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <CheckCircle2 className="h-4 w-4 text-emerald-400" />;
|
||||
return <CheckCircle2 className="h-4 w-4 dg-text-green" />;
|
||||
case 'down':
|
||||
return <XCircle className="h-4 w-4 text-red-400" />;
|
||||
return <XCircle className="h-4 w-4 dg-text-red" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-gray-400" />;
|
||||
return <Clock className="h-4 w-4 dg-text-secondary" />;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status?: string) {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">正常</Badge>;
|
||||
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">正常</Badge>;
|
||||
case 'down':
|
||||
return <Badge className="bg-red-500/15 text-red-400 border-red-500/30">异常</Badge>;
|
||||
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30">异常</Badge>;
|
||||
default:
|
||||
return <Badge className="bg-gray-500/15 text-gray-400 border-gray-500/30">未知</Badge>;
|
||||
return <Badge className="dg-badge-muted">未知</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,9 +300,9 @@ export function SiteMonitor() {
|
||||
{[
|
||||
{ label: '总任务数', value: stats?.total_tasks ?? '--' },
|
||||
{ label: '已启用', value: stats?.enabled_tasks ?? '--' },
|
||||
{ label: '正常', value: stats?.up_count ?? '--', color: 'text-emerald-400' },
|
||||
{ label: '异常', value: stats?.down_count ?? '--', color: 'text-red-400' },
|
||||
{ label: '可用率', value: availStr, color: 'text-blue-400' },
|
||||
{ label: '正常', value: stats?.up_count ?? '--', color: 'dg-text-green' },
|
||||
{ label: '异常', value: stats?.down_count ?? '--', color: 'dg-text-red' },
|
||||
{ label: '可用率', value: availStr, color: 'dg-text-blue' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
@@ -407,7 +407,7 @@ export function SiteMonitor() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
className="h-8 w-8 dg-text-red hover:dg-text-red hover:bg-red-500/10"
|
||||
onClick={() => handleDelete(task)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
@@ -33,12 +33,12 @@ export function DualLineChart({
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Network className="h-4 w-4 text-blue-400" />
|
||||
<Network className="h-4 w-4 dg-text-blue" />
|
||||
<span className="text-xs text-[var(--dg-text-primary)] font-medium">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-blue-400 mr-1 align-middle" />接收 <b className="text-blue-300">{currentRx}</b></span>
|
||||
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-emerald-400 mr-1 align-middle" />发送 <b className="text-emerald-300">{currentTx}</b></span>
|
||||
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-emerald-400 mr-1 align-middle" />发送 <b className="dg-text-green">{currentTx}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-56">
|
||||
|
||||
@@ -110,9 +110,9 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{p.last_status === 'up' ? <CheckCircle className="w-4 h-4 text-emerald-400" /> :
|
||||
p.last_status === 'down' ? <XCircle className="w-4 h-4 text-red-400" /> :
|
||||
<Clock className="w-4 h-4 text-slate-500" />}
|
||||
{p.last_status === 'up' ? <CheckCircle className="w-4 h-4 dg-text-green" /> :
|
||||
p.last_status === 'down' ? <XCircle className="w-4 h-4 dg-text-red" /> :
|
||||
<Clock className="w-4 h-4 dg-text-muted" />}
|
||||
<span className="text-xs font-medium text-[var(--dg-text-primary)]">{String(p.name)}</span>
|
||||
<Badge variant="outline" className="text-[10px] h-5 px-1.5 capitalize">
|
||||
{p.probe_type === 'http_get' ? 'HTTP' : p.probe_type === 'tcp_socket' ? 'TCP' : 'Exec'}
|
||||
@@ -122,7 +122,7 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => openEditDialog(p)}>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-400 hover:text-red-300" onClick={() => handleDeleteProbe(p)}>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 dg-text-red hover:dg-text-red" onClick={() => handleDeleteProbe(p)}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -138,11 +138,11 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
<div className="truncate font-mono">{String(p.exec_command)}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span>状态: <span className={p.last_status === 'up' ? 'text-emerald-400' : p.last_status === 'down' ? 'text-red-400' : 'text-slate-400'}>
|
||||
<span>状态: <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() : '未检测'}
|
||||
</span></span>
|
||||
{Number(p.last_latency_ms) > 0 && <span>延迟: {String(p.last_latency_ms)}ms</span>}
|
||||
{Number(p.fail_count) > 0 && <span className="text-red-400">连续失败: {String(p.fail_count)}次</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>
|
||||
@@ -165,7 +165,7 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
</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">探针名称 <span className="text-red-400">*</span></label>
|
||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">探针名称 <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="如:cex-agent 健康检查" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -183,7 +183,7 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
|
||||
{probeForm.probe_type === 'http_get' && (
|
||||
<>
|
||||
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">HTTP URL <span className="text-red-400">*</span></label>
|
||||
<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">期望状态码</label>
|
||||
@@ -204,7 +204,7 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
)}
|
||||
|
||||
{probeForm.probe_type === 'exec_command' && (
|
||||
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">执行命令 <span className="text-red-400">*</span></label>
|
||||
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">执行命令 <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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -38,13 +38,13 @@ export function PortPolicyDialog({
|
||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block">过滤模式</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 text-blue-400' : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
|
||||
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')}
|
||||
>
|
||||
白名单 ─ 全量上报
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 h-9 rounded text-xs font-medium border transition-colors ${portPolicyMode === 'blacklist' ? 'bg-amber-500/20 border-amber-500 text-amber-400' : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
|
||||
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')}
|
||||
>
|
||||
黑名单 ─ 仅上报选中
|
||||
@@ -60,7 +60,7 @@ export function PortPolicyDialog({
|
||||
<div>
|
||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block">
|
||||
端口列表 <span className="text-[var(--dg-text-muted)]">(逗号分隔,如: 80,443,3306,6379)</span>
|
||||
{portPolicyMode === 'whitelist' && <span className="text-blue-400 ml-1">— 白名单模式无需配置</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 ${
|
||||
|
||||
@@ -55,9 +55,9 @@ export function SystemInfoSection({
|
||||
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">系统负载 (Load Average)</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-xs">
|
||||
<div><span className="text-[var(--dg-text-muted)]">1min </span><span className="text-sm font-bold text-slate-200">{latest.load1 ?? '--'}</span></div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">5min </span><span className="text-sm font-bold text-slate-200">{latest.load5 ?? '--'}</span></div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">15min </span><span className="text-sm font-bold text-slate-200">{latest.load15 ?? '--'}</span></div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">1min </span><span className="text-sm font-bold text-[var(--dg-text-primary)]">{latest.load1 ?? '--'}</span></div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">5min </span><span className="text-sm font-bold text-[var(--dg-text-primary)]">{latest.load5 ?? '--'}</span></div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">15min </span><span className="text-sm font-bold text-[var(--dg-text-primary)]">{latest.load15 ?? '--'}</span></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -71,11 +71,11 @@ export function SystemInfoSection({
|
||||
{parts.map((p: JsonData, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-[var(--dg-text-muted)] w-24 font-mono truncate">{p.mount || p.mountpoint || p.device || `disk${i}`}</span>
|
||||
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className="flex-1 h-1.5 bg-[var(--dg-bg-active)] rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full bg-blue-500" style={{ width: `${Math.min(p.usage_percent || p.used_percent || 0, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-[var(--dg-text-primary)] w-28 text-right tabular-nums whitespace-nowrap">{(p.used_gb ?? 0).toFixed(1)}/{(p.total_gb ?? 0).toFixed(0)}GB</span>
|
||||
<span className="text-blue-400 w-14 text-right tabular-nums whitespace-nowrap">{(p.usage_percent ?? p.used_percent ?? 0).toFixed(1)}%</span>
|
||||
<span className="dg-text-blue w-14 text-right tabular-nums whitespace-nowrap">{(p.usage_percent ?? p.used_percent ?? 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user