feat(audit): F1+F2 告警静默规则标记完成 + 审计日志CSV导出实现

F1 告警静默规则 [done]:
- 后端 alert_silence.go: CRUD + Toggle (onetime/daily/weekly 三模式)
- 告警引擎 isSilenced 集成 (alert_engine.go)
- 前端 SilenceRuleTab + SilenceDialog
- 路由 /alerts/silences 已注册

F2 审计日志导出 [done]:
- 重写 ExportAuditLogs: 真正的 CSV 导出 (encoding/csv + UTF-8 BOM)
- 支持 module/action/username/startTime/endTime 筛选
- 导出限制 10000 条防溢出,自动记录审计日志
- JSON 格式向后兼容 (format=json)
- 前端 exportLogs 传递查询参数

同时包含累积的前期工作:
- 目录重构 internal/ -> controller/model/router (v0.55-v0.57)
- 后端分层架构 dto/repository/service/utils
- 审计模块全功能 (终端审计/操作审计/登录日志)
- asciicast v2 录制格式
- SFTP 文件浏览器
- StatStrip 紧凑统计条
- 在线会话自动刷新
- LiveMonitor WebSocket 诊断修复

验证: go build ✅ | npm run build ✅ | Docker 部署 ✅ | 10 回归端点 ✅ | CSV 导出 ✅ | 静默 CRUD ✅
This commit is contained in:
attacker committed 2026-06-25 20:16:30 +08:00
1 parent e57718998a
commit 77c234b060
314 files changed
+10050 -4065

No files matched your search

+9 -18
View File
@@ -11,12 +11,20 @@ server {
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# HTML 不缓存 — 确保每次加载最新 JS bundle(关键!)
location ~* \.html$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri =404;
}
# SPA 前端路由(history / hash 模式兼容)
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源长缓存
# 静态资源长缓存(Vite 已做 content hash,文件名不变=内容不变)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
@@ -47,21 +55,4 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
# Agent 安装脚本和二进制文件代理 → 后端
location /agent/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 健康检查代理
location /health {
proxy_pass http://backend:8080;
}
location /version {
proxy_pass http://backend:8080;
}
}
+12 -2
View File
@@ -32,8 +32,12 @@ const EventsPage = lazy(() => import('@/components/kubernetes/EventsPage').then(
const CapacityPage = lazy(() => import('@/components/kubernetes/CapacityPage').then(m => ({ default: m.default })));
const AlertManager = lazy(() => import('@/components/monitor/AlertManager').then(m => ({ default: m.AlertManager })));
const ApprovalFlowManager = lazy(() => import('@/components/approval/ApprovalFlow').then(m => ({ default: m.ApprovalFlowManager })));
const ApprovalResourcePage = lazy(() => import('@/pages/ApprovalResourcePage').then(m => ({ default: m.ApprovalResourcePage })));
const ApprovalAppPage = lazy(() => import('@/pages/ApprovalAppPage').then(m => ({ default: m.ApprovalAppPage })));
const ApprovalPermissionPage = lazy(() => import('@/pages/ApprovalPermissionPage').then(m => ({ default: m.ApprovalPermissionPage })));
const ScriptManager = lazy(() => import('@/components/automation/ScriptManager').then(m => ({ default: m.ScriptManager })));
const SystemManager = lazy(() => import('@/components/system/SystemManager').then(m => ({ default: m.SystemManager })));
const AuditPage = lazy(() => import('@/pages/AuditPage').then(m => ({ default: m.AuditPage })));
const CredentialManager = lazy(() => import('@/components/deploy/CredentialManager').then(m => ({ default: m.default })));
const PipelineManager = lazy(() => import('@/components/deploy/PipelineManager').then(m => ({ default: m.PipelineManager })));
const DeployCredentials = lazy(() => import('@/components/deploy/DeployCredentials').then(m => ({ default: m.default })));
@@ -173,6 +177,9 @@ function App() {
{/* Automation */}
<Route path="approval" element={<LazyPage><ApprovalFlowManager /></LazyPage>} />
<Route path="approval/resource" element={<LazyPage><ApprovalResourcePage /></LazyPage>} />
<Route path="approval/app" element={<LazyPage><ApprovalAppPage /></LazyPage>} />
<Route path="approval/permission" element={<LazyPage><ApprovalPermissionPage /></LazyPage>} />
<Route path="cronjobs" element={<LazyPage><CronJobManager /></LazyPage>} />
<Route path="scripts" element={<LazyPage><ScriptManager /></LazyPage>} />
@@ -182,11 +189,14 @@ function App() {
<Route path="ai-alert-analyzer" element={<LazyPage><AIAlertAnalyzer /></LazyPage>} />
<Route path="ai-predictive" element={<LazyPage><AIPredictiveAnalytics /></LazyPage>} />
{/* Audit */}
<Route path="audit/terminal" element={<LazyPage><AuditPage /></LazyPage>} />
<Route path="audit/operations" element={<LazyPage><AuditPage /></LazyPage>} />
<Route path="audit/login-logs" element={<LazyPage><AuditPage /></LazyPage>} />
{/* System */}
<Route path="users" element={<LazyPage><SystemManager /></LazyPage>} />
<Route path="roles" element={<LazyPage><SystemManager /></LazyPage>} />
<Route path="logs-audit" element={<LazyPage><SystemManager /></LazyPage>} />
<Route path="terminal-audit" element={<LazyPage><SystemManager /></LazyPage>} />
<Route path="credentials" element={<LazyPage><CredentialManager /></LazyPage>} />
<Route path="settings" element={<LazyPage><SystemManager /></LazyPage>} />
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { StatStrip } from '@/components/ui/stat-strip';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -137,75 +138,14 @@ export function AIPredictiveAnalytics() {
</div>
{/* Overview Cards */}
<div className="grid grid-cols-4 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-400">预测置信度</p>
<p className="text-2xl font-bold text-slate-100">{accuracy}%</p>
</div>
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-emerald-400" />
</div>
</div>
<div className="flex items-center gap-1 mt-2 text-xs text-slate-500">
<span>基于 {rawMetrics.length} 个时序数据点回归拟合 R²</span>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-400">高风险预测</p>
<p className="text-2xl font-bold text-slate-100">{predictedAlertCount}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-amber-400" />
</div>
</div>
<div className="flex items-center gap-1 mt-2 text-xs text-amber-400">
<span>概率 ≥50% 的异常预测</span>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-400">容量预警</p>
<p className="text-2xl font-bold text-slate-100">{capacityWarningCount}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
<HardDrive className="h-5 w-5 text-red-400" />
</div>
</div>
<div className="flex items-center gap-1 mt-2 text-xs text-slate-500">
<span>14天内触及阈值</span>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-400">监控指标数</p>
<p className="text-2xl font-bold text-slate-100">{predictions.length}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Sparkles className="h-5 w-5 text-purple-400" />
</div>
</div>
<div className="flex items-center gap-1 mt-2 text-xs text-slate-500">
<span>CPU / 内存 / 磁盘</span>
</div>
</CardContent>
</Card>
</div>
<StatStrip
items={[
{ label: '置信度', value: `${accuracy}%`, color: 'emerald' },
{ label: '高风险', value: predictedAlertCount, color: 'amber' },
{ label: '容量预警', value: capacityWarningCount, color: 'red' },
{ label: '指标', value: predictions.length, color: 'purple' },
]}
/>
<Tabs defaultValue="metrics" className="space-y-4">
<TabsList className="bg-slate-800 border border-slate-700">
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { StatStrip } from '@/components/ui/stat-strip';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -194,38 +195,14 @@ export function CronJobManager() {
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<p className="text-[var(--dg-text-secondary)] text-sm">任务总数</p>
<p className="text-2xl font-bold text-[var(--dg-text-primary)]">{jobs.length}</p>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<p className="text-[var(--dg-text-secondary)] text-sm">已启用</p>
<p className="text-2xl font-bold text-emerald-400">
{jobs.filter((j) => j.enabled).length}
</p>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<p className="text-[var(--dg-text-secondary)] text-sm">累计成功</p>
<p className="text-2xl font-bold text-emerald-400">
{jobs.reduce((sum, j) => sum + (j.success_count || 0), 0)}
</p>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<p className="text-[var(--dg-text-secondary)] text-sm">累计失败</p>
<p className="text-2xl font-bold text-red-400">
{jobs.reduce((sum, j) => sum + (j.fail_count || 0), 0)}
</p>
</CardContent>
</Card>
</div>
<StatStrip
items={[
{ label: '任务总数', value: jobs.length, color: 'blue' },
{ label: '已启用', value: jobs.filter((j) => j.enabled).length, color: 'emerald' },
{ label: '累计成功', value: jobs.reduce((sum, j) => sum + (j.success_count || 0), 0), color: 'emerald' },
{ label: '累计失败', value: jobs.reduce((sum, j) => sum + (j.fail_count || 0), 0), color: 'red' },
]}
/>
{/* Filters */}
<div className="flex items-center justify-between flex-wrap gap-4">
@@ -1,70 +0,0 @@
// ScriptStatsCards — 脚本库统计卡片 // Logan.Li
import { FileCode, Loader2, CheckCircle, XCircle } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
interface Props {
total: number;
running: number;
todaySuccess: number;
todayFailed: number;
}
// 四个统计卡片:脚本总数 / 执行中 / 今日成功 / 今日失败 // Logan.Li
export default function ScriptStatsCards({ total, running, todaySuccess, todayFailed }: Props) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">脚本总数</p>
<p className="text-2xl font-bold">{total}</p>
</div>
<div className="p-3 bg-purple-500/10 rounded-lg">
<FileCode className="w-5 h-5 text-purple-500" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">执行中</p>
<p className="text-2xl font-bold text-blue-600">{running}</p>
</div>
<div className="p-3 bg-blue-500/10 rounded-lg">
<Loader2 className="w-5 h-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">今日成功</p>
<p className="text-2xl font-bold text-green-600">{todaySuccess}</p>
</div>
<div className="p-3 bg-green-500/10 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">今日失败</p>
<p className="text-2xl font-bold text-red-600">{todayFailed}</p>
</div>
<div className="p-3 bg-red-500/10 rounded-lg">
<XCircle className="w-5 h-5 text-red-600" />
</div>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -172,62 +172,6 @@ export function CloudAccountManager() {
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">账号总数</p>
<p className="text-2xl font-bold text-slate-100 mt-1">{stats.total}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Cloud className="h-5 w-5 text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">正常</p>
<p className="text-2xl font-bold text-green-400 mt-1">{stats.active}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<CheckCircle2 className="h-5 w-5 text-green-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">已停用</p>
<p className="text-2xl font-bold text-slate-400 mt-1">{stats.inactive}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-slate-500/20 flex items-center justify-center">
<XCircle className="h-5 w-5 text-slate-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">异常</p>
<p className="text-2xl font-bold text-red-400 mt-1">{stats.error}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
<Key className="h-5 w-5 text-red-400" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
+9 -25
View File
@@ -28,6 +28,7 @@ import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { billingApi } from '@/services/api';
import { formatCurrency, StatCard, TrendChart, providerLabels, providerColors } from './CloudBillingHelpers';
import { StatStrip } from '@/components/ui/stat-strip';
import { BillingRecordDialog } from './BillingRecordDialog';
import { BillingRuleDialog } from './BillingRuleDialog';
@@ -208,31 +209,14 @@ export function CloudBilling() {
<div className="flex items-center justify-center py-20 text-slate-500"><Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...</div>
) : stats ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard
icon={<Wallet className="h-5 w-5" />}
label="今日成本"
value={formatCurrency(stats.today_cost || 0)}
trend={stats.today_cost - (stats.yesterday_cost || 0)}
trendLabel={`昨日 ${formatCurrency(stats.yesterday_cost || 0)}`}
/>
<StatCard
icon={<CalendarDays className="h-5 w-5" />}
label="本月成本"
value={formatCurrency(stats.month_cost || 0)}
/>
<StatCard
icon={<TrendingUp className="h-5 w-5" />}
label="年度成本"
value={formatCurrency(stats.year_cost || 0)}
/>
<StatCard
icon={<AlertTriangle className="h-5 w-5" />}
label="活跃预警"
value={`${stats.alerts?.length || 0} 条`}
alert={stats.alerts?.length > 0}
/>
</div>
<StatStrip
items={[
{ label: '今日', value: formatCurrency(stats.today_cost || 0), color: 'blue' },
{ label: '本月', value: formatCurrency(stats.month_cost || 0), color: 'amber' },
{ label: '年度', value: formatCurrency(stats.year_cost || 0), color: 'purple' },
{ label: '预警', value: `${stats.alerts?.length || 0} 条`, color: stats.alerts?.length > 0 ? 'red' : 'muted' },
]}
/>
{stats.alerts?.length > 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
@@ -178,75 +178,6 @@ export function DomainManager() {
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-5 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">域名总数</p>
<p className="text-2xl font-bold text-slate-100 mt-1">{stats.total}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Globe className="h-5 w-5 text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">正常</p>
<p className="text-2xl font-bold text-green-400 mt-1">{stats.active}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<CheckCircle2 className="h-5 w-5 text-green-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">已过期</p>
<p className="text-2xl font-bold text-red-400 mt-1">{stats.expired}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
<XCircle className="h-5 w-5 text-red-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">待审核</p>
<p className="text-2xl font-bold text-yellow-400 mt-1">{stats.pending}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<Clock className="h-5 w-5 text-yellow-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">SSL证书</p>
<p className="text-2xl font-bold text-cyan-400 mt-1">{stats.sslEnabled}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center">
<CheckCircle2 className="h-5 w-5 text-cyan-400" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
+16 -54
View File
@@ -2,10 +2,10 @@ import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Clock, Server, Cloud, Bell, DollarSign, ArrowRight} from 'lucide-react';
import { Clock, Server, ArrowRight } from 'lucide-react';
import { dashboardStatsApi, serverApi, kubernetesApi } from '@/services/api';
import { StatCard } from './DashboardStats';
import { CostOverviewCard, TopResourceUsageCard, ResourceUsageCard, computeMonthChange } from './DashboardCharts';
import { StatStrip } from '@/components/ui/stat-strip';
import { CostOverviewCard, TopResourceUsageCard, ResourceUsageCard } from './DashboardCharts';
import {
AlertMessagesCard,
CertificateExpiryCard,
@@ -98,7 +98,6 @@ export function Dashboard() {
const k8sStats = stats?.kubernetes_stats;
const alertStats = stats?.alert_stats;
const costStats = stats?.cost_stats;
const costChange = computeMonthChange(costStats?.month_trend);
return (
<div className="dg-page">
@@ -129,56 +128,19 @@ export function Dashboard() {
</div>
</div>
{/* Stats Grid with staggered animation */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="animate-fade-in-delay-1">
<StatCard
title="服务器总数"
value={serverStats?.total ?? 0}
description={`${serverStats?.running ?? 0} 台运行中`}
icon={Server}
trend={Number(serverStats?.error ?? 0) > 0 ? 'down' : 'neutral'}
trendValue={Number(serverStats?.error ?? 0) > 0 ? `${serverStats?.error} 台异常` : '运行正常'}
color="blue"
onClick={() => navigate('/servers')}
/>
</div>
<div className="animate-fade-in-delay-2">
<StatCard
title="Kubernetes 集群"
value={k8sStats?.total ?? 0}
description={`${k8sStats?.healthy ?? 0} 个健康`}
icon={Cloud}
trend="neutral"
trendValue="稳定"
color="purple"
onClick={() => navigate('/kubernetes')}
/>
</div>
<div className="animate-fade-in-delay-3">
<StatCard
title="待处理告警"
value={alertStats?.firing ?? 0}
description={`${alertStats?.critical ?? 0} 个严重`}
icon={Bell}
trend={Number(alertStats?.today_count ?? 0) > 0 ? 'up' : 'down'}
trendValue={`本周 ${alertStats?.week_count ?? 0} 条`}
color="rose"
onClick={() => navigate('/alert-center')}
/>
</div>
<div className="animate-fade-in-delay-4">
<StatCard
title="本月云成本"
value={`¥${Number(costStats?.month_cost ?? 0).toLocaleString()}`}
description={costChange.label}
icon={DollarSign}
trend={costChange.direction}
trendValue={`${costChange.pct > 0 ? '+' : ''}${costChange.pct.toFixed(1)}%`}
color="orange"
onClick={() => navigate('/cloud-billing')}
/>
</div>
{/* Stats — compact strip */}
<div className="animate-fade-in-delay-1">
<StatStrip
items={[
{ label: '服务器', value: serverStats?.total ?? 0, color: 'blue' },
{ label: '运行中', value: serverStats?.running ?? 0, color: 'emerald' },
{ label: 'K8s 集群', value: k8sStats?.total ?? 0, color: 'purple' },
{ label: '健康集群', value: k8sStats?.healthy ?? 0, color: 'cyan' },
{ label: '告警', value: alertStats?.firing ?? 0, color: alertStats?.firing > 0 ? 'red' : 'muted' },
{ label: '严重', value: alertStats?.critical ?? 0, color: 'red' },
{ label: '本月成本', value: `¥${Number(costStats?.month_cost ?? 0).toLocaleString()}`, color: 'amber' },
]}
/>
</div>
{/* Resource Usage Chart */}
@@ -296,22 +296,6 @@ export function PipelineManager() {
</div>
</div>
<div className="grid grid-cols-4 gap-4">
{[
{ label: '流水线总数', val: stats.total, icon: Workflow, color: '#3B82F6' },
{ label: '运行中', val: stats.running, icon: Loader2, color: '#3B82F6' },
{ label: '成功', val: stats.success, icon: CheckCircle, color: '#10B981' },
{ label: '失败', val: stats.failed, icon: XCircle, color: '#EF4444' },
].map((s, i) => (
<Card key={i} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4 flex items-center justify-between">
<div><p className="text-[var(--dg-text-secondary)] text-sm">{s.label}</p><p className="text-2xl font-bold text-[var(--dg-text-primary)]">{s.val}</p></div>
<div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ backgroundColor: s.color + '20' }}><s.icon className="h-5 w-5" style={{ color: s.color }} /></div>
</CardContent>
</Card>
))}
</div>
{/* ══════════ 全流程可视化横幅 — 双路径 ══════════ */}
<div className="flex items-stretch gap-3 px-4 py-3 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
{/* 路径 1: SSH 传统主机部署 */}
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { StatStrip } from '@/components/ui/stat-strip';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
@@ -139,29 +140,15 @@ export default function ClusterComparePage() {
)}
{/* 概览卡片 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{[
{ label: '集群总数', value: totals.cluster_count || 0, icon: Cloud, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '节点总数', value: totals.node_count || 0, icon: Cpu, color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
{ label: 'Pod 总数', value: totals.pod_count || 0, icon: Container, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: 'Deployment 总数', value: totals.deployment_count || 0, icon: Boxes, color: 'text-amber-400', bg: 'bg-amber-500/10' },
{ label: 'Service 总数', value: totals.service_count || 0, icon: Network, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
].map((s, i) => (
<Card key={i} className="bg-slate-900/60 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-500 mb-1">{s.label}</p>
<p className="text-2xl font-bold">{s.value}</p>
</div>
<div className={`p-2.5 rounded-lg ${s.bg}`}>
<s.icon className={`h-5 w-5 ${s.color}`} />
</div>
</div>
</CardContent>
</Card>
))}
</div>
<StatStrip
items={[
{ label: '集群', value: totals.cluster_count || 0, color: 'blue' },
{ label: '节点', value: totals.node_count || 0, color: 'emerald' },
{ label: 'Pod', value: totals.pod_count || 0, color: 'purple' },
{ label: 'Deploy', value: totals.deployment_count || 0, color: 'amber' },
{ label: 'Service', value: totals.service_count || 0, color: 'cyan' },
]}
/>
{clusters.length === 0 && !loading && (
<Card className="bg-slate-900/60 border-slate-800">
@@ -185,68 +185,6 @@ export function KubernetesClusters() {
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">集群总数</p>
<p className="text-2xl font-bold text-slate-100">{clusters.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Cloud className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">健康集群</p>
<p className="text-2xl font-bold text-emerald-400">
{clusters.filter((c) => c.status === 'healthy').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<CheckCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">总节点数</p>
<p className="text-2xl font-bold text-slate-100">
{clusters.reduce((acc, c) => acc + (c.node_count || 0), 0)}
</p>
</div>
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
<Server className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">总Pod数</p>
<p className="text-2xl font-bold text-slate-100">
{clusters.reduce((acc, c) => acc + (c.pod_count || 0), 0)}
</p>
</div>
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
<Database className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Search */}
<div className="flex items-center gap-3">
<div className="relative w-80">
@@ -81,22 +81,6 @@ export function KubernetesNodes() {
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: '总节点', value: nodes.length, color: 'text-blue-400' },
{ label: 'Master', value: nodes.filter((n) => n.role === 'master').length, color: 'text-purple-400' },
{ label: 'Worker', value: nodes.filter((n) => n.role === 'worker').length, color: 'text-emerald-400' },
{ label: '就绪', value: nodes.filter((n) => n.status === 'Ready').length, color: 'text-cyan-400' },
].map((s) => (
<Card key={s.label} className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<p className="text-sm text-slate-500">{s.label}</p>
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
</CardContent>
</Card>
))}
</div>
<div className="flex items-center gap-3">
<div className="relative w-56">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
@@ -48,6 +48,9 @@ const ROUTE_NAME_MAP: Record<string, string> = {
'host-monitor': '主机监控',
'alerts': '监控告警',
'approval': '资源申请',
'approval-resource': '资源申请',
'approval-app': '应用申请',
'approval-permission': '权限审批',
'scripts': '脚本库',
'ansible': 'Ansible',
'ai-assistant': '智能助手',
+2 -104
View File
@@ -1,9 +1,8 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { useThemeStore, useNotificationStore, useUserStore } from '@/store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
DropdownMenu,
@@ -14,7 +13,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Search,
Bell,
Menu,
Settings,
@@ -30,30 +28,18 @@ import {
import { formatDistanceToNow } from '@/lib/utils';
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
// Keyboard shortcut handler
// Keyboard shortcut handler — Cmd/Ctrl+B 切换侧边栏
function useKeyboardShortcuts() {
const _navigate = useNavigate();
const [searchOpen, setSearchOpen] = useState(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Cmd/Ctrl + K to open search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setSearchOpen((prev) => !prev);
}
// Cmd/Ctrl + B to toggle sidebar
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
e.preventDefault();
// Dispatch custom event for sidebar toggle
window.dispatchEvent(new CustomEvent('toggle-sidebar'));
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
return { searchOpen, setSearchOpen };
}
export function Header() {
@@ -62,9 +48,6 @@ export function Header() {
const { user, logout } = useUserStore();
const { theme, setTheme } = useTheme();
const navigate = useNavigate();
const [searchValue, setSearchValue] = useState('');
const [searchFocused, setSearchFocused] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
useKeyboardShortcuts();
const handleLogout = () => {
@@ -87,18 +70,6 @@ export function Header() {
}
}, []);
// Quick nav items for search
const quickNavItems = [
{ name: '仪表盘', path: '/', icon: 'LayoutDashboard' },
{ name: '服务器管理', path: '/servers', icon: 'Server' },
{ name: 'Kubernetes 集群', path: '/kubernetes', icon: 'Cloud' },
{ name: '监控告警', path: '/alerts', icon: 'Bell' },
{ name: '告警中心', path: '/alert-center', icon: 'AlertTriangle' },
{ name: '流水线', path: '/pipelines', icon: 'GitBranch' },
{ name: '智能助手', path: '/ai-assistant', icon: 'Brain' },
{ name: '脚本库', path: '/scripts', icon: 'FileCode' },
];
return (
<header
className={cn(
@@ -121,79 +92,6 @@ export function Header() {
>
<Menu className="h-4 w-4" />
</Button>
{/* Global Search */}
<div className={cn(
'relative transition-all duration-300',
searchFocused ? 'w-[28rem] max-w-full' : 'w-80'
)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)] pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="搜索服务器、应用、工单、文档..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setTimeout(() => setSearchFocused(false), 200)}
className={cn(
'pl-9 h-9 text-sm transition-all duration-300',
'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]',
'text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]',
'focus:border-[var(--dg-blue)] focus:ring-1 focus:ring-[var(--dg-blue)]/30',
'hover:border-[var(--dg-border-hover)]',
)}
/>
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono text-[var(--dg-text-muted)] bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded hidden sm:inline-block">
⌘K
</kbd>
{/* Search Dropdown */}
{searchFocused && searchValue && (
<div
className={cn(
'absolute top-full left-0 right-0 mt-2 py-2 rounded-lg',
'bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]',
'shadow-lg z-50 animate-scale-in',
)}
style={{ boxShadow: 'var(--shadow-dropdown)' }}
>
<div className="px-3 py-1.5 text-xs font-medium text-[var(--dg-text-muted)] uppercase tracking-wider">
快速导航
</div>
{quickNavItems
.filter((item) =>
item.name.toLowerCase().includes(searchValue.toLowerCase())
)
.map((item) => (
<button
key={item.path}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-sm',
'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]',
'hover:bg-[var(--dg-bg-hover)] transition-colors text-left',
)}
onMouseDown={(e) => {
e.preventDefault();
navigate(item.path);
setSearchValue('');
setSearchFocused(false);
}}
>
<Search className="h-3.5 w-3.5" />
<span>{item.name}</span>
</button>
))}
{quickNavItems.filter((item) =>
item.name.toLowerCase().includes(searchValue.toLowerCase())
).length === 0 && (
<div className="px-3 py-4 text-center text-sm text-[var(--dg-text-muted)]">
未找到匹配的结果
</div>
)}
</div>
)}
</div>
</div>
{/* Right: Actions */}
+18 -3
View File
@@ -35,6 +35,9 @@ import {
Boxes,
Clock,
Rocket,
ShieldCheck,
FileSearch,
LogIn,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -132,14 +135,26 @@ const menuItems: MenuItem[] = [
{ id: 'ai-predictive', name: '容量预测', icon: BarChart3, path: '/ai-predictive' },
],
},
{ id: 'approval', name: '资源申请', icon: CheckSquare, path: '/approval' },
{ id: 'approval', name: '资源申请', icon: CheckSquare,
children: [
{ id: 'approval-resource', name: '资源申请', icon: Server, path: '/approval/resource' },
{ id: 'approval-app', name: '应用申请', icon: Rocket, path: '/approval/app' },
{ id: 'approval-permission', name: '权限审批', icon: ShieldCheck, path: '/approval/permission' },
],
},
{
id: 'audit', name: '安全审计', icon: FileSearch,
children: [
{ id: 'audit-terminal', name: '终端审计', icon: Terminal, path: '/audit/terminal' },
{ id: 'audit-operations', name: '操作审计', icon: FileCode, path: '/audit/operations' },
{ id: 'audit-login', name: '登录日志', icon: LogIn, path: '/audit/login-logs' },
],
},
{
id: 'system', name: '系统管理', icon: Settings,
children: [
{ id: 'users', name: '用户管理', icon: Users, path: '/users' },
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
{ id: 'logs-audit', name: '操作审计', icon: FileCode, path: '/logs-audit' },
{ id: 'terminal-audit', name: '终端审计', icon: Terminal, path: '/terminal-audit' },
{ id: 'credentials', name: '凭证配置', icon: Shield, path: '/credentials' },
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings' },
],
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { StatStrip } from '@/components/ui/stat-strip';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
@@ -24,6 +25,7 @@ import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
import { ContactDialog } from './ContactDialog';
import { ContactGroupDialog } from './ContactGroupDialog';
import SilenceRuleTab from './SilenceRuleTab';
import type { Contact, ContactGroup } from './alert-center-helpers';
const AlertCenter: React.FC = () => {
@@ -151,8 +153,21 @@ const AlertCenter: React.FC = () => {
const handleSilence = async (id: string, duration: string) => {
try {
await alertCenterApi.silence(id, duration);
toast.success('告警已静默');
const alert = alerts.find(a => a.id === id);
const now = new Date();
const match = duration.match(/^(\d+)([hd])$/);
const num = match ? parseInt(match[1]) : 1;
const unit = match?.[2] || 'h';
const hours = unit === 'd' ? num * 24 : num;
const endTime = new Date(now.getTime() + hours * 3600_000);
await alertCenterApi.createSilence({
name: `静默: ${alert?.title || id} (${duration})`,
description: `从告警列表快速静默 — ${alert?.source || ''}`,
silence_type: 'onetime',
start_time: now.toISOString(),
end_time: endTime.toISOString(),
});
toast.success(`告警已静默 ${duration}`);
setSilenceOpen(false);
fetchAlerts();
} catch {
@@ -205,66 +220,21 @@ const AlertCenter: React.FC = () => {
return (
<div className="space-y-3">
{/* 统计卡片 — 紧凑 */}
<div className="grid grid-cols-4 gap-3">
<Card className={stats.firing > 0 ? 'border-red-500 border-2' : ''}>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">触发中</p>
<p className={`text-xl font-bold ${stats.firing > 0 ? 'text-red-600' : ''}`}>{stats.firing}</p>
</div>
<div className={`p-2 rounded-md ${stats.firing > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
<Zap className={`w-4 h-4 ${stats.firing > 0 ? 'text-red-600' : 'text-gray-600'}`} />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">严重告警</p>
<p className="text-xl font-bold text-red-600">{stats.critical}</p>
</div>
<div className="p-2 bg-red-100 rounded-md">
<AlertCircle className="w-4 h-4 text-red-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">警告告警</p>
<p className="text-xl font-bold text-orange-600">{stats.warning}</p>
</div>
<div className="p-2 bg-orange-100 rounded-md">
<AlertTriangle className="w-4 h-4 text-orange-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">今日已解决</p>
<p className="text-xl font-bold text-green-600">{stats.resolved}</p>
</div>
<div className="p-2 bg-green-100 rounded-md">
<CheckCircle className="w-4 h-4 text-green-600" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 统计 — 紧凑条 */}
<StatStrip
items={[
{ label: '触发中', value: stats.firing, color: stats.firing > 0 ? 'red' : 'muted' },
{ label: '严重', value: stats.critical, color: 'red' },
{ label: '警告', value: stats.warning, color: 'amber' },
{ label: '已解决', value: stats.resolved, color: 'emerald' },
]}
/>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4 lg:w-[480px] h-9">
<TabsList className="grid w-full grid-cols-5 lg:w-[600px] h-9">
<TabsTrigger value="alerts" className="text-xs">告警列表</TabsTrigger>
<TabsTrigger value="rules" className="text-xs">告警规则</TabsTrigger>
<TabsTrigger value="silences" className="text-xs">静默规则</TabsTrigger>
<TabsTrigger value="contacts" className="text-xs">报警联系人</TabsTrigger>
<TabsTrigger value="groups" className="text-xs">报警联系组</TabsTrigger>
</TabsList>
@@ -501,6 +471,11 @@ const AlertCenter: React.FC = () => {
)}
</TabsContent>
{/* 静默规则 Tab */}
<TabsContent value="silences">
<SilenceRuleTab />
</TabsContent>
{/* 报警联系人 Tab */}
<TabsContent value="contacts" className="space-y-4">
<div className="flex justify-between items-center">
@@ -0,0 +1,255 @@
// 告警静默规则创建/编辑弹框 // Logan.Li
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import { alertCenterApi } from '@/services/api';
// 静默类型中文标签
const SILENCE_TYPE_LABELS: Record<string, string> = {
onetime: '一次性',
daily: '每天循环',
weekly: '每周循环',
};
const WEEKDAY_LABELS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
export interface SilenceData {
id?: number;
name: string;
description: string;
silence_type: string;
start_time: string;
end_time: string;
weekdays: number[];
}
function toLocalDateTimeInput(d: Date): string {
const off = d.getTimezoneOffset();
const local = new Date(d.getTime() - off * 60000);
return local.toISOString().slice(0, 16);
}
function addHours(d: Date, hours: number): Date {
return new Date(d.getTime() + hours * 3600_000);
}
export function SilenceDialog({
open,
onOpenChange,
editingSilence,
onSaved,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editingSilence: any;
onSaved: () => void;
}) {
const [saving, setSaving] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [silenceType, setSilenceType] = useState('onetime');
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [weekdays, setWeekdays] = useState<number[]>([]);
// 编辑时回填
useEffect(() => {
if (editingSilence) {
setName(editingSilence.name || '');
setDescription(editingSilence.description || '');
setSilenceType(editingSilence.silence_type || 'onetime');
// 解析时间
const st = editingSilence.start_time ? new Date(editingSilence.start_time) : new Date();
const et = editingSilence.end_time ? new Date(editingSilence.end_time) : addHours(new Date(), 1);
setStartTime(toLocalDateTimeInput(st));
setEndTime(toLocalDateTimeInput(et));
// 解析 weekdays
let wds: number[] = [];
if (editingSilence.weekday_list) {
wds = editingSilence.weekday_list;
} else if (editingSilence.weekdays) {
try { wds = JSON.parse(editingSilence.weekdays); } catch { /* ignore */ }
}
setWeekdays(wds);
} else {
setName('');
setDescription('');
setSilenceType('onetime');
const now = new Date();
setStartTime(toLocalDateTimeInput(now));
setEndTime(toLocalDateTimeInput(addHours(now, 2)));
setWeekdays([]);
}
}, [editingSilence, open]);
const toggleWeekday = (day: number) => {
setWeekdays(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
);
};
const handleSave = async () => {
if (!name.trim()) {
toast.error('请填写规则名称');
return;
}
if (!endTime) {
toast.error('请设置结束时间');
return;
}
if (silenceType === 'weekly' && weekdays.length === 0) {
toast.error('每周静默需要选择至少一个星期');
return;
}
const payload: Record<string, unknown> = {
name: name.trim(),
description: description.trim(),
silence_type: silenceType,
start_time: startTime,
end_time: endTime,
weekdays: silenceType === 'weekly' ? weekdays : [],
};
setSaving(true);
try {
if (editingSilence) {
await alertCenterApi.updateSilence(editingSilence.id, payload);
toast.success('静默规则已更新');
} else {
await alertCenterApi.createSilence(payload);
toast.success('静默规则已创建');
}
onOpenChange(false);
onSaved();
} catch {
toast.error('保存失败');
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{editingSilence ? '编辑静默规则' : '创建静默规则'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
静默期间匹配的告警将不会触发通知
</DialogDescription>
</DialogHeader>
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
{/* 名称 */}
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-primary)]">规则名称 *</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="如:维护窗口静默"
className="bg-[var(--dg-bg-primary)]"
/>
</div>
{/* 描述 */}
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-primary)]">描述</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="可选备注"
className="bg-[var(--dg-bg-primary)]"
/>
</div>
{/* 静默类型 */}
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-primary)]">静默类型</Label>
<Select value={silenceType} onValueChange={setSilenceType}>
<SelectTrigger className="bg-[var(--dg-bg-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SILENCE_TYPE_LABELS).map(([val, label]) => (
<SelectItem key={val} value={val}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 时间窗口 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-primary)]">
{silenceType === 'onetime' ? '开始时间' : '每日开始'}
</Label>
<Input
type="datetime-local"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="bg-[var(--dg-bg-primary)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-primary)]">
{silenceType === 'onetime' ? '结束时间' : '每日结束'}
</Label>
<Input
type="datetime-local"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="bg-[var(--dg-bg-primary)]"
/>
</div>
</div>
{/* 星期选择(仅 weekly) */}
{silenceType === 'weekly' && (
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-primary)]">生效星期</Label>
<div className="flex flex-wrap gap-2">
{WEEKDAY_LABELS.map((label, idx) => (
<button
key={idx}
type="button"
onClick={() => toggleWeekday(idx)}
className={`px-3 py-1.5 rounded-md text-xs transition-colors ${
weekdays.includes(idx)
? 'bg-primary text-primary-foreground'
: 'bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] text-[var(--dg-text-secondary)]'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{/* 提示 */}
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-2">
{silenceType === 'onetime' && '在指定时间窗口内,所有匹配条件的告警将被静默。窗口结束后自动恢复正常。'}
{silenceType === 'daily' && '每天在指定时段内静默告警,适合非工作时间免打扰。'}
{silenceType === 'weekly' && '仅在选定星期的指定时段内静默,适合周末维护窗口。'}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中…' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,226 @@
// 告警静默规则管理 Tab — 列表 + 创建/编辑/删除/启停 // Logan.Li
import React, { useState, useEffect, useCallback } from 'react';
import { VolumeX, Plus, RefreshCw, Clock, Calendar, Repeat, Edit, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { alertCenterApi } from '@/services/api';
import { SilenceDialog } from './SilenceDialog';
const SILENCE_TYPE_LABELS: Record<string, string> = {
onetime: '一次性',
daily: '每天',
weekly: '每周',
};
const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
function formatTime(t: string | Date): string {
if (!t) return '—';
const d = typeof t === 'string' ? new Date(t) : t;
if (isNaN(d.getTime())) return '—';
return d.toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
const SilenceRuleTab: React.FC = () => {
const confirm = useConfirm();
const [silences, setSilences] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const fetchSilences = useCallback(async () => {
setLoading(true);
try {
const res: Record<string, any> = await alertCenterApi.getSilences();
const list = res?.list || res?.data?.list || [];
setSilences(list);
} catch {
toast.error('获取静默规则失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSilences();
}, [fetchSilences]);
const handleAdd = () => {
setEditing(null);
setDialogOpen(true);
};
const handleEdit = (silence: any) => {
setEditing(silence);
setDialogOpen(true);
};
const handleDelete = async (silence: any) => {
const ok = await confirm({
title: '删除静默规则',
description: `确定删除「${silence.name}」?删除后该规则匹配的告警将恢复通知。`,
confirmText: '删除',
variant: 'destructive',
});
if (!ok) return;
try {
await alertCenterApi.deleteSilence(silence.id);
toast.success('已删除');
fetchSilences();
} catch {
toast.error('删除失败');
}
};
const handleToggle = async (silence: any) => {
try {
await alertCenterApi.toggleSilence(silence.id);
toast.success(silence.status === 1 ? '已禁用' : '已启用');
fetchSilences();
} catch {
toast.error('操作失败');
}
};
const isCurrentlyActive = (s: any): boolean => {
if (s.is_active !== undefined) return s.is_active;
if (s.status !== 1) return false;
const now = new Date();
const st = new Date(s.start_time);
const et = new Date(s.end_time);
return now >= st && now <= et;
};
return (
<div className="space-y-4">
{/* 操作栏 */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<VolumeX className="w-4 h-4" />
<span>共 {silences.length} 条规则</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchSilences} disabled={loading}>
<RefreshCw className="w-3.5 h-3.5 mr-1" />
刷新
</Button>
<Button size="sm" onClick={handleAdd}>
<Plus className="w-3.5 h-3.5 mr-1" />
添加静默规则
</Button>
</div>
</div>
{/* 规则列表 */}
{loading && silences.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p className="text-sm">加载中...</p>
</div>
) : silences.length === 0 ? (
<div className="text-center py-12 text-muted-foreground border border-dashed rounded-lg">
<VolumeX className="w-10 h-10 mx-auto mb-2 opacity-50" />
<p className="text-sm">暂无静默规则</p>
<p className="text-xs mt-1">点击「添加静默规则」创建维护窗口或免打扰时段</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3">
{silences.map((s) => {
const active = isCurrentlyActive(s);
const enabled = s.status === 1;
const sType = s.silence_type || 'onetime';
const weekdayList: number[] = s.weekday_list || (() => {
try { return JSON.parse(s.weekdays || '[]'); } catch { return []; }
})();
return (
<Card key={s.id} className={active ? 'border-primary/50' : ''}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
{/* 左侧信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className="font-medium text-sm">{s.name}</span>
{/* 类型 Badge */}
<Badge variant="outline" className="text-xs">
{sType === 'onetime' && <Clock className="w-3 h-3 mr-1" />}
{sType === 'daily' && <Repeat className="w-3 h-3 mr-1" />}
{sType === 'weekly' && <Calendar className="w-3 h-3 mr-1" />}
{SILENCE_TYPE_LABELS[sType] || sType}
</Badge>
{/* 活跃状态 */}
{active && (
<Badge className="bg-green-500 text-xs">生效中</Badge>
)}
{!enabled && (
<Badge variant="secondary" className="text-xs">已禁用</Badge>
)}
</div>
{s.description && (
<p className="text-xs text-muted-foreground mb-1.5">{s.description}</p>
)}
{/* 时间窗口 */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>
{sType === 'onetime'
? `${formatTime(s.start_time)} — ${formatTime(s.end_time)}`
: `每日 ${formatTime(s.start_time)} — ${formatTime(s.end_time)}`
}
</span>
{sType === 'weekly' && weekdayList.length > 0 && (
<span className="flex items-center gap-1">
{weekdayList.sort((a,b)=>a-b).map(d => (
<span key={d} className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-[10px]">
{WEEKDAY_LABELS[d]}
</span>
))}
</span>
)}
</div>
{/* 创建时间 */}
<div className="text-[10px] text-muted-foreground/60 mt-1">
创建于 {formatTime(s.created_at)}
</div>
</div>
{/* 右侧操作 */}
<div className="flex items-center gap-2 shrink-0">
<Switch
checked={enabled}
onCheckedChange={() => handleToggle(s)}
/>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleEdit(s)}>
<Edit className="w-3 h-3 mr-1" />编辑
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400" onClick={() => handleDelete(s)}>
<Trash2 className="w-3 h-3 mr-1" />删除
</Button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* 创建/编辑弹框 */}
<SilenceDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingSilence={editing}
onSaved={fetchSilences}
/>
</div>
);
};
export default SilenceRuleTab;
@@ -4,6 +4,7 @@ import { Server, Loader2, Activity, Cpu, RefreshCw, AlertTriangle, Container, Za
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { StatStrip } from '@/components/ui/stat-strip';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { http } from '@/services/http';
@@ -47,12 +48,14 @@ const Box = ( p: any) => <svg xmlns="http://www.w3.org/2000/svg" width="16" heig
const K8sPanel: React.FC<{ d: Record<string, any>}> = ({ d }) => (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-3">
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-2xl font-bold text-purple-400">{d.podCount?.[0]?.value || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Pods</div></CardContent></Card>
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-2xl font-bold text-blue-400">{d.deployCount?.[0]?.value || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Deployments</div></CardContent></Card>
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-2xl font-bold text-emerald-400">{d.nodeCount?.[0]?.value || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Nodes</div></CardContent></Card>
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-2xl font-bold text-amber-400">{d.nsPods?.length || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Namespaces</div></CardContent></Card>
</div>
<StatStrip
items={[
{ label: 'Pods', value: d.podCount?.[0]?.value || 0, color: 'purple' },
{ label: 'Deployments', value: d.deployCount?.[0]?.value || 0, color: 'blue' },
{ label: 'Nodes', value: d.nodeCount?.[0]?.value || 0, color: 'emerald' },
{ label: 'Namespaces', value: d.nsPods?.length || 0, color: 'amber' },
]}
/>
<div className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3 max-h-60 overflow-y-auto">
<div className="text-xs font-medium mb-2 flex items-center gap-1"><Cpu className="w-3 h-3 text-purple-400" />节点 CPU / 内存利用率</div>
{(d.nodeCPU || []).map(( n: any, i: number) => {
@@ -93,10 +96,12 @@ const NodePanel: React.FC<{ d: Record<string, any>}> = ({ d }) => (
const PodPanel: React.FC<{ d: Record<string, any>}> = ({ d }) => (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-3">
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-xl font-bold text-emerald-400">{d.running?.[0]?.value||0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Running</div></CardContent></Card>
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-xl font-bold text-amber-400">{d.pending?.[0]?.value||0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Pending</div></CardContent></Card>
</div>
<StatStrip
items={[
{ label: 'Running', value: d.running?.[0]?.value || 0, color: 'emerald' },
{ label: 'Pending', value: d.pending?.[0]?.value || 0, color: 'amber' },
]}
/>
<div className="rounded-lg bg-[var(--dg-bg-primary)] p-3 max-h-40 overflow-y-auto"><div className="text-xs font-medium mb-2">CPU Top 10</div>{(d.cpuTop||[]).map(( p: any,i:number)=><div key={i} className="flex items-center gap-2 text-[11px] mb-1"><span className="w-10 text-right text-[var(--dg-text-muted)]">{i+1}</span><span className="w-32 truncate" title={p.pod}>{p.pod}</span><MiniBar val={Number(p.value||0)*100} label={`${Number(p.value||0).toFixed(2)}c`} color="bg-purple-500" /></div>)}</div>
<div className="rounded-lg bg-[var(--dg-bg-primary)] p-3 max-h-40 overflow-y-auto"><div className="text-xs font-medium mb-2">内存 Top 10</div>{(d.memTop||[]).map(( p: any,i:number)=><div key={i} className="flex items-center gap-2 text-[11px] mb-1"><span className="w-10 text-right text-[var(--dg-text-muted)]">{i+1}</span><span className="w-32 truncate" title={p.pod}>{p.pod}</span><span className="ml-auto">{fmtBytes(Number(p.value||0))}</span></div>)}</div>
</div>
@@ -26,6 +26,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import { StatStrip } from '@/components/ui/stat-strip';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription
} from '@/components/ui/dialog';
@@ -260,64 +261,14 @@ const TraceManager: React.FC = () => {
return (
<div className="space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">总请求数</p>
<p className="text-2xl font-bold">{services.reduce((sum, s) => sum + s.requestCount, 0).toLocaleString()}</p>
</div>
<div className="p-3 bg-blue-100 rounded-lg">
<Activity className="w-5 h-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">平均错误率</p>
<p className="text-2xl font-bold">
{(services.reduce((sum, s) => sum + s.errorRate, 0) / (services.length || 1)).toFixed(2)}%
</p>
</div>
<div className="p-3 bg-red-100 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">平均延迟</p>
<p className="text-2xl font-bold">
{formatDuration(services.reduce((sum, s) => sum + s.avgDuration, 0) / (services.length || 1))}
</p>
</div>
<div className="p-3 bg-green-100 rounded-lg">
<Clock className="w-5 h-5 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">服务数量</p>
<p className="text-2xl font-bold">{services.length}</p>
</div>
<div className="p-3 bg-purple-100 rounded-lg">
<Layers className="w-5 h-5 text-purple-600" />
</div>
</div>
</CardContent>
</Card>
</div>
<StatStrip
items={[
{ label: '总请求', value: services.reduce((sum, s) => sum + s.requestCount, 0).toLocaleString(), color: 'blue' },
{ label: '错误率', value: `${(services.reduce((sum, s) => sum + s.errorRate, 0) / (services.length || 1)).toFixed(2)}%`, color: 'red' },
{ label: '平均延迟', value: formatDuration(services.reduce((sum, s) => sum + s.avgDuration, 0) / (services.length || 1)), color: 'amber' },
{ label: '服务数', value: services.length, color: 'purple' },
]}
/>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
@@ -0,0 +1,338 @@
// 服务器文件浏览器 — SFTP 浏览/上传/下载(参考 deployment FileBrowserDialog 设计)
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Folder,
FolderOpen,
File,
Upload,
Download,
Trash2,
ArrowLeft,
Home,
RefreshCw,
Loader2,
FolderPlus,
ChevronRight,
X,
Maximize2,
Minimize2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { serverApi } from '@/services/api';
interface FileItem {
name: string;
path: string;
size: number;
is_dir: boolean;
mod_time: string;
mode: string;
}
interface Breadcrumb {
name: string;
path: string;
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(2)} MB`;
};
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
serverId: number | null;
serverName: string;
}
export function ServerFileBrowser({ open, onOpenChange, serverId, serverName }: Props) {
const [items, setItems] = useState<FileItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<Breadcrumb[]>([]);
const [currentPath, setCurrentPath] = useState('/');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const fetchDir = useCallback(async (path: string) => {
if (!serverId) return;
setLoading(true);
setError('');
try {
const resp = await serverApi.listFiles(serverId, path) as any;
const data = resp?.data || resp;
setItems(data.items || []);
setBreadcrumbs(data.breadcrumbs || []);
setCurrentPath(data.path || path);
} catch (err: any) {
setError(err?.message || '加载目录失败');
setItems([]);
} finally {
setLoading(false);
}
}, [serverId]);
useEffect(() => {
if (open && serverId) {
setFullscreen(false);
fetchDir('/');
}
}, [open, serverId, fetchDir]);
const handleDownload = async (filePath: string) => {
if (!serverId) return;
try {
await serverApi.downloadFile(serverId, filePath);
} catch (err) {
setError('下载失败: ' + ((err as any)?.message || ''));
}
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !serverId) return;
setUploading(true);
try {
await serverApi.uploadFile(serverId, currentPath, file);
await fetchDir(currentPath);
} catch (err: any) {
setError(err?.message || '上传失败');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
};
const handleDelete = async (filePath: string, isDir: boolean) => {
if (!serverId || !confirm(`确认删除 ${isDir ? '目录' : '文件'} "${filePath}"?`)) return;
try {
await serverApi.deleteFile(serverId, filePath);
await fetchDir(currentPath);
} catch (err: any) {
setError(err?.message || '删除失败');
}
};
const handleMkdir = async () => {
const name = prompt('输入目录名:');
if (!name || !serverId) return;
const newPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
try {
await serverApi.mkdir(serverId, newPath);
await fetchDir(currentPath);
} catch (err: any) {
setError(err?.message || '创建失败');
}
};
const sortedItems = [...items].sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
return a.name.localeCompare(b.name);
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
onInteractOutside={(e) => {
// 阻止点击外部关闭,上传中避免误关
if (uploading) e.preventDefault();
}}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
'!duration-0 !transition-none',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none'
: ''
)}
style={fullscreen
? { maxWidth: '100vw', width: '100vw', height: '100vh' }
: { maxWidth: '95vw', width: '1100px', maxHeight: '92vh' }
}
>
<DialogTitle className="sr-only">文件浏览 — {serverName}</DialogTitle>
<DialogDescription className="sr-only">SFTP 文件管理 · 上传 · 下载 · 删除</DialogDescription>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--dg-border)] shrink-0">
<div className="flex items-center gap-2 min-w-0">
<FolderOpen className="h-4 w-4 text-[var(--dg-blue)] shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium text-sm truncate">
{serverName}
</span>
<span className="text-[var(--dg-text-muted)] text-xs">· SFTP 文件管理</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title={fullscreen ? '退出全屏' : '全屏'}
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-primary)] shrink-0">
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8"
onClick={() => fetchDir(currentPath)}>
<RefreshCw className="h-3.5 w-3.5 mr-1" />刷新
</Button>
<label>
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8 cursor-pointer"
onClick={() => inputRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1" />}
上传
</Button>
<input ref={inputRef} type="file" className="hidden" onChange={handleUpload} />
</label>
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8"
onClick={handleMkdir}>
<FolderPlus className="h-3.5 w-3.5 mr-1" />新建目录
</Button>
{error && (
<Badge variant="outline" className="border-red-500/30 text-red-400 ml-auto">
<X className="h-3 w-3 mr-1 cursor-pointer" onClick={() => setError('')} />
{error}
</Badge>
)}
</div>
{/* Breadcrumbs */}
<div className="flex items-center gap-0.5 px-4 py-2 text-xs border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] overflow-x-auto shrink-0">
{breadcrumbs.map((crumb, i) => (
<span key={crumb.path} className="flex items-center gap-0.5">
{i === 0 ? (
<button
onClick={() => fetchDir('/')}
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] p-0.5"
>
<Home className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={() => fetchDir(crumb.path)}
className="text-[var(--dg-blue)] hover:underline truncate max-w-[150px]"
>
{crumb.name}
</button>
)}
{i < breadcrumbs.length - 1 && (
<ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] shrink-0" />
)}
</span>
))}
</div>
{/* File List */}
<div className="overflow-y-auto flex-1" style={{
height: fullscreen ? 'calc(100vh - 110px)' : 'min(55vh, 600px)'
}}>
{loading ? (
<div className="flex items-center justify-center h-full py-16">
<Loader2 className="h-6 w-6 animate-spin text-[var(--dg-blue)]" />
</div>
) : sortedItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]">
<Folder className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm">空目录</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[var(--dg-bg-primary)] border-b border-[var(--dg-border)] z-10">
<tr className="text-left text-[var(--dg-text-muted)] text-xs">
<th className="py-2 pl-4 w-8 font-medium"></th>
<th className="py-2 font-medium">名称</th>
<th className="py-2 px-2 font-medium w-28 text-right">大小</th>
<th className="py-2 px-2 font-medium w-44 hidden sm:table-cell">修改时间</th>
<th className="py-2 px-2 font-medium w-24 hidden md:table-cell">权限</th>
<th className="py-2 pr-4 pl-2 font-medium w-24 text-center">操作</th>
</tr>
</thead>
<tbody>
{currentPath !== '/' && (
<tr className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
onClick={() => {
const parent = currentPath.split('/').slice(0, -1).join('/') || '/';
fetchDir(parent);
}}>
<td className="py-2 pl-4"><ArrowLeft className="h-4 w-4 text-[var(--dg-text-muted)]" /></td>
<td className="py-2 text-[var(--dg-text-secondary)]" colSpan={5}>..</td>
</tr>
)}
{sortedItems.map((item) => (
<tr key={item.path}
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"
>
<td className="py-2 pl-4">
{item.is_dir ? (
<Folder className="h-4 w-4 text-amber-400" />
) : (
<File className="h-4 w-4 text-sky-400" />
)}
</td>
<td className="py-2">
{item.is_dir ? (
<button
onClick={() => fetchDir(item.path)}
className="text-[var(--dg-text-primary)] hover:text-[var(--dg-blue)] hover:underline font-medium cursor-pointer"
>
{item.name}
</button>
) : (
<span className="text-[var(--dg-text-primary)]">{item.name}</span>
)}
</td>
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">
{item.is_dir ? '-' : formatSize(item.size)}
</td>
<td className="py-2 px-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{item.mod_time}</td>
<td className="py-2 px-2 text-[var(--dg-text-muted)] font-mono text-xs hidden md:table-cell">{item.mode}</td>
<td className="py-2 pr-4 pl-2">
<div className="flex items-center justify-center gap-1">
{!item.is_dir && (
<button
onClick={(e) => { e.stopPropagation(); handleDownload(item.path); }}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-white"
title="下载"
>
<Download className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); handleDelete(item.path, item.is_dir); }}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-red-400 hover:text-red-300"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -40,12 +40,13 @@ import {
ChevronRight,
} from 'lucide-react';
import { serverApi } from '@/services/api';
import { ServerStatsCards } from './ServerStatsCards';
import { ServerStatsStrip } from './ServerStatsStrip';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { useUserStore } from '@/store';
import { CloudServerImport } from './CloudServerImport';
import { TerminalDialog } from './TerminalDialog';
import { ServerFileBrowser } from './ServerFileBrowser';
import { ExcelServerImport } from './ExcelServerImport';
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
import { getTags } from './serverHelpers';
@@ -72,6 +73,10 @@ export function ServerManager() {
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string; hasAuth?: boolean } | null>(null);
// 文件浏览 Dialog 状态
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
const [fileBrowserServer, setFileBrowserServer] = useState<{ id: number; name: string } | null>(null);
// 分页状态
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
@@ -82,6 +87,11 @@ export function ServerManager() {
setTerminalOpen(true);
};
const openFileBrowser = ( server: any) => {
setFileBrowserServer({ id: server.id, name: server.hostname });
setFileBrowserOpen(true);
};
const loadData = async (kw?: string, st?: string) => {
try {
setLoading(true);
@@ -421,7 +431,7 @@ export function ServerManager() {
</div>
{/* Stats Cards */}
<ServerStatsCards servers={servers} />
<ServerStatsStrip servers={servers} />
{/* Filters & Table */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
@@ -512,6 +522,7 @@ export function ServerManager() {
selected={selectedServers.includes(server.id)}
onToggleSelect={toggleSelect}
onOpenTerminal={openTerminal}
onOpenFiles={openFileBrowser}
onOpenEdit={openEditForm}
onDelete={handleDelete}
/>
@@ -603,6 +614,14 @@ export function ServerManager() {
serverIp={terminalServer?.ip ?? ''}
hasAuth={terminalServer?.hasAuth ?? true}
/>
{/* 文件浏览 Dialog */}
<ServerFileBrowser
open={fileBrowserOpen}
onOpenChange={setFileBrowserOpen}
serverId={fileBrowserServer?.id ?? null}
serverName={fileBrowserServer?.name ?? ''}
/>
</div>
);
}
@@ -1,70 +0,0 @@
// 服务器统计卡片组件 — 从 ServerManager 提取 // Logan.Li
import { Card, CardContent } from '@/components/ui/card';
import { Server, Activity, RotateCw, Power } from 'lucide-react';
interface ServerStatsCardsProps {
servers: any[];
}
export function ServerStatsCards({ servers }: ServerStatsCardsProps) {
const runningCount = servers.filter((s) => s.status === 'running').length;
const maintenanceCount = servers.filter((s) => s.status === 'maintenance').length;
const stoppedCount = servers.filter((s) => s.status === 'stopped').length;
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--dg-text-muted)]">服务器总数</p>
<p className="text-2xl font-bold text-[var(--dg-text-primary)]">{servers.length}</p>
</div>
<div className="p-3 rounded-lg bg-[var(--dg-blue)]/10 text-[var(--dg-blue)]">
<Server className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--dg-text-muted)]">运行中</p>
<p className="text-2xl font-bold text-emerald-400">{runningCount}</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Activity className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--dg-text-muted)]">维护中</p>
<p className="text-2xl font-bold text-amber-400">{maintenanceCount}</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<RotateCw className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--dg-text-muted)]">已停止</p>
<p className="text-2xl font-bold text-[var(--dg-text-muted)]">{stoppedCount}</p>
</div>
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]">
<Power className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,23 @@
// 服务器统计条 — 紧凑型替代 4 张大卡片
import { StatStrip } from '@/components/ui/stat-strip';
interface ServerStatsStripProps {
servers: any[];
}
export function ServerStatsStrip({ servers }: ServerStatsStripProps) {
const runningCount = servers.filter((s) => s.status === 'running').length;
const maintenanceCount = servers.filter((s) => s.status === 'maintenance').length;
const stoppedCount = servers.filter((s) => s.status === 'stopped').length;
return (
<StatStrip
items={[
{ label: '服务器', value: servers.length, color: 'blue' },
{ label: '运行中', value: runningCount, color: 'emerald' },
{ label: '维护中', value: maintenanceCount, color: 'amber' },
{ label: '已停止', value: stoppedCount, color: 'muted' },
]}
/>
);
}
@@ -12,7 +12,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TableCell, TableRow } from '@/components/ui/table';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive } from 'lucide-react';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen } from 'lucide-react';
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
interface ServerTableRowProps {
@@ -21,6 +21,7 @@ interface ServerTableRowProps {
onToggleSelect: (id: string) => void;
onOpenTerminal: ( server: any) => void;
onOpenEdit: ( server: any) => void;
onOpenFiles?: ( server: any) => void;
onDelete: (id: string) => void;
}
@@ -30,6 +31,7 @@ export function ServerTableRow({
onToggleSelect,
onOpenTerminal,
onOpenEdit,
onOpenFiles,
onDelete,
}: ServerTableRowProps) {
const cpuUsage = Number(server.cpu_usage || 0);
@@ -145,7 +147,7 @@ export function ServerTableRow({
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)}>
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)} title="终端登录">
<Terminal className="h-4 w-4" />
</Button>
<DropdownMenu>
@@ -171,6 +173,13 @@ export function ServerTableRow({
<Terminal className="h-4 w-4 mr-2" />
终端
</DropdownMenuItem>
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenFiles?.(server)}
>
<FolderOpen className="h-4 w-4 mr-2" />
文件
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem
className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
+16 -9
View File
@@ -239,7 +239,7 @@ export function WebShell() {
}
};
// 注册容器并首次挂载终端
// 注册容器并首次挂载终端 + ResizeObserver 自适应
const registerContainer = (sessionId: string, el: HTMLDivElement | null) => {
if (el) {
containerRef.current.set(sessionId, el);
@@ -248,19 +248,25 @@ export function WebShell() {
try {
entry.term.open(el);
entry.opened = true;
requestAnimationFrame(() => {
try {
entry.fit.fit();
entry.term.focus();
} catch {
/* noop */
}
// 延迟适配:等 DOM 布局完全确定
setTimeout(() => {
try { entry.fit.fit(); entry.term.focus(); } catch { /* noop */ }
}, 100);
// ResizeObserver 持续监听容器大小变化
const observer = new ResizeObserver(() => {
try { entry.fit.fit(); } catch { /* noop */ }
});
observer.observe(el);
(entry as any)._observer = observer;
} catch (e) {
console.error('打开终端失败:', e);
}
}
} else {
const entry = termsRef.current.get(sessionId);
if (entry && (entry as any)._observer) {
(entry as any)._observer.disconnect();
}
containerRef.current.delete(sessionId);
}
};
@@ -399,9 +405,10 @@ export function WebShell() {
key={session.id}
ref={(el) => registerContainer(session.id, el)}
className={cn(
'absolute inset-0 p-1',
'absolute inset-0',
activeSession === session.id ? 'block' : 'hidden'
)}
style={{ padding: 0 }}
/>
))}
</div>
@@ -0,0 +1,220 @@
// 终端审计实时监控 Dialog — xterm.js 实时查看在线会话 // Logan.Li
import { useEffect, useRef, useState } from 'react';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Loader2, Monitor, Server, User as UserIcon, Clock } from 'lucide-react';
import { useUserStore } from '@/store';
interface SessionInfo {
session_id: number;
username: string;
server_name: string;
server_ip: string;
start_time: string;
status: string;
}
interface LiveMonitorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: number | null;
sessionInfo?: SessionInfo | null;
}
export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo }: LiveMonitorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [connected, setConnected] = useState(false);
const [connectError, setConnectError] = useState('');
const [meta, setMeta] = useState<SessionInfo | null>(sessionInfo || null);
useEffect(() => {
if (!open || !sessionId) return;
const container = containerRef.current;
if (!container) return;
// Init xterm
const term = new XTerm({
cursorBlink: false,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
disableStdin: true,
theme: {
background: '#0a0e14',
foreground: '#d6d6d6',
cursor: '#d6d6d6',
selectionBackground: '#264f78',
black: '#1a1a2e',
red: '#ff6b6b',
green: '#69db7c',
yellow: '#ffd43b',
blue: '#4dabf7',
magenta: '#da77f2',
cyan: '#63e6be',
white: '#d6d6d6',
brightBlack: '#4a4a4a',
brightRed: '#ff8787',
brightGreen: '#8ce99a',
brightYellow: '#ffe066',
brightBlue: '#74c0fc',
brightMagenta: '#e599f7',
brightCyan: '#7ae5d5',
brightWhite: '#f8f9fa',
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(container);
fitAddon.fit();
termRef.current = term;
// Connect WebSocket — try zustand store first, fallback to localStorage
let token = useUserStore.getState().token;
if (!token) {
try {
const stored = JSON.parse(localStorage.getItem('user-storage') || '');
token = stored?.state?.token || '';
} catch { /* ignore */ }
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/audit/${sessionId}?token=${encodeURIComponent(token || '')}`;
console.log('[LiveMonitor] Connecting to:', wsUrl.replace(/token=[^&]+/, 'token=***'));
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
let didOpen = false;
ws.onopen = () => {
console.log('[LiveMonitor] WS connected for session:', sessionId);
didOpen = true;
setConnected(true);
setConnectError('');
term.writeln('\x1b[36m[审计监控] 已连接到会话...\x1b[0m');
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'session_info':
setMeta(msg.payload);
term.writeln(`\x1b[36m用户: ${msg.payload.username} 服务器: ${msg.payload.server_name}\x1b[0m`);
break;
case 'history':
if (msg.data) {
term.write(msg.data);
}
break;
case 'output':
if (msg.data) {
term.write(msg.data);
}
break;
}
} catch {
// ignore parse errors
}
};
ws.onerror = (e) => {
console.log('[LiveMonitor] WS error:', e);
setConnectError('WebSocket 连接失败');
};
ws.onclose = (e) => {
console.log('[LiveMonitor] WS closed code:', e.code, 'reason:', e.reason);
if (!didOpen) {
setConnectError(`连接失败 (code=${e.code}): ${e.reason || '认证失败或网络问题'}`);
}
setConnected(false);
term.writeln('\r\n\x1b[33m[审计监控] 连接已断开\x1b[0m');
};
// 5s 超时:如果还没连上,显示错误
const timeout = setTimeout(() => {
if (!didOpen) {
setConnectError('连接超时 — 请确认已登录且有活跃会话');
ws.close();
}
}, 5000);
return () => {
clearTimeout(timeout);
ws.close();
term.dispose();
termRef.current = null;
wsRef.current = null;
setConnected(false);
};
}, [open, sessionId]);
const formatTime = (t: string) => {
if (!t) return '--';
return new Date(t).toLocaleString();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-[95vw] max-h-[90vh] w-[1200px]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Monitor className="h-5 w-5 text-emerald-400" />
终端实时监控
{connected && (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 ml-2 text-xs">
● 在线
</Badge>
)}
{!connected && !connectError && (
<Badge variant="outline" className="border-yellow-500/30 text-yellow-400 bg-yellow-500/10 ml-2 text-xs">
<Loader2 className="h-3 w-3 animate-spin mr-1" />
连接中
</Badge>
)}
{connectError && (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10 ml-2 text-xs">
连接失败
</Badge>
)}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
{meta && (
<div className="flex items-center gap-4 text-xs mt-1 flex-wrap">
<span className="flex items-center gap-1">
<UserIcon className="h-3 w-3" />
{meta.username}
</span>
<span className="flex items-center gap-1">
<Server className="h-3 w-3" />
{meta.server_name} ({meta.server_ip})
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(meta.start_time)}
</span>
</div>
)}
</DialogDescription>
</DialogHeader>
<div
ref={containerRef}
className="w-full rounded-md overflow-hidden border border-[var(--dg-border)]"
style={{ height: '55vh' }}
/>
</DialogContent>
</Dialog>
);
}
+105 -22
View File
@@ -18,13 +18,22 @@ interface ReplayDialogProps {
sessionId: number | null;
}
interface AsciicastRow {
time: number;
eventType: string;
data: string;
}
/**
* 终端录制回放 Dialog — 用 xterm.js 重放录制的终端输出 // Logan.Li
* 终端录制回放 Dialog — asciicast v2 定时回放 (KoKo-compatible)
*/
export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [progress, setProgress] = useState(0);
useEffect(() => {
if (!open || !sessionId) return;
@@ -34,7 +43,8 @@ export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProp
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
disableStdin: true,
allowProposedApi: true,
cols: 80,
rows: 24,
theme: {
background: '#0a0e14',
foreground: '#d6d6d6',
@@ -48,48 +58,108 @@ export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProp
let cancelled = false;
setLoading(true);
setError('');
setProgress(0);
terminalSessionApi
.getRecord(sessionId)
.then(( data: any) => {
.then((raw: any) => {
if (cancelled) return;
const data = typeof raw === 'string' ? raw : (raw?.data || raw?.record_data || '');
if (!data || data.trim() === '') {
setError('No recording data available for this session');
setLoading(false);
return;
}
if (containerRef.current) {
term.open(containerRef.current);
try { fit.fit(); } catch { /* noop */ }
}
// Parse asciicast v2 format
const lines = data.split('\n').filter((l: string) => l.trim());
const rows: AsciicastRow[] = [];
for (const line of lines) {
try {
fit.fit();
const parsed = JSON.parse(line);
if (Array.isArray(parsed) && parsed.length >= 3) {
// asciicast v2 row: [time, "o", "data"]
rows.push({ time: parsed[0], eventType: parsed[1], data: parsed[2] });
}
// Header is skipped (JSON object, not array)
} catch {
/* noop */
// Skip lines that can't be parsed
}
const output = typeof data === 'string' ? data : '';
term.write(output);
}
})
.catch(() => {
if (!cancelled && containerRef.current) {
term.open(containerRef.current);
term.write('\x1b[31m无录制数据\x1b[0m\r\n');
if (rows.length === 0) {
// Fallback: just write raw data (old format)
term.write(data);
setProgress(100);
setLoading(false);
return;
}
// Timed playback
setLoading(false);
const totalTime = rows[rows.length - 1].time;
let rowIndex = 0;
let lastTime = 0;
const playNext = () => {
if (cancelled || rowIndex >= rows.length) {
setProgress(100);
return;
}
const row = rows[rowIndex];
const delay = (row.time - lastTime) * 1000; // convert to ms
lastTime = row.time;
rowIndex++;
// Clamp delay to avoid extremely long waits (max 2s between chunks)
const clampedDelay = Math.min(delay, 2000);
timerRef.current = setTimeout(() => {
if (cancelled) return;
term.write(row.data);
setProgress(Math.round((row.time / totalTime) * 100));
playNext();
}, clampedDelay);
};
playNext();
})
.finally(() => {
if (!cancelled) setLoading(false);
.catch((err: any) => {
if (!cancelled) {
console.error('[Replay] Load failed:', err);
setError('Failed to load recording data');
setLoading(false);
}
});
return () => {
cancelled = true;
try {
term.dispose();
} catch {
/* noop */
}
if (timerRef.current) clearTimeout(timerRef.current);
try { term.dispose(); } catch { /* noop */ }
termRef.current = null;
};
}, [open, sessionId]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-slate-900 border-slate-800 p-0 gap-0" style={{ maxWidth: '900px', width: '90vw' }}>
<DialogContent showOverlay={false} className="bg-slate-900 border-slate-800 p-0 gap-0" style={{ maxWidth: '900px', width: '90vw' }}>
<DialogHeader className="px-4 py-3 border-b border-slate-800">
<DialogTitle className="text-slate-100 text-base">终端录制回放 #{sessionId ?? ''}</DialogTitle>
<DialogDescription className="text-slate-500 sr-only">重放该会话录制的终端输出</DialogDescription>
<DialogTitle className="text-slate-100 text-base flex items-center gap-2">
Terminal Replay #{sessionId ?? ''}
{progress > 0 && progress < 100 && (
<span className="text-xs text-slate-500 font-normal ml-2">{progress}%</span>
)}
</DialogTitle>
<DialogDescription className="text-slate-500 sr-only">Replay recorded terminal output</DialogDescription>
</DialogHeader>
<div className="relative bg-[#0a0e14]" style={{ height: '460px' }}>
{loading && (
@@ -97,6 +167,19 @@ export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProp
<Loader2 className="h-6 w-6 animate-spin text-blue-400" />
</div>
)}
{error && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{progress > 0 && progress < 100 && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-slate-800 z-10">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
)}
<div ref={containerRef} className="absolute inset-0 p-1" />
</div>
</DialogContent>
@@ -3,14 +3,12 @@ import { useLocation } from 'react-router-dom';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { CheckCircle, XCircle, Users, Shield, FileCode, Settings, TerminalSquare } from 'lucide-react';
import { CheckCircle, XCircle, Users, Shield, Settings } from 'lucide-react';
import { userApi } from '@/services/api';
import type { Role } from '@/types';
import { SystemUserTab } from './SystemUserTab';
import { SystemRolesTab } from './SystemRolesTab';
import { SystemLogsTab } from './SystemLogsTab';
import { SystemSettingsTab } from './SystemSettingsTab';
import { TerminalSessionsTab } from './TerminalSessionsTab';
import { useLdapManagement } from './useLdapManagement';
import type { UserFormData } from './UserFormDialog';
@@ -43,8 +41,6 @@ export function SystemManager() {
const pathToTab: Record<string, string> = {
'/users': 'users',
'/roles': 'roles',
'/logs-audit': 'logs',
'/terminal-audit': 'terminal',
'/settings': 'settings',
};
const [activeTab, setActiveTab] = useState(pathToTab[location.pathname] || 'users');
@@ -253,14 +249,6 @@ export function SystemManager() {
<Shield className="h-4 w-4 mr-2" />
角色权限
</TabsTrigger>
<TabsTrigger value="logs" className="data-[state=active]:bg-slate-700">
<FileCode className="h-4 w-4 mr-2" />
操作审计
</TabsTrigger>
<TabsTrigger value="terminal" className="data-[state=active]:bg-slate-700">
<TerminalSquare className="h-4 w-4 mr-2" />
终端审计
</TabsTrigger>
<TabsTrigger value="settings" className="data-[state=active]:bg-slate-700">
<Settings className="h-4 w-4 mr-2" />
系统设置
@@ -315,17 +303,6 @@ export function SystemManager() {
/>
</TabsContent>
<TabsContent value="logs" className="mt-4 space-y-4">
<SystemLogsTab
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
</TabsContent>
<TabsContent value="terminal" className="mt-4 space-y-4">
<TerminalSessionsTab />
</TabsContent>
<TabsContent value="settings" className="mt-4">
<SystemSettingsTab {...ldap.settingsTabProps} />
</TabsContent>
+55
View File
@@ -0,0 +1,55 @@
// 紧凑型统计条 — 替代 4~5 个大卡片的笨重布局
// 一行显示所有统计项,带颜色区分
import { cn } from '@/lib/utils';
interface StatItem {
label: string;
value: number | string;
color?: string; // tailwind text class or hex
icon?: React.ReactNode;
}
interface StatStripProps {
items: StatItem[];
className?: string;
}
const colorMap: Record<string, { text: string; bg: string }> = {
blue: { text: 'text-blue-400', bg: 'bg-blue-500/20' },
emerald: { text: 'text-emerald-400', bg: 'bg-emerald-500/20' },
amber: { text: 'text-amber-400', bg: 'bg-amber-500/20' },
red: { text: 'text-red-400', bg: 'bg-red-500/20' },
purple: { text: 'text-purple-400', bg: 'bg-purple-500/20' },
cyan: { text: 'text-cyan-400', bg: 'bg-cyan-500/20' },
muted: { text: 'text-[var(--dg-text-secondary)]', bg: 'bg-[var(--dg-bg-tertiary)]' },
};
export function StatStrip({ items, className }: StatStripProps) {
return (
<div className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-lg',
'bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]',
'overflow-x-auto',
className,
)}>
{items.map((item, i) => {
const clr = colorMap[item.color || 'muted'] || colorMap.muted;
return (
<div key={i} className="flex items-center gap-1.5 shrink-0">
{i > 0 && (
<span className="w-px h-5 bg-[var(--dg-border)] mx-1 shrink-0" />
)}
<span className={cn(
'w-1.5 h-1.5 rounded-full shrink-0',
clr.bg.replace('/20', '').replace('bg-', 'bg-') || 'bg-slate-500',
)}
style={{ backgroundColor: item.color?.startsWith('#') ? item.color : undefined }}
/>
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
<span className={cn('text-sm font-semibold', clr.text)}>{item.value}</span>
</div>
);
})}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
// 应用申请子页面 — 应用部署、版本发布申请
import { ApprovalCategoryPage, type FormField } from './ApprovalCategoryPage';
const APP_FIELDS: FormField[] = [
{ key: 'app_name', label: '应用名称', type: 'text', placeholder: '如 user-service', required: true },
{ key: 'git_url', label: 'Git 地址', type: 'text', placeholder: '如 https://github.com/org/repo.git', required: true },
{ key: 'branch', label: '分支', type: 'text', defaultValue: 'main', required: true },
{ key: 'language', label: '开发语言', type: 'select', options: ['Java', 'Go', 'Python', 'Node.js', 'C++'], required: true },
{ key: 'http_port', label: 'HTTP 端口', type: 'number', placeholder: '如 8080' },
{ key: 'env', label: '部署环境', type: 'select', options: ['dev', 'staging', 'prod'], required: true },
{ key: 'replicas', label: '副本数', type: 'number', defaultValue: 2, required: true },
{ key: 'remark', label: '备注', type: 'textarea' },
];
export function ApprovalAppPage() {
return (
<ApprovalCategoryPage
category="app"
title="应用申请"
description="申请应用部署、版本发布、流水线配置"
fields={APP_FIELDS}
templateCode="app_deploy"
/>
);
}
+273
View File
@@ -0,0 +1,273 @@
// 审批分类通用页面 — 资源申请/应用申请/权限审批三个子页面共用
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
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 } from 'lucide-react';
import { approvalApi } from '@/services/api';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
export interface FormField {
key: string;
label: string;
type: 'text' | 'number' | 'select' | 'textarea';
options?: string[];
placeholder?: string;
defaultValue?: string | number;
required?: boolean;
}
interface Props {
category: 'resource' | 'app' | 'permission';
title: string;
description: string;
fields: FormField[];
templateCode: string;
}
interface ProcessItem {
id: number;
title: string;
status: string;
form_data: string;
created_at: string;
}
const statusBadge = (status: 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" />已通过</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" />已拒绝</Badge>;
default: return <Badge className="bg-amber-500/20 text-amber-400 border-amber-500/30"><Clock className="h-3 w-3 mr-1" />审批中</Badge>;
}
};
export function ApprovalCategoryPage({ category, title, description, fields, templateCode }: Props) {
const [processes, setProcesses] = useState<ProcessItem[]>([]);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [templateId, setTemplateId] = useState<number | null>(null);
const [formData, setFormData] = useState<Record<string, string>>({});
const fetchProcesses = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { page_size: '100' };
if (category) params.category = category;
if (statusFilter) params.status = statusFilter;
const resp = (await approvalApi.getProcesses(params)) as any;
setProcesses(resp?.list ?? resp?.data?.list ?? []);
} catch {
setProcesses([]);
} finally {
setLoading(false);
}
}, [category, statusFilter]);
const fetchTemplateId = useCallback(async () => {
try {
const resp = (await approvalApi.getTemplates({ type: templateCode })) as any;
const list = resp?.list ?? resp?.data?.list ?? [];
if (list.length > 0) setTemplateId(list[0].id);
} catch { /* ignore */ }
}, [templateCode]);
useEffect(() => { fetchProcesses(); }, [fetchProcesses]);
useEffect(() => { fetchTemplateId(); }, [fetchTemplateId]);
const handleCreate = async () => {
if (!templateId) { toast.error('模板未加载'); return; }
// Validate required fields
for (const f of fields) {
if (f.required && !formData[f.key]) {
toast.error(`请填写「${f.label}」`);
return;
}
}
setSubmitting(true);
try {
// Build title from first 2 fields
const firstVal = formData[fields[0]?.key] || '';
const secondVal = fields[1] ? formData[fields[1].key] || '' : '';
const autoTitle = `${title} - ${firstVal}${secondVal ? ' / ' + secondVal : ''}`;
await approvalApi.createProcess({
template_id: templateId,
title: autoTitle,
form_data: JSON.stringify(formData),
});
toast.success('申请已提交');
setDialogOpen(false);
setFormData({});
fetchProcesses();
} catch (err: any) {
toast.error(err?.message || '提交失败');
} finally {
setSubmitting(false);
}
};
const openCreateDialog = () => {
const init: Record<string, string> = {};
fields.forEach((f) => { init[f.key] = f.defaultValue?.toString() || ''; });
setFormData(init);
setDialogOpen(true);
};
const filtered = processes.filter((p) => {
return !keyword || (p.title || '').toLowerCase().includes(keyword.toLowerCase());
});
return (
<div className="space-y-5">
{/* Header */}
<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" />新建申请
</Button>
</div>
{/* 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-[var(--dg-text-muted)]" />
<Input
placeholder="搜索申请..."
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="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="pending">审批中</SelectItem>
<SelectItem value="approved">已通过</SelectItem>
<SelectItem value="rejected">已拒绝</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="border-[var(--dg-border)]" onClick={fetchProcesses}>
<RefreshCw className="h-4 w-4 mr-2" />刷新
</Button>
</div>
{/* Table */}
<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">暂无申请记录</p>
<Button variant="link" className="mt-2" onClick={openCreateDialog}>立即申请</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-[var(--dg-text-secondary)]">ID</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">申请标题</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">提交时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((p) => (
<TableRow key={p.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<TableCell className="text-[var(--dg-text-muted)] text-xs">#{p.id}</TableCell>
<TableCell className="text-[var(--dg-text-primary)] font-medium">{p.title}</TableCell>
<TableCell>{statusBadge(p.status)}</TableCell>
<TableCell className="text-[var(--dg-text-muted)] text-sm">
{p.created_at ? new Date(p.created_at).toLocaleString('zh-CN') : '--'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">新建{title}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">{description}</DialogDescription>
</DialogHeader>
<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>
{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 || `选择${field.label}`} />
</SelectTrigger>
<SelectContent>
{(field.options || []).map((opt) => (
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : field.type === 'textarea' ? (
<textarea
value={formData[field.key] || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
rows={3}
className="w-full rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] px-3 py-2 text-sm resize-none focus:outline-none focus:border-[var(--dg-blue)]"
/>
) : (
<Input
type={field.type}
value={formData[field.key] || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
)}
</div>
))}
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" className="border-[var(--dg-border)]" onClick={() => setDialogOpen(false)}>
取消
</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" />提交中...</> : '提交申请'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,25 @@
// 权限审批子页面 — 堡垒机、VPN、日志查询、数据查询等权限申请
import { ApprovalCategoryPage, type FormField } from './ApprovalCategoryPage';
const PERMISSION_FIELDS: FormField[] = [
{ key: 'perm_type', label: '权限类型', type: 'select', options: [
'堡垒机权限', '生产VPN权限', '日志查询权限', '数据库查询权限', '服务器SSH权限', 'K8s集群权限',
], required: true },
{ key: 'account', label: '申请账号', type: 'text', placeholder: '如 logan.li', required: true },
{ key: 'reason', label: '申请理由', type: 'textarea', placeholder: '说明为什么需要该权限', required: true },
{ key: 'duration', label: '权限时长', type: 'select', options: [
'临时(24小时)', '短期(7天)', '中期(30天)', '长期(90天)', '永久',
], required: true },
];
export function ApprovalPermissionPage() {
return (
<ApprovalCategoryPage
category="permission"
title="权限审批"
description="申请堡垒机、VPN、日志查询、数据库查询等运维权限"
fields={PERMISSION_FIELDS}
templateCode="permission_request"
/>
);
}
@@ -0,0 +1,24 @@
// 资源申请子页面 — 云服务器、存储、网络等资源申请
import { ApprovalCategoryPage, type FormField } from './ApprovalCategoryPage';
const RESOURCE_FIELDS: FormField[] = [
{ key: 'provider', label: '云厂商', type: 'select', options: ['阿里云', '腾讯云', '华为云', 'AWS'], required: true },
{ key: 'region', label: '地域', type: 'text', placeholder: '如 cn-hangzhou', required: true },
{ key: 'spec', label: '规格', type: 'select', options: ['2C4G', '4C8G', '8C16G', '16C32G', '32C64G'], required: true },
{ key: 'disk', label: '系统盘(GB)', type: 'number', defaultValue: 40, required: true },
{ key: 'count', label: '数量(台)', type: 'number', defaultValue: 1, required: true },
{ key: 'duration', label: '时长', type: 'select', options: ['1个月', '3个月', '6个月', '1年'], required: true },
{ key: 'purpose', label: '用途说明', type: 'textarea', required: true },
];
export function ApprovalResourcePage() {
return (
<ApprovalCategoryPage
category="resource"
title="资源申请"
description="申请云服务器、存储、网络等基础设施资源"
fields={RESOURCE_FIELDS}
templateCode="server_apply"
/>
);
}
+487
View File
@@ -0,0 +1,487 @@
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Search,
RefreshCw,
Download,
BarChart3,
Eye,
X,
} from 'lucide-react';
import { auditApi } from '@/services/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import type { OperationLog } from '@/types';
// Normalize backend log to frontend type
function normalizeLog(raw: any): OperationLog {
return {
id: String(raw.id ?? ''),
userId: String(raw.user_id ?? raw.userId ?? ''),
username: raw.username ?? '',
module: raw.module ?? raw.resource ?? '',
action: raw.action ?? '',
resource: raw.resource ?? '',
ip: raw.ip ?? '',
userAgent: raw.user_agent ?? raw.userAgent ?? '',
result: raw.result === 'failure' ? 'failure' : 'success',
message: raw.message ?? raw.detail ?? '',
createdAt: raw.createdAt ?? raw.created_at ?? '',
};
}
interface AuditStatistics {
total_logs?: number;
success_count?: number;
failure_count?: number;
today_count?: number;
top_modules?: { module: string; count: number }[];
top_actions?: { action: string; count: number }[];
top_users?: { username: string; count: number }[];
}
export function AuditOperationsTab() {
const confirm = useConfirm();
const [logs, setLogs] = useState<OperationLog[]>([]);
const [loading, setLoading] = useState(true);
// Filters
const [keyword, setKeyword] = useState('');
const [moduleFilter, setModuleFilter] = useState('');
const [actionFilter, setActionFilter] = useState('');
const [modules, setModules] = useState<string[]>([]);
const [actions, setActions] = useState<string[]>([]);
// Detail modal
const [detailLog, setDetailLog] = useState<OperationLog | null>(null);
// Statistics modal
const [statsOpen, setStatsOpen] = useState(false);
const [statistics, setStatistics] = useState<AuditStatistics | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const params: any = { page: 1, page_size: 100 };
if (moduleFilter) params.module = moduleFilter;
if (actionFilter) params.action = actionFilter;
const resp = await auditApi.getLogs(params);
const list = (resp as any)?.list ?? [];
setLogs(list.map(normalizeLog));
} catch {
setLogs([]);
} finally {
setLoading(false);
}
}, [moduleFilter, actionFilter]);
const fetchOptions = useCallback(async () => {
try {
const [modRes, actRes] = await Promise.all([auditApi.getModules(), auditApi.getActions()]);
setModules((modRes as any)?.list ?? (modRes as any) ?? []);
setActions((actRes as any)?.list ?? (actRes as any) ?? []);
} catch {
/* noop */
}
}, []);
useEffect(() => {
fetchLogs();
fetchOptions();
}, [fetchLogs, fetchOptions]);
const filteredLogs = logs.filter((log) => {
const kw = keyword.toLowerCase();
return (
!kw ||
log.username.toLowerCase().includes(kw) ||
log.module.toLowerCase().includes(kw) ||
log.action.toLowerCase().includes(kw) ||
log.resource.toLowerCase().includes(kw)
);
});
const getLogResultBadge = (result: OperationLog['result']) => {
return result === 'success' ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
成功
</Badge>
) : (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
失败
</Badge>
);
};
const handleExport = async () => {
try {
const params: any = {};
if (moduleFilter) params.module = moduleFilter;
if (actionFilter) params.action = actionFilter;
await auditApi.exportLogs(params);
} catch {
// noop
}
};
const handleShowStats = async () => {
setStatsOpen(true);
setStatsLoading(true);
try {
const data = (await auditApi.getStatistics()) as AuditStatistics;
setStatistics(data);
} catch {
setStatistics(null);
} finally {
setStatsLoading(false);
}
};
const viewDetail = (log: OperationLog) => {
setDetailLog(log);
};
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="搜索操作日志..."
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>
{/* 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="">全部模块</option>
{modules.map((m: any) => (
<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="">全部操作</option>
{actions.map((a: any) => (
<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={fetchLogs}
>
<RefreshCw className="h-4 w-4 mr-2" />
刷新
</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" />
导出
</Button>
</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">暂无操作日志</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">时间</TableHead>
<TableHead className="text-slate-400">用户</TableHead>
<TableHead className="text-slate-400">模块</TableHead>
<TableHead className="text-slate-400">操作类型</TableHead>
<TableHead className="text-slate-400">资源</TableHead>
<TableHead className="text-slate-400">IP</TableHead>
<TableHead className="text-slate-400">结果</TableHead>
<TableHead className="text-slate-400 text-right">详情</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<span className="text-sm text-slate-400">
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-200">{log.username || '--'}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{log.module || '--'}</span>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
{log.action || '--'}
</Badge>
</TableCell>
<TableCell>
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded">
{log.resource || '--'}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">{log.ip || '--'}</span>
</TableCell>
<TableCell>{getLogResultBadge(log.result)}</TableCell>
<TableCell>
<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"
onClick={() => viewDetail(log)}
title="查看详情"
>
<Eye className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
{/* Detail Modal */}
<LogDetailDialog log={detailLog} onClose={() => setDetailLog(null)} />
{/* Statistics Modal */}
<StatisticsDialog
open={statsOpen}
onClose={() => setStatsOpen(false)}
loading={statsLoading}
stats={statistics}
/>
</div>
);
}
// =========================================================================
// Log Detail Dialog
// =========================================================================
function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose: () => void }) {
if (!log) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative bg-slate-900 border border-slate-800 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">操作日志详情</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-slate-100"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-3 text-sm">
<DetailRow label="ID" value={log.id} />
<DetailRow label="时间" value={log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'} />
<DetailRow label="用户" value={log.username || '--'} />
<DetailRow label="用户ID" value={log.userId || '--'} />
<DetailRow label="模块" value={log.module || '--'} />
<DetailRow label="操作类型" value={log.action || '--'} />
<DetailRow label="资源" value={log.resource || '--'} />
<DetailRow label="IP地址" value={log.ip || '--'} />
<DetailRow label="User-Agent" value={log.userAgent || '--'} />
<DetailRow
label="结果"
value={
<Badge
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'
}
>
{log.result === 'success' ? '成功' : '失败'}
</Badge>
}
/>
{log.message && <DetailRow label="详情" value={log.message} mono />}
</div>
<div className="mt-5 flex justify-end">
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:bg-slate-800"
onClick={onClose}
>
关闭
</Button>
</div>
</div>
</div>
);
}
function DetailRow({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div className="flex items-start gap-4">
<span className="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'}>
{value}
</span>
</div>
);
}
// =========================================================================
// Statistics Dialog
// =========================================================================
function StatisticsDialog({
open,
onClose,
loading,
stats,
}: {
open: boolean;
onClose: () => void;
loading: boolean;
stats: AuditStatistics | null;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative bg-slate-900 border border-slate-800 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">审计统计</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-slate-100"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-10">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : stats ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<StatCard label="总日志数" value={String(stats.total_logs ?? '--')} color="blue" />
<StatCard label="今日日志" value={String(stats.today_count ?? '--')} color="emerald" />
<StatCard label="成功数" value={String(stats.success_count ?? '--')} color="emerald" />
<StatCard label="失败数" value={String(stats.failure_count ?? '--')} color="red" />
</div>
{stats.top_modules && stats.top_modules.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-slate-400 mb-2">热门模块</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>
</div>
))}
</div>
</div>
)}
{stats.top_users && stats.top_users.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-slate-400 mb-2">活跃用户</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>
</div>
))}
</div>
</div>
)}
</div>
) : (
<p className="text-slate-500 text-center py-10 text-sm">暂无统计数据</p>
)}
<div className="mt-5 flex justify-end">
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:bg-slate-800"
onClick={onClose}
>
关闭
</Button>
</div>
</div>
</div>
);
}
function StatCard({ label, value, color }: { label: string; value: string; color: 'blue' | 'emerald' | 'red' }) {
const colorMap = {
blue: 'border-blue-500/20 bg-blue-500/5 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',
};
return (
<div className={`rounded-lg border p-3 ${colorMap[color]}`}>
<div className="text-xs text-slate-500">{label}</div>
<div className="text-xl font-bold mt-0.5">{value}</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
// 安全审计 — 内容由侧边栏路由分发 | v2 (0603)
import { useLocation } from 'react-router-dom';
import { TerminalAuditTab } from './TerminalAuditTab';
import { AuditOperationsTab } from './AuditOperationsTab';
import { LoginLogsTab } from './LoginLogsTab';
export function AuditPage() {
const { pathname } = useLocation();
console.debug('[AuditPage v2] pathname:', pathname);
if (pathname.includes('/audit/terminal')) return <TerminalAuditTab />;
if (pathname.includes('/audit/operations')) return <AuditOperationsTab />;
if (pathname.includes('/audit/login-logs')) return <LoginLogsTab />;
return <TerminalAuditTab />;
}
+234
View File
@@ -0,0 +1,234 @@
// 登录日志 Tab — 用户登录记录列表(参考 opsx-web-lite) // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, RefreshCw, LogIn, Monitor, Globe } from 'lucide-react';
import { loginLogApi } from '@/services/api';
interface LoginLog {
id: number;
username: string;
login_type: string;
ip: string;
location: string;
browser: string;
os: string;
user_agent: string;
status: 'success' | 'fail';
fail_reason: string;
login_at: string;
created_at: string;
}
function normalizeLoginLog(raw: any): LoginLog {
return {
id: raw.id ?? 0,
username: raw.username ?? '',
login_type: raw.login_type ?? 'password',
ip: raw.ip ?? '',
location: raw.location ?? '',
browser: raw.browser ?? '',
os: raw.os ?? '',
user_agent: raw.user_agent ?? raw.userAgent ?? '',
status: raw.status === 'failed' || raw.status === 'fail' ? 'fail' : 'success',
fail_reason: raw.fail_reason ?? raw.message ?? '',
login_at: raw.login_at ?? raw.created_at ?? '',
created_at: raw.created_at ?? raw.login_at ?? '',
};
}
export function LoginLogsTab() {
const [logs, setLogs] = useState<LoginLog[]>([]);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const params: any = { page: 1, page_size: 100 };
if (statusFilter) params.status = statusFilter === 'fail' ? 'failed' : statusFilter;
const resp = (await loginLogApi.list(params)) as any;
const list = resp?.list ?? [];
setLogs(list.map(normalizeLoginLog));
} catch {
setLogs([]);
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const filteredLogs = logs.filter((log) => {
const kw = keyword.toLowerCase();
return (
!kw ||
(log.username || '').toLowerCase().includes(kw) ||
(log.ip || '').toLowerCase().includes(kw) ||
(log.location || '').toLowerCase().includes(kw) ||
(log.user_agent || '').toLowerCase().includes(kw)
);
});
const getStatusBadge = (status: LoginLog['status'], reason?: string) => {
return status === 'success' ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
成功
</Badge>
) : (
<Badge
variant="outline"
className="border-red-500/30 text-red-400 bg-red-500/10"
title={reason || undefined}
>
{reason || '失败'}
</Badge>
);
};
const getLoginTypeLabel = (type: string) => {
switch (type) {
case 'password': return '密码登录';
case 'ldap': return 'LDAP';
case 'token': return 'Token';
case 'oauth': return 'OAuth';
default: return type || '密码登录';
}
};
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="搜索用户/IP/归属地..."
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>
<div className="flex items-center gap-1">
{[
{ value: '', label: '全部' },
{ value: 'success', label: '成功' },
{ value: 'fail', label: '失败' },
].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-slate-700 text-slate-400 hover:text-slate-100 hover:bg-slate-800'
}
onClick={() => setStatusFilter(value)}
>
{label}
</Button>
))}
</div>
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={fetchLogs}
>
<RefreshCw className="h-4 w-4 mr-2" />
刷新
</Button>
</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">
<LogIn className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">暂无登录日志</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">登录时间</TableHead>
<TableHead className="text-slate-400">用户</TableHead>
<TableHead className="text-slate-400">登录方式</TableHead>
<TableHead className="text-slate-400">IP</TableHead>
<TableHead className="text-slate-400">归属地</TableHead>
<TableHead className="text-slate-400">浏览器/OS</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<span className="text-sm text-slate-400">
{log.login_at ? new Date(log.login_at).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-200 font-medium">{log.username || '--'}</span>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10 text-xs">
{getLoginTypeLabel(log.login_type)}
</Badge>
</TableCell>
<TableCell>
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded">
{log.ip || '--'}
</code>
</TableCell>
<TableCell>
<span className="text-xs text-slate-500 flex items-center gap-1">
<Globe className="h-3 w-3" />
{log.location || '--'}
</span>
</TableCell>
<TableCell>
<span className="text-xs text-slate-500 flex items-center gap-1" title={log.user_agent}>
<Monitor className="h-3 w-3 flex-shrink-0" />
{log.browser || log.os
? [log.browser, log.os].filter(Boolean).join(' / ')
: log.user_agent
? log.user_agent.length > 40
? log.user_agent.slice(0, 40) + '...'
: log.user_agent
: '--'}
</span>
</TableCell>
<TableCell>{getStatusBadge(log.status, log.fail_reason)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+727
View File
@@ -0,0 +1,727 @@
// 终端审计 Tab — 3 个子标签页:在线会话 / 历史会话 / 会话命令
import { useState, useEffect, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Search,
RefreshCw,
Play,
Download,
XOctagon,
Eye,
Monitor,
Activity,
Timer,
TimerOff,
} from 'lucide-react';
import { terminalSessionApi, sessionCommandApi } from '@/services/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { ReplayDialog } from '@/components/system/ReplayDialog';
import { LiveMonitorDialog } from '@/components/system/LiveMonitorDialog';
// ---- Types ----
interface TerminalSession {
id: number;
user_id?: number;
username: string;
server_id: number;
server_name: string;
server_ip: string;
server_port: number;
client_ip: string;
status: string;
duration: number;
record_size: number;
start_time: string;
end_time?: string | null;
}
interface SessionCommand {
id: number;
session_id: number;
username: string;
command: string;
exit_code: number;
output_size: number;
created_at: string;
}
type SubTabId = 'active' | 'history' | 'commands';
// ---- Helpers ----
function formatDuration(sec: number): string {
if (!sec || sec < 0) return '--';
if (sec < 60) return `${sec}秒`;
const m = Math.floor(sec / 60);
const s = sec % 60;
if (m < 60) return `${m}分${s > 0 ? `${s}秒` : ''}`;
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}时${rm > 0 ? `${rm}分` : ''}`;
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function getStatusBadge(status: string) {
if (status === 'active') {
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 inline-block animate-pulse" />
活动中
</Badge>
);
}
if (status === 'failed') {
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
失败
</Badge>
);
}
return (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
已关闭
</Badge>
);
}
// Computes elapsed time for active sessions
function useElapsedTimer(startTime: string | undefined): number {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!startTime) return;
const start = new Date(startTime).getTime();
const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [startTime]);
return elapsed;
}
// ---- Sub-Tab definitions ----
const subTabs: { id: SubTabId; name: string; icon: React.ElementType }[] = [
{ id: 'active', name: '在线会话', icon: Activity },
{ id: 'history', name: '历史会话', icon: Monitor },
{ id: 'commands', name: '会话命令', icon: Search },
];
// =========================================================================
// MAIN COMPONENT
// =========================================================================
export function TerminalAuditTab() {
const confirm = useConfirm();
const [subTab, setSubTab] = useState<SubTabId>('active');
// ---- Active Sessions State ----
const [activeSessions, setActiveSessions] = useState<TerminalSession[]>([]);
const [activeLoading, setActiveLoading] = useState(true);
const [activeKeyword, setActiveKeyword] = useState('');
// ---- History Sessions State ----
const [historySessions, setHistorySessions] = useState<TerminalSession[]>([]);
const [historyLoading, setHistoryLoading] = useState(true);
const [historyKeyword, setHistoryKeyword] = useState('');
const [replayId, setReplayId] = useState<number | null>(null);
const [replayOpen, setReplayOpen] = useState(false);
// ---- Session Commands State ----
const [commands, setCommands] = useState<SessionCommand[]>([]);
const [commandsLoading, setCommandsLoading] = useState(true);
const [commandsKeyword, setCommandsKeyword] = useState('');
const [commandsSessionId, setCommandsSessionId] = useState('');
// ---- Real-time monitoring dialog ----
const [monitorOpen, setMonitorOpen] = useState(false);
const [monitorSessionId, setMonitorSessionId] = useState<number | null>(null);
// ---- Auto-refresh ----
const [autoRefresh, setAutoRefresh] = useState(true); // 页面打开即自动刷新
const [userToggledOff, setUserToggledOff] = useState(false); // 用户手动关闭后不再自动开启
const REFRESH_SEC = 10;
// 切换到「在线会话」时自动开启刷新(除非用户已手动关闭)
useEffect(() => {
if (subTab === 'active' && !userToggledOff) {
setAutoRefresh(true);
} else if (subTab !== 'active') {
setAutoRefresh(false);
}
}, [subTab, userToggledOff]);
// ==================== Active Sessions ====================
const fetchActiveSessions = useCallback(async () => {
setActiveLoading(true);
try {
const resp = (await terminalSessionApi.list({ page: 1, page_size: 100, status: 'active' })) as any;
setActiveSessions(resp?.list ?? []);
} catch {
setActiveSessions([]);
} finally {
setActiveLoading(false);
}
}, []);
useEffect(() => {
fetchActiveSessions();
}, [fetchActiveSessions]);
// 自动刷新在线会话列表
useEffect(() => {
if (!autoRefresh) return;
const id = setInterval(fetchActiveSessions, REFRESH_SEC * 1000);
return () => clearInterval(id);
}, [autoRefresh, fetchActiveSessions]);
const filteredActive = activeSessions.filter((s) => {
const kw = activeKeyword.toLowerCase();
return (
!kw ||
(s.username || '').toLowerCase().includes(kw) ||
(s.server_name || '').toLowerCase().includes(kw) ||
(s.server_ip || '').toLowerCase().includes(kw)
);
});
const handleKill = async (id: number, username: string) => {
const ok = await confirm({
title: '阻断会话',
description: `确认强制断开用户「${username}」的当前会话吗?`,
confirmText: '阻断',
variant: 'destructive',
});
if (!ok) return;
try {
await terminalSessionApi.kill(id);
await fetchActiveSessions();
} catch {
/* noop */
}
};
const openMonitor = (sessionId: number) => {
setMonitorSessionId(sessionId);
setMonitorOpen(true);
};
// ==================== History Sessions ====================
const fetchHistorySessions = useCallback(async () => {
setHistoryLoading(true);
try {
const resp = (await terminalSessionApi.list({ page: 1, page_size: 100, status: 'closed' })) as any;
setHistorySessions(resp?.list ?? []);
} catch {
setHistorySessions([]);
} finally {
setHistoryLoading(false);
}
}, []);
useEffect(() => {
fetchHistorySessions();
}, [fetchHistorySessions]);
const filteredHistory = historySessions.filter((s) => {
const kw = historyKeyword.toLowerCase();
return (
!kw ||
(s.username || '').toLowerCase().includes(kw) ||
(s.server_name || '').toLowerCase().includes(kw) ||
(s.server_ip || '').toLowerCase().includes(kw)
);
});
const openReplay = (id: number) => {
setReplayId(id);
setReplayOpen(true);
};
const handleDownload = async (id: number) => {
try {
await terminalSessionApi.download(id);
} catch {
// download error is handled in http.download
}
};
// ==================== Session Commands ====================
const fetchCommands = useCallback(async () => {
setCommandsLoading(true);
try {
const params: any = { page: 1, page_size: 200 };
if (commandsSessionId) params.session_id = Number(commandsSessionId);
const resp = (await sessionCommandApi.list(params)) as any;
setCommands(resp?.list ?? []);
} catch {
setCommands([]);
} finally {
setCommandsLoading(false);
}
}, [commandsSessionId]);
useEffect(() => {
fetchCommands();
}, [fetchCommands]);
const filteredCommands = commands.filter((c) => {
const kw = commandsKeyword.toLowerCase();
return (
!kw ||
(c.username || '').toLowerCase().includes(kw) ||
(c.command || '').toLowerCase().includes(kw)
);
});
// ==================== RENDER ====================
return (
<div className="space-y-4">
{/* Sub-Tab Bar */}
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] p-1 rounded-lg border border-[var(--dg-border)] w-fit">
{subTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setSubTab(tab.id)}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all',
subTab === 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" />
{tab.name}
</button>
))}
</div>
{/* ==================== 在线会话 ==================== */}
{subTab === '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" />
<Input
placeholder="搜索用户/服务器..."
value={activeKeyword}
onChange={(e) => setActiveKeyword(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={fetchActiveSessions}
>
<RefreshCw className="h-4 w-4 mr-2" />
刷新
</Button>
<Button
variant={autoRefresh ? 'default' : 'outline'}
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'
}
onClick={() => {
if (autoRefresh) setUserToggledOff(true); // 手动关闭后不再自动开启
setAutoRefresh(!autoRefresh);
}}
title={autoRefresh ? `每 ${REFRESH_SEC}s 自动刷新 · 点击关闭` : '点击开启自动刷新'}
>
{autoRefresh ? (
<><Timer className="h-4 w-4 mr-1.5 animate-pulse" />{REFRESH_SEC}s</>
) : (
<><TimerOff className="h-4 w-4 mr-2" />自动刷新</>
)}
</Button>
</div>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
{activeLoading ? (
<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>
) : filteredActive.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
<Activity className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">暂无在线会话</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">ID</TableHead>
<TableHead className="text-slate-400">用户</TableHead>
<TableHead className="text-slate-400">目标服务器</TableHead>
<TableHead className="text-slate-400">端口</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead>
<TableHead className="text-slate-400">时长</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredActive.map((s) => (
<ActiveSessionRow
key={s.id}
session={s}
onKill={handleKill}
onMonitor={openMonitor}
/>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</>
)}
{/* ==================== 历史会话 ==================== */}
{subTab === 'history' && (
<>
<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="搜索用户/服务器/IP..."
value={historyKeyword}
onChange={(e) => setHistoryKeyword(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={fetchHistorySessions}
>
<RefreshCw className="h-4 w-4 mr-2" />
刷新
</Button>
</div>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
{historyLoading ? (
<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>
) : filteredHistory.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
<Monitor className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">暂无历史会话</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">ID</TableHead>
<TableHead className="text-slate-400">用户</TableHead>
<TableHead className="text-slate-400">目标服务器</TableHead>
<TableHead className="text-slate-400">端口</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead>
<TableHead className="text-slate-400">结束时间</TableHead>
<TableHead className="text-slate-400">时长</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredHistory.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>
<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>
</div>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">{s.server_port}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{s.start_time ? new Date(s.start_time).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{s.end_time ? new Date(s.end_time).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{formatDuration(s.duration)}</span>
</TableCell>
<TableCell>{getStatusBadge(s.status)}</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
onClick={() => openReplay(s.id)}
title="回放"
>
<Play className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-green-400 hover:bg-slate-800"
onClick={() => handleDownload(s.id)}
title="下载录制"
>
<Download className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</>
)}
{/* ==================== 会话命令 ==================== */}
{subTab === 'commands' && (
<>
<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="搜索用户/命令..."
value={commandsKeyword}
onChange={(e) => setCommandsKeyword(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Input
placeholder="会话 ID(可选)"
value={commandsSessionId}
onChange={(e) => setCommandsSessionId(e.target.value)}
className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={fetchCommands}
>
<RefreshCw className="h-4 w-4 mr-2" />
刷新
</Button>
</div>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
{commandsLoading ? (
<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>
) : filteredCommands.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">暂无会话命令记录</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">时间</TableHead>
<TableHead className="text-slate-400">用户</TableHead>
<TableHead className="text-slate-400">命令</TableHead>
<TableHead className="text-slate-400">输出大小</TableHead>
<TableHead className="text-slate-400">关联会话</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCommands.map((c) => (
<TableRow key={c.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<span className="text-sm text-slate-400">
{c.created_at ? new Date(c.created_at).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-200">{c.username || '--'}</span>
</TableCell>
<TableCell>
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded max-w-[400px] truncate block">
{c.command || '--'}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">{formatSize(c.output_size)}</span>
</TableCell>
<TableCell>
<span className="text-xs text-slate-500">#{c.session_id}</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</>
)}
{/* Replay Dialog */}
<ReplayDialog open={replayOpen} onOpenChange={setReplayOpen} sessionId={replayId} />
{/* Real-time monitor dialog */}
<LiveMonitorDialog open={monitorOpen} onOpenChange={setMonitorOpen} sessionId={monitorSessionId} />
</div>
);
}
// =========================================================================
// Active Session Row (with live timer)
// =========================================================================
function ActiveSessionRow({
session,
onKill,
onMonitor,
}: {
session: TerminalSession;
onKill: (id: number, username: string) => void;
onMonitor: (id: number) => void;
}) {
const elapsed = useElapsedTimer(session.start_time);
return (
<TableRow className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<span className="text-sm text-slate-400">{session.id}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-200">{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>
</div>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">{session.server_port}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{session.start_time ? new Date(session.start_time).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-emerald-400 font-mono">{formatDuration(elapsed)}</span>
</TableCell>
<TableCell>{getStatusBadge(session.status)}</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400 hover:bg-slate-800"
onClick={() => onKill(session.id, session.username)}
title="阻断会话"
>
<XOctagon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
onClick={() => onMonitor(session.id)}
title="实时查看"
>
<Eye className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
}
// =========================================================================
// Real-time Monitor Stub Dialog
// =========================================================================
function MonitorStubDialog({
open,
onOpenChange,
sessionId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: number | null;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={() => onOpenChange(false)} />
<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="flex items-start gap-4">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
<Monitor className="h-5 w-5 text-blue-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-slate-100">实时查看</h3>
<p className="text-sm text-slate-400 mt-1">
会话 #{sessionId} 实时监控功能即将上线
</p>
<p className="text-xs text-slate-500 mt-2 bg-slate-800 rounded-md p-2">
WebSocket 流式传输功能开发中,敬请期待。届时支持终端实时镜像查看与会话录制同步回放。
</p>
</div>
</div>
<div className="mt-6 flex justify-end">
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:bg-slate-800"
onClick={() => onOpenChange(false)}
>
关闭
</Button>
</div>
</div>
</div>
);
}
+1 -21
View File
@@ -1,4 +1,4 @@
// Agent 管理 & 终端会话
// Agent 管理
// 从 services/api.ts 拆分 — Q3 api.ts 模块化
import { http, type ListResponse } from '../http';
@@ -36,24 +36,4 @@ export const agentApi = {
savePortPolicy: (hostname: string, data: { port_filter_mode: string; port_whitelist: number[] }) => http.put(`/agent/port-policies/${hostname}`, data),
};
// ==================== 区域管理 API ====================
export const terminalSessionApi = {
list: async (params?: { page?: number; page_size?: number; server_id?: number; username?: string; status?: string }) => {
return http.get<ListResponse>('/terminal-sessions', params);
},
get: async (id: number) => {
return http.get(`/terminal-sessions/${id}`);
},
getRecord: async (id: number) => {
return http.get<string>(`/terminal-sessions/${id}/record`);
},
delete: async (id: number) => {
return http.delete(`/terminal-sessions/${id}`);
},
batchDelete: async (ids: number[]) => {
return http.post('/terminal-sessions/batch-delete', { ids });
},
};
// ==================== 发布系统 API(仓库/镜像仓库认证) ====================
+16 -1
View File
@@ -91,7 +91,22 @@ export const alertCenterApi = {
resolve: async (id: number | string) => {
return http.post(`/alerts/${id}/ack`);
},
silence: async (_id: number | string, _duration: string) => ({ success: true }),
// 告警静默规则 CRUD
getSilences: async (params?: { status?: string }) => {
return http.get<ListResponse>('/alerts/silences', params);
},
createSilence: async (data: Record<string, unknown>) => {
return http.post('/alerts/silences', data);
},
updateSilence: async (id: number | string, data: Record<string, unknown>) => {
return http.put(`/alerts/silences/${id}`, data);
},
deleteSilence: async (id: number | string) => {
return http.delete(`/alerts/silences/${id}`);
},
toggleSilence: async (id: number | string) => {
return http.post(`/alerts/silences/${id}/toggle`);
},
toggleRule: async (id: number | string) => {
return http.post(`/alerts/rules/${id}/toggle`);
},
+19
View File
@@ -43,6 +43,25 @@ export const serverApi = {
batchInstallAgent: async (ids: number[], token: string) => {
return http.post<{ total: number; success: number; results: any[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, { server_ids: ids });
},
// SFTP 文件浏览
listFiles: async (serverId: number, path: string) => {
return http.get(`/servers/${serverId}/files?path=${encodeURIComponent(path)}`);
},
downloadFile: async (serverId: number, filePath: string) => {
return http.download(`/servers/${serverId}/files/download?path=${encodeURIComponent(filePath)}`);
},
uploadFile: async (serverId: number, dirPath: string, file: File) => {
const formData = new FormData();
formData.append('file', file);
return http.upload(`/servers/${serverId}/files/upload?path=${encodeURIComponent(dirPath)}`, formData);
},
deleteFile: async (serverId: number, filePath: string) => {
return http.delete(`/servers/${serverId}/files?path=${encodeURIComponent(filePath)}`);
},
mkdir: async (serverId: number, dirPath: string) => {
return http.post(`/servers/${serverId}/files/mkdir`, { path: dirPath });
},
};
// ==================== Kubernetes 集群 API ====================
+61 -6
View File
@@ -2,11 +2,13 @@
// 从 services/api.ts 拆分 — Q3 api.ts 模块化
import { http, type ListResponse } from '../http';
import { isOnline, timeAgo, asList } from '../../components/agent/agentUtils';
export const auditApi = {
getLogs: async (params?: { page?: number; page_size?: number; module?: string; action?: string }) => {
return http.get<ListResponse>('/audit/logs', params);
getLogs: async (params?: { page?: number; page_size?: number; module?: string; action?: string; username?: string; start_time?: string; end_time?: string }) => {
return http.get<ListResponse>('/audit/logs', params as Record<string, string | number | boolean | undefined>);
},
getLogDetail: async (id: number | string) => {
return http.get(`/audit/logs/${id}`);
},
getModules: async () => {
return http.get('/audit/modules');
@@ -17,6 +19,17 @@ export const auditApi = {
getStatistics: async () => {
return http.get('/audit/statistics');
},
exportLogs: async (params?: Record<string, string | number | boolean | undefined>) => {
// Build URL with query params for server-side CSV export
const qs = new URLSearchParams();
qs.set('format', 'csv');
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
});
}
return http.download(`/audit/logs/export?${qs.toString()}`);
},
clearLogs: async () => {
return http.post('/audit/logs/clear');
},
@@ -24,9 +37,51 @@ export const auditApi = {
// ==================== 终端会话审计 API(WebSSH 录制) ==================== // Logan.Li
export const terminalSessionApi = {
list: async (params?: { page?: number; page_size?: number; server_id?: number; username?: string; status?: string }) => {
return http.get<ListResponse>('/terminal-sessions', params as Record<string, string | number | boolean | undefined>);
},
get: async (id: number) => {
return http.get(`/terminal-sessions/${id}`);
},
getRecord: async (id: number) => {
return http.get<string>(`/terminal-sessions/${id}/record`);
},
kill: async (id: number) => {
return http.post(`/terminal-sessions/${id}/kill`);
},
download: async (id: number) => {
return http.download(`/terminal-sessions/${id}/download`, `session-${id}.cast`);
},
delete: async (id: number) => {
return http.delete(`/terminal-sessions/${id}`);
},
batchDelete: async (ids: number[]) => {
return http.post('/terminal-sessions/batch-delete', { ids });
},
};
// ==================== 会话命令审计 API ====================
export const sessionCommandApi = {
list: async (params?: { page?: number; page_size?: number; session_id?: number; username?: string }) => {
return http.get<ListResponse>('/session-commands', params as Record<string, string | number | boolean | undefined>);
},
};
// ==================== 登录日志 API ====================
export const loginLogApi = {
list: async (params?: { page?: number; page_size?: number; username?: string; status?: string }) => {
return http.get<ListResponse>('/login-logs', params as Record<string, string | number | boolean | undefined>);
},
};
// ==================== 审批流程 API ====================
export const approvalApi = {
getTemplates: async () => {
return http.get('/approvals/templates');
getTemplates: async (params?: { type?: string; category?: string }) => {
return http.get('/approvals/templates', params);
},
getTemplate: async (id: number | string) => {
return http.get(`/approvals/templates/${id}`);
@@ -40,7 +95,7 @@ export const approvalApi = {
deleteTemplate: async (id: number | string) => {
return http.delete(`/approvals/templates/${id}`);
},
getProcesses: async (params?: { page?: number; page_size?: number; status?: string }) => {
getProcesses: async (params?: { page?: number; page_size?: number; status?: string; category?: string }) => {
return http.get('/approvals/processes', params);
},
createProcess: async ( data: any) => {
+26 -21
View File
@@ -15,12 +15,8 @@ export interface ListResponse<T = unknown> {
total: number;
}
// 可序列化为 JSON 的请求体类型
// 注: 使用 unknown 接受任意 body(前端 API 层会 JSON.stringify)
type Json = unknown;
// 通用请求函数
// 注: 默认返回类型为 any 以兼容动态后端响应;调用方应通过泛型参数指定具体类型
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function request<T = any>(
method: string,
@@ -28,7 +24,6 @@ async function request<T = any>(
body?: Json,
params?: Record<string, string | number | boolean | undefined>
): Promise<T> {
// 构建 URL(含 query params)
let fullUrl = `${BASE_URL}${url}`;
if (params) {
const searchParams = new URLSearchParams();
@@ -38,17 +33,16 @@ async function request<T = any>(
}
});
const qs = searchParams.toString();
if (qs) fullUrl += `?${qs}`;
if (qs) fullUrl += '?' + qs;
}
// 获取 token
const token = useUserStore.getState().token || localStorage.getItem('token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
headers['Authorization'] = 'Bearer ' + token;
}
const response = await fetch(fullUrl, {
@@ -69,12 +63,9 @@ async function request<T = any>(
throw new Error(json.message || '请求失败');
}
// Return json.data for standard responses, or full json for non-standard ones
return (json.data !== undefined ? json.data : json) as T;
}
// 导出便捷方法
// 注: 默认泛型为 any 以兼容动态后端响应
export const http = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get: <T = any>(url: string, params?: Record<string, string | number | boolean | undefined>) =>
@@ -92,40 +83,54 @@ export const http = {
getRaw: async (url: string): Promise<string> => {
const tk = useUserStore.getState().token;
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
const resp = await fetch(`${baseUrl}/api/v1${url}`, { headers: { Authorization: `Bearer ${tk}` } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const resp = await fetch(baseUrl + '/api/v1' + url, { headers: { Authorization: 'Bearer ' + tk } });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.text();
},
putRaw: async (url: string, body: string, contentType: string): Promise<unknown> => {
const tk = useUserStore.getState().token;
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
const resp = await fetch(`${baseUrl}/api/v1${url}`, {
const resp = await fetch(baseUrl + '/api/v1' + url, {
method: 'PUT',
headers: { Authorization: `Bearer ${tk}`, 'Content-Type': contentType },
headers: { Authorization: 'Bearer ' + tk, 'Content-Type': contentType },
body,
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
},
// 二进制下载(blob)— 用于 tar.gz / 文件下载
download: async (url: string, filename?: string): Promise<void> => {
const tk = useUserStore.getState().token;
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
const resp = await fetch(`${baseUrl}/api/v1${url}`, {
headers: { Authorization: `Bearer ${tk}` },
const resp = await fetch(baseUrl + '/api/v1' + url, {
headers: { Authorization: 'Bearer ' + tk },
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename || resp.headers.get('Content-Disposition')?.match(/filename="?(.+?)"?$/)?.[1] || 'download';
const disp = resp.headers.get('Content-Disposition') || '';
const m = disp.match(/filename="?(.+?)"?$/);
a.download = filename || (m ? m[1] : 'download');
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
upload: async <T = any>(url: string, formData: FormData): Promise<T> => {
const tk = useUserStore.getState().token;
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
const resp = await fetch(baseUrl + '/api/v1' + url, {
method: 'POST',
headers: { Authorization: 'Bearer ' + tk },
body: formData,
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
},
};
export default http;