feat: 全站 PageShell 设计系统与专业 UI 统一

- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架
- index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩)
- 全站路由页接入 PageShell,云账单三 Tab 为标杆
- 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化
- 登录页/侧栏/顶栏去渐变光球,企业克制风
- 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
This commit is contained in:
Logan committed 2026-07-01 12:45:38 +08:00
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>
</>
);
}
+108 -108
View File
@@ -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>
);
+56 -45
View File
@@ -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>
);
}
+17 -30
View File
@@ -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>
);
+133 -107
View File
@@ -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>
);
}
+63 -80
View File
@@ -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>
+25 -54
View File
@@ -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>
);
}
}
+14 -14
View File
@@ -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" />
+15 -15
View File
@@ -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>