feat(rdp): 新增 RDP 远程桌面/SMB/账单同步能力 + 清理编译产物

主要变更:
- RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、
  backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API
- Windows SMB 文件传输:backend/pkg/winsmb
- 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情
- Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs
- 服务器导入助手:server_import_helpers、ImportCredentialSettings
- 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块)
- 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-*
- .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
LoganandClaude committed 2026-07-04 01:50:36 +08:00
1 parent 777d9e4e71
commit 76ec9387f0
202 files changed
+16555 -2543

No files matched your search

@@ -34,7 +34,7 @@ import { useTranslation } from 'react-i18next';
function parseAIResponse(raw: string, fallback: AIAnalysis): AIAnalysis {
try {
// Strip markdown code fences if present
let json = raw.replace(/```(?:json)?\s*\n?/g, '').replace(/```$/, '').trim();
const json = raw.replace(/```(?:json)?\s*\n?/g, '').replace(/```$/, '').trim();
const parsed = JSON.parse(json);
return {
rootCause: parsed.root_cause || parsed.rootCause || fallback.rootCause,
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { StatStrip } from '@/components/ui/stat-strip';
import { Button } from '@/components/ui/button';
@@ -15,7 +15,6 @@ import {
Brain,
TrendingUp,
TrendingDown,
AlertTriangle,
HardDrive,
Cpu,
MemoryStick,
@@ -23,36 +22,36 @@ import {
Activity,
Calendar,
Sparkles,
Zap,
Minus,
Loader2,
} from 'lucide-react';
import { dashboardStatsApi, serverApi, alertApi } from '@/api';
import { dashboardStatsApi, serverApi, billingApi } from '@/api';
import { PredictiveCapacityTab } from './PredictiveCapacityTab';
import { PredictiveCostTab } from './PredictiveCostTab';
import {
computePredictions,
computeCostPredictions,
trendColorClass,
type CapacityForecast,
} from './predictUtils';
import { PageShell } from '@/components/layout/PageShell';
import { formatCurrency } from '@/components/cloud/CloudBillingHelpers';
import type { JsonData } from '@/types/common';
import { useTranslation } from 'react-i18next';
// 重新导出 CapacityForecast,兼容已有 import 路径 './AIPredictiveAnalytics'
export type { CapacityForecast };
export function AIPredictiveAnalytics() {
const { t } = useTranslation();
const [selectedTimeRange, setSelectedTimeRange] = useState('24h');
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('metrics');
// 原始数据
const [rawMetrics, setRawMetrics] = useState<JsonData[]>([]);
const [serverStats, setServerStats] = useState<JsonData>({});
const [alertEvents, setAlertEvents] = useState<JsonData[]>([]);
const [alertRules, setAlertRules] = useState<JsonData[]>([]);
const [billingStats, setBillingStats] = useState<JsonData | null>(null);
const [billingRecords, setBillingRecords] = useState<JsonData[]>([]);
const [billingAlertRules, setBillingAlertRules] = useState<JsonData[]>([]);
useEffect(() => {
void loadData();
@@ -61,18 +60,23 @@ export function AIPredictiveAnalytics() {
const loadData = async () => {
setLoading(true);
try {
const [metricsData, statsResp, alertsResp, rulesResp] = await Promise.all([
const start = new Date();
start.setDate(start.getDate() - 89);
const startDate = start.toISOString().slice(0, 10);
const [metricsData, statsResp, billStats, billList, billRules] = await Promise.all([
dashboardStatsApi.getMetrics(72),
serverApi.getServerStats(),
alertApi.getAlerts({ page: 1, page_size: 100 }),
alertApi.getRules(),
billingApi.getStats().catch(() => null),
billingApi.list({ start_date: startDate, page_size: 500 }).catch(() => ({ list: [] })),
billingApi.listAlertRules().catch(() => ({ list: [] })),
]);
setRawMetrics(Array.isArray(metricsData) ? metricsData : []);
setServerStats(statsResp || {});
const alertsRaw = (alertsResp as JsonData)?.list || (alertsResp as JsonData) || [];
setAlertEvents(Array.isArray(alertsRaw) ? alertsRaw : []);
const rulesRaw = (rulesResp as JsonData)?.list || (rulesResp as JsonData) || [];
setAlertRules(Array.isArray(rulesRaw) ? rulesRaw : []);
setBillingStats(billStats);
setBillingRecords((billList as JsonData)?.list || []);
setBillingAlertRules((billRules as JsonData)?.list || []);
} catch {
// 保持空数据,组件显示空状态
} finally {
@@ -86,15 +90,21 @@ export function AIPredictiveAnalytics() {
setIsAnalyzing(false);
};
// ============ 综合预测推算(线性回归 + 告警规则阈值 + 事件频率) ============
const {
predictions,
capacityForecasts,
dedupedAnomalies,
accuracy,
predictedAlertCount,
capacityWarningCount,
} = computePredictions({ rawMetrics, serverStats, alertEvents, alertRules, selectedTimeRange });
const { predictions, capacityForecasts, accuracy, capacityWarningCount } = computePredictions({
rawMetrics,
serverStats,
selectedTimeRange,
});
const costData = useMemo(
() =>
computeCostPredictions({
billingStats,
billingRecords,
billingAlertRules,
}),
[billingStats, billingRecords, billingAlertRules],
);
const getTrendIcon = (trend: string) => {
switch (trend) {
@@ -108,16 +118,16 @@ export function AIPredictiveAnalytics() {
};
const pageTabs = [
{ id: 'metrics', label: t('aiops.metricPredict') },
{ id: 'capacity', label: t('aiops.predictiveCapacity') },
{ id: 'anomaly', label: t('aiops.alertAnalysisPredict') },
{ id: 'metrics', label: '指标预测' },
{ id: 'capacity', label: '容量规划' },
{ id: 'cost', label: '成本预测' },
];
return (
<PageShell
variant="list"
title={t('aiops.aiPredictiveAnalysis')}
subtitle={t('aiops.capacityPredictDesc')}
title="AI 预测分析"
subtitle="基于实时指标与云账单的线性回归预测、容量规划与成本前瞻"
icon={<Brain className="h-5 w-5" />}
tabs={pageTabs}
activeTab={activeTab}
@@ -125,10 +135,16 @@ export function AIPredictiveAnalytics() {
metrics={(
<StatStrip
items={[
{ label: t('aiops.assistantConfidence'), value: `${accuracy}%`, color: 'emerald' },
{ label: t('aiops.predictiveHighRisk'), value: predictedAlertCount, color: 'amber' },
{ label: t('aiops.capacityWarning'), value: capacityWarningCount, color: 'red' },
{ label: t('cloud.colMetric'), value: predictions.length, color: 'purple' },
{ label: '置信度', value: `${accuracy}%`, color: 'emerald' },
{ label: '预算风险', value: costData.budgetRiskCount, color: 'amber' },
{ label: '容量预警', value: capacityWarningCount, color: 'red' },
{
label: '预测月末',
value: costData.summary.predictedMonthEnd > 0
? formatCurrency(costData.summary.predictedMonthEnd)
: '—',
color: 'purple',
},
]}
/>
)}
@@ -140,10 +156,10 @@ export function AIPredictiveAnalytics() {
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input">
<SelectItem value="1h">{t('monitor.traceMgr.hour1')}</SelectItem>
<SelectItem value="24h">{t('monitor.traceMgr.hour24')}</SelectItem>
<SelectItem value="7d">{t('monitor.traceMgr.day7')}</SelectItem>
<SelectItem value="30d">{t('monitor.siteProtocol.ssl.ref30days')}</SelectItem>
<SelectItem value="1h">1小时</SelectItem>
<SelectItem value="24h">24小时</SelectItem>
<SelectItem value="7d">7天</SelectItem>
<SelectItem value="30d">30天</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" className="dg-btn-secondary" onClick={handleRefresh}>
@@ -159,21 +175,21 @@ export function AIPredictiveAnalytics() {
<div className="flex items-center justify-between">
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<TrendingUp className="h-5 w-5 dg-text-blue" />
{t('aiops.keyMetricForecast')}
关键指标预测
</CardTitle>
<Badge variant="outline" className="border-purple-500/30 dg-text-purple bg-purple-500/10">
<Sparkles className="h-3 w-3 mr-1" />
{t('aiops.linearRegressionForecast')}
线性回归预测
</Badge>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12 dg-text-muted">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> {t('aiops.loadingMetricData')}
<Loader2 className="h-5 w-5 animate-spin mr-2" /> 加载指标数据...
</div>
) : predictions.length === 0 ? (
<div className="text-center py-12 dg-text-muted">{t('aiops.noMetricData')}</div>
<div className="text-center py-12 dg-text-muted">暂无指标数据</div>
) : (
<div className="grid grid-cols-2 gap-4">
{predictions.map((prediction, idx) => (
@@ -181,29 +197,29 @@ export function AIPredictiveAnalytics() {
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{prediction.name.includes('CPU') && <Cpu className="h-4 w-4 dg-text-blue" />}
{prediction.name.includes(t('aiops.memoryKeyword')) && <MemoryStick className="h-4 w-4 dg-text-green" />}
{prediction.name.includes(t('aiops.diskKeyword')) && <HardDrive className="h-4 w-4 dg-text-yellow" />}
{prediction.name.includes(t('aiops.networkKeyword')) && <Network className="h-4 w-4 dg-text-purple" />}
{prediction.name.includes('内存') && <MemoryStick className="h-4 w-4 dg-text-green" />}
{prediction.name.includes('磁盘') && <HardDrive className="h-4 w-4 dg-text-yellow" />}
{prediction.name.includes('网络') && <Network className="h-4 w-4 dg-text-purple" />}
<span className="font-medium text-[var(--dg-text-primary)]">{prediction.name}</span>
</div>
<div className="flex items-center gap-1">
{getTrendIcon(prediction.trend)}
<span className={`text-sm ${trendColorClass(prediction.trend)}`}>
{prediction.trend === 'up' ? t('aiops.trendUp') : prediction.trend === 'down' ? t('aiops.trendDown') : t('aiops.trendStable')}
{prediction.trend === 'up' ? '上升' : prediction.trend === 'down' ? '下降' : '平稳'}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<p className="text-xs dg-text-muted mb-1">{t('monitor.alertValue')}</p>
<p className="text-xs dg-text-muted mb-1">当前值</p>
<p className="text-lg font-bold text-[var(--dg-text-primary)]">
{prediction.current}
{prediction.unit}
</p>
</div>
<div>
<p className="text-xs dg-text-muted mb-1">{t('aiops.predictedValueLabel', { range: prediction.timeRange })}</p>
<p className="text-xs dg-text-muted mb-1">预测值 ({prediction.timeRange})</p>
<p className={`text-lg font-bold ${trendColorClass(prediction.trend)}`}>
{prediction.predicted}
{prediction.unit}
@@ -213,7 +229,7 @@ export function AIPredictiveAnalytics() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs dg-text-secondary">{t('aiops.assistantConfidence')}</span>
<span className="text-xs dg-text-secondary">置信度</span>
<div className="w-16 h-1.5 bg-[var(--dg-bg-active)] rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-purple-500"
@@ -235,113 +251,10 @@ export function AIPredictiveAnalytics() {
<PredictiveCapacityTab forecasts={capacityForecasts} loading={loading} />
</TabsContent>
<TabsContent value="anomaly" className="space-y-4">
<Card className="dg-card">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<AlertTriangle className="h-5 w-5 dg-text-red" />
{t('aiops.anomalyEventPredict')}
</CardTitle>
<span className="text-xs dg-text-muted">{t('aiops.anomalyBasis')}</span>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12 dg-text-muted">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> {t('aiops.loadAnomalyDot')}
</div>
) : dedupedAnomalies.length === 0 ? (
<div className="text-center py-12 dg-text-muted">{t('aiops.noAnomalyData')}</div>
) : (
<div className="space-y-3">
{dedupedAnomalies.map((anomaly, idx) => (
<div key={idx} className="p-4 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
anomaly.probability >= 70
? 'bg-red-500/20'
: anomaly.probability >= 50
? 'bg-amber-500/20'
: 'bg-blue-500/20'
}`}
>
<AlertTriangle
className={`h-5 w-5 ${
anomaly.probability >= 70
? 'dg-text-red'
: anomaly.probability >= 50
? 'dg-text-yellow'
: 'dg-text-blue'
}`}
/>
</div>
<div>
<h4 className="font-medium text-[var(--dg-text-primary)]">{anomaly.metric}</h4>
<p className="text-sm dg-text-secondary mt-1">{anomaly.predictedAnomaly}</p>
<div className="flex items-center gap-4 mt-2">
<span className="text-xs dg-text-muted">{anomaly.timeframe}</span>
<span className="text-xs dg-text-muted">
{t('aiops.probabilityColon')}
<span className={anomaly.probability >= 70 ? 'dg-text-red' : 'dg-text-yellow'}>
{anomaly.probability}%
</span>
</span>
</div>
</div>
</div>
<div className="w-16 h-16 relative shrink-0">
<svg className="w-full h-full transform -rotate-90">
<circle
cx="32"
cy="32"
r="28"
stroke="currentColor"
strokeWidth="4"
fill="transparent"
className="text-[var(--dg-text-muted)]"
/>
<circle
cx="32"
cy="32"
r="28"
stroke="currentColor"
strokeWidth="4"
fill="transparent"
strokeDasharray={`${anomaly.probability * 1.76} 176`}
className={
anomaly.probability >= 70
? 'dg-text-red'
: anomaly.probability >= 50
? 'dg-text-yellow'
: 'dg-text-blue'
}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-[var(--dg-text-primary)]">{anomaly.probability}%</span>
</div>
</div>
</div>
<div className="mt-3 p-3 bg-[var(--dg-bg-secondary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-start gap-2">
<Zap className="h-4 w-4 dg-text-green mt-0.5" />
<div>
<span className="text-sm dg-text-secondary">{t('aiops.suggestedActions')}</span>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{anomaly.suggestedAction}</p>
</div>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<TabsContent value="cost" className="space-y-4">
<PredictiveCostTab costData={costData} loading={loading} />
</TabsContent>
</Tabs>
</PageShell>
);
}
}
+311 -117
View File
@@ -10,18 +10,19 @@ import {
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { serverApi, buildAgentCliWsUrl } from '@/api';
import { serverApi } from '@/api';
import { kubernetesApi } from '@/api/kubernetes';
import { buildAgentCliWsUrl, type AgentCliMode } from '@/api/aiops/agent-cli';
import { toast } from 'sonner';
import {
Send, Square, Terminal, Shield, Brain, Cpu, Database, Loader2, Bot, Lightbulb, Server,
PanelLeftClose, History, Rocket,
PanelLeftClose, History, ChevronUp, ChevronDown, Boxes, Cloud,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import AgentCLIHistory from './AgentCLIHistory';
import { AgentChatTimeline, type ChatItem } from './AgentCLIMessages';
import type { AgentCliSession } from '@/api/aiops/agent-cli';
import { PageShell } from '@/components/layout/PageShell';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
// WS 事件类型(后端 wsEvent 对应) // Logan.Li
interface WsEvent {
@@ -43,54 +44,74 @@ interface WsEvent {
}
// 四大核心模块说明(灯泡 Popover) // Logan.Li
const TIPS = [
{ icon: Cpu, title: i18n.t('aiops.llmToolLayer'), color: 'text-sky-400',
desc: i18n.t('aiops.llmToolDesc') },
{ icon: Shield, title: i18n.t('aiops.safetyPrompt'), color: 'dg-text-yellow',
desc: i18n.t('aiops.safetyFrameworkDesc') },
{ icon: Brain, title: i18n.t('aiops.reactEngine'), color: 'dg-text-purple',
desc: i18n.t('aiops.reactDesc') },
{ icon: Database, title: i18n.t('aiops.selfEvolveEngine'), color: 'dg-text-green',
desc: i18n.t('aiops.selfEvolveDesc') },
const POD_NONE = '__none__';
const TIPS_HOST = [
{ icon: Cpu, title: 'LLM + 工具层', color: 'text-sky-400',
desc: 'OpenAI 兼容 LLM;工具含 ssh_execute(远程执行)与 analyze_output(输出分析)。' },
{ icon: Shield, title: '安全 + Prompt', color: 'dg-text-yellow',
desc: '五级风险评估 + 三级安全模式;危险命令硬性拒绝,高危命令弹框确认。' },
{ icon: Brain, title: 'ReAct 核心引擎', color: 'dg-text-purple',
desc: '思考→行动→观察循环(最多 16 轮),失败换法重试,循环检测防死循环。' },
{ icon: Database, title: '自进化引擎', color: 'dg-text-green',
desc: '四层记忆异步学习,同类任务自动注入历史经验,越用越聪明。' },
];
const TIPS_KUBERNETES = [
{ icon: Cloud, title: 'Kubernetes 查询', color: 'text-sky-400',
desc: 'kubernetes_get 统一查询节点/Pod/Deployment/事件等;pod_logs 拉取容器日志。' },
{ icon: Terminal, title: 'Pod 执行', color: 'dg-text-purple',
desc: '选定 Pod 后可用 pod_execute 进入容器排障;kubernetes_action 执行重启/扩缩容等变更。' },
{ icon: Shield, title: '安全 + ReAct', color: 'dg-text-yellow',
desc: '只读工具自动执行;变更与危险命令弹框确认,最多 16 轮循环排障。' },
{ icon: Database, title: '自进化引擎', color: 'dg-text-green',
desc: '按集群/命名空间分桶学习,同类 Kubernetes 排障任务越用越准。' },
];
// 安全模式说明(灯泡 Popover 展示) // Logan.Li
const SAFETY_MODES = [
{
value: 'strict',
label: i18n.t('aiops.safetyStrict'),
short: i18n.t('aiops.safetyMidRiskConfirm'),
desc: i18n.t('aiops.safetyMidDesc'),
label: '严格',
short: '中风险+确认',
desc: 'chmod、文件修改、服务重启等中风险及以上命令均需弹框确认。适合生产环境或不熟悉的主机。',
examples: 'chmod、mv、systemctl restart',
},
{
value: 'balanced',
label: i18n.t('aiops.safetyBalanced'),
short: i18n.t('aiops.safetyHighRiskConfirm'),
desc: i18n.t('aiops.safetyBalancedDesc'),
examples: i18n.t('aiops.safetyBalancedExamples'),
label: '均衡',
short: '高风险+确认',
desc: '日常排障命令(查看日志、ps、df)自动执行;删除文件、重启服务等高风险操作才需确认。推荐日常使用。',
examples: 'cat/grep/df 自动 · rm/reboot 确认',
recommended: true,
},
{
value: 'permissive',
label: i18n.t('aiops.safetyLoose'),
short: i18n.t('aiops.safetyBlockDanger'),
desc: i18n.t('aiops.safetyStrictDesc'),
examples: i18n.t('aiops.safetyAutoExcept'),
label: '宽松',
short: '仅拦截危险',
desc: '除 rm -rf /、mkfs 等危险命令硬性拦截外,其余全部自动执行。仅适合隔离测试环境。',
examples: 'rm -rf / 拦截 · 其余自动',
},
] as const;
// formatServerLabel 主机下拉展示文案 // Logan.Li
function formatServerLabel(s: { hostname: string; ip: string }) {
return i18n.t('aiops.hostIpFormat', { hostname: s.hostname, ip: s.ip });
return `${s.hostname}(${s.ip})`;
}
export default function AgentCLI() {
const { t } = useTranslation();
const confirm = useConfirm();
const [cliMode, setCliMode] = useState<AgentCliMode>('host');
const [servers, setServers] = useState<{ id: number; hostname: string; ip: string }[]>([]);
const [serverId, setServerId] = useState<string>('');
const [clusters, setClusters] = useState<{ id: number; name: string; code: string }[]>([]);
const [clusterCode, setClusterCode] = useState<string>('');
const [namespaces, setNamespaces] = useState<string[]>([]);
const [namespace, setNamespace] = useState<string>('');
const [pods, setPods] = useState<{ name: string; status: string; image: string; restarts: number }[]>([]);
const [podName, setPodName] = useState<string>(POD_NONE);
const [podSearch, setPodSearch] = useState('');
const [safetyMode, setSafetyMode] = useState<string>('balanced');
const [items, setItems] = useState<ChatItem[]>([]);
const [input, setInput] = useState('');
@@ -116,6 +137,13 @@ export default function AgentCLI() {
}, []);
const currentSafety = SAFETY_MODES.find((m) => m.value === safetyMode) ?? SAFETY_MODES[1];
const currentTips = cliMode === 'kubernetes' ? TIPS_KUBERNETES : TIPS_HOST;
const selectedCluster = clusters.find((c) => c.code === clusterCode);
const wsReady = cliMode === 'host' ? Boolean(serverId) : Boolean(clusterCode && namespace);
const filteredPods = pods.filter((p) => {
const q = podSearch.trim().toLowerCase();
return !q || p.name.toLowerCase().includes(q);
});
// 加载主机列表 // Logan.Li
useEffect(() => {
@@ -124,23 +152,86 @@ export default function AgentCLI() {
}).catch(() => {});
}, []);
// 选中主机后建立 WebSocket 连接 // Logan.Li
// 加载 K8s 集群列表 // Logan.Li
useEffect(() => {
if (!serverId) return;
kubernetesApi.getClusters().then((res) => {
const list = (res as { list?: { id: number; name: string; code: string }[] })?.list || [];
setClusters(list);
}).catch(() => {});
}, []);
// 集群变更 → 加载命名空间 // Logan.Li
useEffect(() => {
if (!clusterCode) {
setNamespaces([]);
setNamespace('');
return;
}
kubernetesApi.getNamespaces(clusterCode).then((res) => {
const raw = res as string[] | { list?: string[]; items?: { name?: string; metadata?: { name?: string } }[] };
let ns: string[] = [];
if (Array.isArray(raw)) ns = raw;
else if (Array.isArray(raw?.list)) ns = raw.list as string[];
else if (Array.isArray(raw?.items)) {
ns = raw.items.map((i) => i.name || i.metadata?.name || '').filter(Boolean);
}
setNamespaces(ns);
if (ns.length && !ns.includes(namespace)) setNamespace(ns[0]);
}).catch(() => setNamespaces([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clusterCode]);
// 命名空间变更 → 加载 Pod // Logan.Li
useEffect(() => {
if (!clusterCode || !namespace) {
setPods([]);
setPodName(POD_NONE);
return;
}
kubernetesApi.getNamespacePods(clusterCode, namespace).then((res) => {
const arr = Array.isArray(res) ? res : (res as { list?: unknown[] })?.list || [];
const mapped = (arr as { name?: string; status?: string; image?: string; restart_count?: number }[]).map((p) => ({
name: p.name || '',
status: p.status || 'Unknown',
image: p.image || '',
restarts: p.restart_count || 0,
})).filter((p) => p.name);
setPods(mapped);
if (podName !== POD_NONE && !mapped.some((p) => p.name === podName)) {
setPodName(POD_NONE);
}
}).catch(() => setPods([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clusterCode, namespace]);
// 建立 WebSocket 连接(主机 / K8s) // Logan.Li
useEffect(() => {
if (cliMode === 'host' && !serverId) return;
if (cliMode === 'kubernetes' && (!clusterCode || !namespace)) return;
setItems([]);
setRound(0); setTokens(0); setRunning(false);
setConnected(false);
setViewMode(false);
setViewSession(null);
setCanContinue(false);
const ws = new WebSocket(buildAgentCliWsUrl(serverId));
const wsUrl = cliMode === 'kubernetes'
? buildAgentCliWsUrl({
mode: 'kubernetes',
cluster: clusterCode,
namespace,
pod: podName !== POD_NONE ? podName : undefined,
})
: buildAgentCliWsUrl({ mode: 'host', serverId });
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
let everConnected = false;
let active = true;
const connectTimer = window.setTimeout(() => {
if (active && !everConnected && ws.readyState !== WebSocket.OPEN) {
push({ kind: 'error', content: t('aiops.connectHostTimeout') });
push({ kind: 'error', content: cliMode === 'kubernetes'
? '连接集群超时:请检查集群凭据与大模型配置后重试。'
: '连接主机超时:请检查 SSH 凭据与大模型配置后重试。' });
try { ws.close(); } catch { /* noop */ }
}
}, 8000);
@@ -152,9 +243,11 @@ export default function AgentCLI() {
setConnected(false);
setRunning(false);
if (!everConnected) {
push({ kind: 'error', content: t('aiops.hostConnectRefused') });
push({ kind: 'error', content: cliMode === 'kubernetes'
? '集群连接被拒绝:请检查集群 kubeconfig/token 配置与大模型是否启用。'
: '主机连接被拒绝:请确认 SSH 凭据与大模型配置后重试。' });
} else if (!viewMode) {
push({ kind: 'info', content: t('aiops.connClosed') });
push({ kind: 'info', content: '连接已关闭' });
}
};
ws.onerror = () => { setConnected(false); };
@@ -168,7 +261,7 @@ export default function AgentCLI() {
wsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverId]);
}, [cliMode, serverId, clusterCode, namespace, podName]);
const updateScrollState = useCallback(() => {
const el = scrollRef.current;
@@ -195,6 +288,16 @@ export default function AgentCLI() {
updateScrollState();
}, [items, updateScrollState]);
// 内容高度变化时刷新滚动状态,避免短内容误显示浮动按钮 // Logan.Li
useEffect(() => {
updateScrollState();
const el = scrollRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(() => updateScrollState());
ro.observe(el);
return () => ro.disconnect();
}, [items.length, updateScrollState]);
// 处理后端事件 // Logan.Li
const handleEvent = (ev: WsEvent) => {
const type = ev.type;
@@ -239,7 +342,7 @@ export default function AgentCLI() {
const args = ev.tool_args || {};
push({
kind: 'tool', round: ev.round, toolName: ev.tool_name,
command: String(args.command || ''), risk: ev.risk, pending: true,
command: String(args.command || (args.action ? `${args.action} ${args.name || ''}` : '')), risk: ev.risk, pending: true,
});
break;
}
@@ -266,7 +369,7 @@ export default function AgentCLI() {
setItems((prev) => {
const norm = (s?: string) => (s || '').trim().replace(/\s+/g, ' ');
// 移除与 done 同文的 thought,避免终局总结显示两遍 // Logan.Li
let next = prev.filter((it) => {
const next = prev.filter((it) => {
if (it.kind !== 'thought' || !text) return true;
return norm(it.content) !== norm(text);
});
@@ -280,7 +383,7 @@ export default function AgentCLI() {
return [...next, {
id: nextId(),
kind: 'done',
content: text || t('aiops.taskDone'),
content: text || '任务已完成,请查看上方工具执行结果。',
round: ev.round,
}];
});
@@ -291,7 +394,7 @@ export default function AgentCLI() {
}
case 'error':
case 'aborted':
push({ kind: type === 'aborted' ? 'info' : 'error', content: ev.message || (type === 'aborted' ? t('aiops.taskAborted') : t('aiops.errorOccurred')) });
push({ kind: type === 'aborted' ? 'info' : 'error', content: ev.message || (type === 'aborted' ? '任务已中断' : '发生错误') });
setRunning(false);
setCanContinue(type !== 'aborted');
setHistoryRefresh((k) => k + 1);
@@ -302,23 +405,24 @@ export default function AgentCLI() {
// 高危命令确认(后端驱动 → 弹框 → 回传) // Logan.Li
const handleConfirm = async (ev: WsEvent) => {
const ok = await confirm({
title: t('aiops.confirmHighRisk'),
description: t('aiops.confirmToolExec', { tool: ev.tool_name, risk: ev.risk, cmd: ev.command || t('system.none') }),
confirmText: t('aiops.assistantConfirmExec'),
cancelText: t('common.deny'),
title: '⚠️ 确认执行高危操作',
description: `工具:${ev.tool_name}\n风险:${ev.risk}\n命令:${ev.command || '(无)'}`,
confirmText: '确认执行',
cancelText: '拒绝',
variant: 'destructive',
});
wsRef.current?.send(JSON.stringify({ type: ok ? 'confirm' : 'deny', confirm_id: ev.confirm_id }));
if (!ok) push({ kind: 'info', content: t('aiops.rejectedExec', { cmd: ev.command || ev.tool_name }) });
if (!ok) push({ kind: 'info', content: `已拒绝执行:${ev.command || ev.tool_name}` });
};
// 发送任务 // Logan.Li
const send = () => {
const q = input.trim();
if (!q) return;
if (!serverId) { toast.error(t('aiops.selectTargetHostFirst')); return; }
if (cliMode === 'host' && !serverId) { toast.error('请先选择目标主机'); return; }
if (cliMode === 'kubernetes' && (!clusterCode || !namespace)) { toast.error('请先选择集群和命名空间'); return; }
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
toast.error(t('aiops.notConnectedHint'));
toast.error('连接未就绪,请检查大模型配置或刷新页面重试');
return;
}
if (running) return;
@@ -342,7 +446,7 @@ export default function AgentCLI() {
setItems([
{
id: nextId(), kind: 'info',
content: t('aiops.viewHistoryDetail', { title: sess.title, status: sess.status === 'done' ? t('aiops.taskDone') : sess.status }),
content: `查看历史记录:${sess.title}(${sess.status === 'done' ? '已完成' : sess.status})— 仅浏览,点击「继续追问」可在此基础上接着执行`,
},
...replayItems,
]);
@@ -353,11 +457,11 @@ export default function AgentCLI() {
const resumeHistorySession = () => {
if (!viewSession) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
toast.error(t('aiops.notConnectedRetry'));
toast.error('连接未就绪,请稍后重试');
return;
}
wsRef.current.send(JSON.stringify({ type: 'resume', session_id: viewSession.id }));
push({ kind: 'info', content: t('aiops.resumingSession', { title: viewSession.title }) });
push({ kind: 'info', content: `正在恢复会话「${viewSession.title}」…` });
};
const handleNewTask = () => {
@@ -376,28 +480,110 @@ export default function AgentCLI() {
<PageShell
variant="workspace"
title="Agent CLI"
subtitle={t('aiops.agentCliSubtitle')}
subtitle={cliMode === 'kubernetes' ? '自然语言驱动 Kubernetes 集群查询与 Pod 排障' : '自然语言驱动 SSH 自主排障与部署'}
icon={<Bot className="h-5 w-5" />}
className="h-full"
contentClassName="h-full flex flex-col min-h-0 gap-2"
>
{/* 工作区控制栏:主机 + 安全模式 + 状态,不挤占对话区宽度 // Logan.Li */}
<div className="flex items-center gap-2 flex-wrap shrink-0 px-0.5">
<Select value={serverId} onValueChange={setServerId}>
<SelectTrigger className="w-80 max-w-[22rem] h-8 text-xs dg-input min-w-0">
<Server className="w-3.5 h-3.5 mr-1.5 dg-text-secondary shrink-0" />
<SelectValue placeholder={t('automation.remoteSelectHost')} className="truncate">
{selectedServer ? formatServerLabel(selectedServer) : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent className="dg-input max-h-72 min-w-[var(--radix-select-trigger-width)]">
{servers.map((s) => (
<SelectItem key={s.id} value={String(s.id)} title={formatServerLabel(s)}>
<span className="truncate block max-w-[20rem]">{formatServerLabel(s)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex rounded-md border border-[var(--dg-border)] overflow-hidden h-8 shrink-0">
<button
type="button"
className={`px-2.5 text-xs flex items-center gap-1 ${cliMode === 'host' ? 'bg-blue-500/15 dg-text-blue' : 'dg-text-secondary hover:bg-[var(--dg-bg-hover)]'}`}
onClick={() => setCliMode('host')}
>
<Server className="w-3.5 h-3.5" />主机
</button>
<button
type="button"
className={`px-3 text-xs flex items-center gap-1 border-l border-[var(--dg-border)] ${cliMode === 'kubernetes' ? 'bg-blue-500/15 dg-text-blue' : 'dg-text-secondary hover:bg-[var(--dg-bg-hover)]'}`}
onClick={() => setCliMode('kubernetes')}
>
<Boxes className="w-3.5 h-3.5 shrink-0" />Kubernetes
</button>
</div>
{cliMode === 'host' ? (
<Select value={serverId} onValueChange={setServerId}>
<SelectTrigger className="w-80 max-w-[22rem] h-8 text-xs dg-input min-w-0">
<Server className="w-3.5 h-3.5 mr-1.5 dg-text-secondary shrink-0" />
<SelectValue placeholder="选择目标主机" className="truncate">
{selectedServer ? formatServerLabel(selectedServer) : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent className="dg-input max-h-72 min-w-[var(--radix-select-trigger-width)]">
{servers.map((s) => (
<SelectItem key={s.id} value={String(s.id)} title={formatServerLabel(s)}>
<span className="truncate block max-w-[20rem]">{formatServerLabel(s)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<>
<Select value={clusterCode} onValueChange={setClusterCode}>
<SelectTrigger className="w-44 h-8 text-xs dg-input">
<Cloud className="w-3.5 h-3.5 mr-1 dg-text-secondary shrink-0" />
<SelectValue placeholder="集群" />
</SelectTrigger>
<SelectContent className="dg-input max-h-72">
{clusters.map((c) => (
<SelectItem key={c.id} value={c.code}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={namespace} onValueChange={setNamespace}>
<SelectTrigger className="w-36 h-8 text-xs dg-input">
<SelectValue placeholder="命名空间" />
</SelectTrigger>
<SelectContent className="dg-input max-h-72">
{namespaces.map((ns) => (
<SelectItem key={ns} value={ns}>{ns}</SelectItem>
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-8 text-xs dg-input min-w-[10rem] justify-start font-normal">
<Terminal className="w-3.5 h-3.5 mr-1 dg-text-secondary shrink-0" />
{podName === POD_NONE ? 'Pod(可选)' : podName}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 dg-input p-2 space-y-2">
<Input
value={podSearch}
onChange={(e) => setPodSearch(e.target.value)}
placeholder="搜索 Pod 名称"
className="h-8 text-xs dg-input"
/>
<div className="max-h-56 overflow-y-auto space-y-0.5">
<button
type="button"
className={`w-full text-left text-xs px-2 py-1.5 rounded ${podName === POD_NONE ? 'bg-blue-500/15' : 'hover:bg-[var(--dg-bg-hover)]'}`}
onClick={() => setPodName(POD_NONE)}
>
不选定 Pod(集群/NS 级任务)
</button>
{filteredPods.map((p) => (
<button
key={p.name}
type="button"
className={`w-full text-left text-xs px-2 py-1.5 rounded ${podName === p.name ? 'bg-blue-500/15' : 'hover:bg-[var(--dg-bg-hover)]'}`}
onClick={() => setPodName(p.name)}
>
<div className="font-medium truncate">{p.name}</div>
<div className="text-[10px] dg-text-muted truncate">{p.status} · restarts {p.restarts}</div>
</button>
))}
{filteredPods.length === 0 && (
<div className="text-[10px] dg-text-muted text-center py-2">无匹配 Pod</div>
)}
</div>
</PopoverContent>
</Popover>
</>
)}
<div className="flex items-center gap-1">
<Select value={safetyMode} onValueChange={setSafetyMode}>
@@ -413,14 +599,14 @@ export default function AgentCLI() {
</Select>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-yellow hover:bg-[var(--dg-bg-hover)]" title={t('aiops.safetyModeDesc')}>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-yellow hover:bg-[var(--dg-bg-hover)]" title="安全模式说明">
<Lightbulb className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 dg-input p-3 space-y-2.5">
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{t('aiops.safetyModeDesc')}</div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">安全模式说明</div>
<p className="text-[11px] dg-text-muted leading-relaxed">
{t('aiops.safetyModeHint')}
控制 AI 执行远程命令前是否需要你确认。危险命令(如 rm -rf /)任何模式均硬性拦截。
</p>
{SAFETY_MODES.map((m) => (
<div
@@ -435,11 +621,11 @@ export default function AgentCLI() {
<span className="font-medium text-[var(--dg-text-primary)]">{m.label}</span>
<span className="text-[10px] dg-text-muted">{m.short}</span>
{'recommended' in m && m.recommended && (
<Badge className="text-[9px] px-1 py-0 bg-violet-500/15 dg-text-purple border-violet-500/30">{t('common.recommended')}</Badge>
<Badge className="text-[9px] px-1 py-0 bg-violet-500/15 dg-text-purple border-violet-500/30">推荐</Badge>
)}
</div>
<p className="dg-text-secondary leading-relaxed mt-1">{m.desc}</p>
<p className="text-[10px] dg-text-muted mt-1">{t('aiops.examplePrefix')}{m.examples}</p>
<p className="text-[10px] dg-text-muted mt-1">例:{m.examples}</p>
</div>
))}
</PopoverContent>
@@ -451,40 +637,40 @@ export default function AgentCLI() {
size="sm"
className="h-8 px-2 text-xs dg-text-secondary"
onClick={() => setHistoryOpen((v) => !v)}
title={historyOpen ? t('aiops.collapseHistory') : t('aiops.expandHistory')}
title={historyOpen ? '收起历史任务' : '展开历史任务'}
>
{historyOpen ? <PanelLeftClose className="w-3.5 h-3.5 mr-1" /> : <History className="w-3.5 h-3.5 mr-1" />}
{t('aiops.history')}
历史
</Button>
<div className="flex-1 min-w-[1rem]" />
{connected && (
<Badge className="h-6 text-[10px] bg-emerald-500/15 dg-text-green border-emerald-500/30">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1" />{t('common.connected')}
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1" />已连接
</Badge>
)}
{viewMode && (
<Badge className="h-6 text-[10px] bg-amber-500/15 dg-text-yellow border-amber-500/30">{t('aiops.viewRecord')}</Badge>
<Badge className="h-6 text-[10px] bg-amber-500/15 dg-text-yellow border-amber-500/30">查看记录</Badge>
)}
{canContinue && !running && !viewMode && (
<Badge className="h-6 text-[10px] bg-violet-500/15 dg-text-purple border-violet-500/30">{t('aiops.canFollowup')}</Badge>
<Badge className="h-6 text-[10px] bg-violet-500/15 dg-text-purple border-violet-500/30">可追问</Badge>
)}
{running && (
<Badge className="h-6 text-[10px] bg-blue-500/15 dg-text-blue border-blue-500/30">
<Loader2 className="w-3 h-3 mr-1 animate-spin" />{t('aiops.roundTokens', { round, tokens })}
<Loader2 className="w-3 h-3 mr-1 animate-spin" />第 {round} 轮 · {tokens}t
</Badge>
)}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-muted hover:dg-text-secondary" title={t('aiops.coreAbilityTitle')}>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-muted hover:dg-text-secondary" title="核心能力说明">
<Lightbulb className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 dg-input p-3 space-y-2">
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{t('aiops.coreAbility')}</div>
{TIPS.map((t) => (
<div className="text-sm font-medium text-[var(--dg-text-primary)]">核心能力</div>
{currentTips.map((t) => (
<div key={t.title} className="flex gap-2 p-2 rounded-md bg-[var(--dg-bg-tertiary)]/40 border border-[var(--dg-border)]">
<t.icon className={`w-4 h-4 mt-0.5 shrink-0 ${t.color}`} />
<div>
@@ -501,7 +687,10 @@ export default function AgentCLI() {
<div className="flex flex-1 min-h-0 gap-2">
{historyOpen && (
<AgentCLIHistory
mode={cliMode}
serverId={serverId}
clusterId={selectedCluster ? String(selectedCluster.id) : undefined}
namespace={namespace}
onView={handleViewHistory}
onNewTask={handleNewTask}
refreshKey={historyRefresh}
@@ -513,15 +702,15 @@ export default function AgentCLI() {
{viewMode && viewSession && (
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)] bg-amber-500/5 shrink-0 flex-wrap">
<span className="text-xs dg-text-secondary flex-1 min-w-[12rem]">
{t('aiops.historyReadonly', { title: viewSession.title })}
历史记录(只读)· {viewSession.title}
</span>
{canResumeView && (
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={resumeHistorySession}>
{t('aiops.continueTask')}
继续追问
</Button>
)}
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={handleNewTask}>
{t('aiops.newTask')}
新建任务
</Button>
</div>
)}
@@ -534,23 +723,25 @@ export default function AgentCLI() {
{items.length === 0 && (
<div className="h-full flex flex-col items-center justify-center dg-text-muted gap-3">
<Terminal className="w-10 h-10 opacity-40" />
{!serverId ? (
{!wsReady ? (
<>
<div className="text-sm">{t('aiops.selectOneHostFirst')}</div>
{servers.length === 0 && (
<div className="text-xs dg-text-muted">{t('aiops.noHostsHint')}</div>
)}
<div className="text-sm">
{cliMode === 'kubernetes' ? '请先选择集群和命名空间' : '请先选择一台目标主机'}
</div>
</>
) : !connected ? (
<>
<Loader2 className="w-6 h-6 animate-spin dg-text-blue" />
<div className="text-sm">{t('aiops.connectingHost')}</div>
<div className="text-sm">{cliMode === 'kubernetes' ? '正在连接集群...' : '正在连接主机...'}</div>
</>
) : (
<div className="text-sm text-center max-w-md">
{t('aiops.describeTaskHint')}
{cliMode === 'kubernetes'
? '描述 Kubernetes 排障任务,例如「查看 NotReady 节点」或「分析 nginx Pod 重启原因」'
: '描述你要执行的任务,例如「检查磁盘使用率并清理大文件」'}
<div className="text-xs dg-text-muted mt-2">
{t('aiops.currentSafetyMode', { label: currentSafety.label, short: currentSafety.short })}
当前安全模式:{currentSafety.label}({currentSafety.short})
{cliMode === 'kubernetes' && podName === POD_NONE && ' · 未选 Pod,仅集群/NS 级查询'}
</div>
</div>
)}
@@ -558,30 +749,32 @@ export default function AgentCLI() {
)}
<AgentChatTimeline items={items} activeRound={round} />
</div>
{items.length > 0 && (
{items.length > 0 && (!scrollAtTop || !scrollAtBottom) && (
<div className="absolute right-3 bottom-3 z-10 flex flex-col gap-1.5">
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-full shadow-md border border-[var(--dg-border)] bg-[var(--surface-raised)]/95 opacity-90 hover:opacity-100"
onClick={scrollToTop}
disabled={scrollAtTop}
title={t('common.goTop')}
>
<Rocket className="h-3.5 w-3.5 -rotate-45" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-full shadow-md border border-[var(--dg-border)] bg-[var(--surface-raised)]/95 opacity-90 hover:opacity-100"
onClick={scrollToBottom}
disabled={scrollAtBottom}
title={t('common.goBottom')}
>
<Rocket className="h-3.5 w-3.5 rotate-[135deg]" />
</Button>
{!scrollAtTop && (
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-full shadow-md border border-[var(--dg-border)] bg-[var(--surface-raised)]/95 opacity-90 hover:opacity-100"
onClick={scrollToTop}
title="回到顶部"
>
<ChevronUp className="h-4 w-4" />
</Button>
)}
{!scrollAtBottom && (
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-full shadow-md border border-[var(--dg-border)] bg-[var(--surface-raised)]/95 opacity-90 hover:opacity-100"
onClick={scrollToBottom}
title="回到底部"
>
<ChevronDown className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
@@ -593,23 +786,24 @@ export default function AgentCLI() {
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
placeholder={
viewMode ? t('aiops.viewModeHint')
: canContinue ? t('aiops.followupPlaceholder')
: serverId ? t('aiops.inputPlaceholderAgent') : t('aiops.selectTargetHostFirst')
viewMode ? '查看模式中,点击「继续追问」或「新建任务」'
: canContinue ? '继续追问,回车发送(Shift+回车换行)'
: wsReady ? '输入任务描述,回车发送(Shift+回车换行)'
: (cliMode === 'kubernetes' ? '请先选择集群和命名空间' : '请先选择目标主机')
}
rows={3}
disabled={viewMode}
className="w-full resize-none dg-input min-h-[5rem] max-h-40 text-sm leading-relaxed disabled:opacity-50"
/>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] dg-text-muted">{t('aiops.shiftEnterHint')}</span>
<span className="text-[10px] dg-text-muted">Shift+回车换行 · 回车发送</span>
{running ? (
<Button variant="destructive" size="sm" onClick={stop} className="bg-red-600 hover:bg-red-700">
<Square className="w-3.5 h-3.5 mr-1" /> {t('common.stop')}
<Square className="w-3.5 h-3.5 mr-1" /> 停止
</Button>
) : (
<Button size="sm" onClick={send} disabled={!input.trim() || viewMode} className="bg-blue-600 hover:bg-blue-700">
<Send className="w-3.5 h-3.5 mr-1" /> {t('common.execute')}
<Send className="w-3.5 h-3.5 mr-1" /> 执行
</Button>
)}
</div>
@@ -4,14 +4,16 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { agentCliApi, type AgentCliSession } from '@/api/aiops/agent-cli';
import { agentCliApi, type AgentCliMode, type AgentCliSession } from '@/api/aiops/agent-cli';
import { messagesToChatItems, type ChatItem } from './AgentCLIMessages';
import { toast } from 'sonner';
import { History, Trash2, Plus, Loader2, PanelLeftClose } from 'lucide-react';
import i18n from '@/i18n';
interface Props {
serverId: string;
mode: AgentCliMode;
serverId?: string;
clusterId?: string;
namespace?: string;
onView: (items: ChatItem[], session: AgentCliSession) => void;
onNewTask: () => void;
refreshKey?: number;
@@ -28,31 +30,38 @@ function statusBadge(status: string) {
}
}
export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKey, onCollapse }: Props) {
export default function AgentCLIHistory({ mode, serverId, clusterId, namespace, onView, onNewTask, refreshKey, onCollapse }: Props) {
const confirm = useConfirm();
const [sessions, setSessions] = useState<AgentCliSession[]>([]);
const [loading, setLoading] = useState(false);
const [activeId, setActiveId] = useState<number | null>(null);
const contextReady = mode === 'kubernetes'
? Boolean(clusterId && namespace)
: Boolean(serverId);
const loadSessions = useCallback(async () => {
if (!serverId) {
if (!contextReady) {
setSessions([]);
return;
}
setLoading(true);
try {
const res = await agentCliApi.getSessions({
server_id: Number(serverId),
mode,
server_id: mode === 'host' && serverId ? Number(serverId) : undefined,
cluster_id: mode === 'kubernetes' && clusterId ? Number(clusterId) : undefined,
namespace: mode === 'kubernetes' ? namespace : undefined,
page: 1,
page_size: 50,
});
setSessions(res?.list || []);
} catch {
toast.error(i18n.t('aiops.agentclihistory.s119e8948'));
toast.error('加载历史会话失败');
} finally {
setLoading(false);
}
}, [serverId]);
}, [contextReady, mode, serverId, clusterId, namespace]);
useEffect(() => { loadSessions(); }, [loadSessions, refreshKey]);
@@ -63,17 +72,17 @@ export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKe
setActiveId(sess.id);
onView(items, sess);
} catch {
toast.error(i18n.t('aiops.agentclihistory.sa4671c05'));
toast.error('加载会话详情失败');
}
};
const handleDelete = async (e: React.MouseEvent, sess: AgentCliSession) => {
e.stopPropagation();
const ok = await confirm({
title: i18n.t('audit.terminal.deleteTitle'),
description: i18n.t('aiops.confirmDeleteSession', { title: sess.title }),
confirmText: i18n.t('kubernetes.storage.delete2'),
cancelText: i18n.t('rbac.jobRole.cancel'),
title: '删除会话',
description: `确定删除「${sess.title}」?此操作不可恢复。`,
confirmText: '删除',
cancelText: '取消',
variant: 'destructive',
});
if (!ok) return;
@@ -84,9 +93,9 @@ export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKe
onNewTask();
}
loadSessions();
toast.success(i18n.t('monitor.silenceRules.deleted'));
toast.success('已删除');
} catch {
toast.error(i18n.t('kubernetes.msg.deleteFailed2'));
toast.error('删除失败');
}
};
@@ -94,23 +103,25 @@ export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKe
<div className="w-56 shrink-0 flex flex-col bg-[var(--dg-bg-secondary)]/60 border border-[var(--dg-border)] rounded-lg overflow-hidden">
<div className="flex items-center gap-1.5 px-2.5 py-2 border-b border-[var(--dg-border)]">
<History className="w-3.5 h-3.5 dg-text-secondary shrink-0" />
<span className="text-xs font-medium text-[var(--dg-text-primary)] flex-1 truncate"> {i18n.t('k8s.history.historical')} </span>
<span className="text-xs font-medium text-[var(--dg-text-primary)] flex-1 truncate">历史</span>
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => { setActiveId(null); onNewTask(); }}>
<Plus className="w-3 h-3 mr-0.5" />{i18n.t('aiops.newSessionBtn')}
<Plus className="w-3 h-3 mr-0.5" />新建
</Button>
{onCollapse && (
<Button variant="ghost" size="icon" className="h-6 w-6 dg-text-muted hover:dg-text-secondary" onClick={onCollapse} title={i18n.t('aiops.agentclihistory.s590d5c76')}>
<Button variant="ghost" size="icon" className="h-6 w-6 dg-text-muted hover:dg-text-secondary" onClick={onCollapse} title="收起历史">
<PanelLeftClose className="w-3.5 h-3.5" />
</Button>
)}
</div>
<ScrollArea className="flex-1 min-h-0">
{!serverId ? (
<div className="p-3 text-[10px] dg-text-muted text-center"> {i18n.t('aiops.agentclihistory.s414f8d5b')} </div>
{!contextReady ? (
<div className="p-3 text-[10px] dg-text-muted text-center">
{mode === 'kubernetes' ? '选择集群和命名空间后查看' : '选择主机后查看'}
</div>
) : loading ? (
<div className="p-3 flex justify-center"><Loader2 className="w-4 h-4 animate-spin dg-text-muted" /></div>
) : sessions.length === 0 ? (
<div className="p-3 text-[10px] dg-text-muted text-center"> {i18n.t('aiops.agentclihistory.s89fe87e5')} </div>
<div className="p-3 text-[10px] dg-text-muted text-center">暂无历史</div>
) : (
<div className="p-1 space-y-0.5">
{sessions.map((s) => (
@@ -118,7 +129,7 @@ export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKe
key={s.id}
type="button"
onClick={() => handleView(s)}
title={i18n.t('aiops.agentclihistory.se585e8a2')}
title="查看历史记录"
className={`w-full text-left rounded-md px-2 py-1.5 transition-colors group ${
activeId === s.id ? 'bg-blue-500/15 border border-blue-500/30' : 'hover:bg-[var(--dg-bg-hover)]/60 border border-transparent'
}`}
@@ -127,11 +138,11 @@ export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKe
<div className="flex-1 min-w-0">
<div className="text-[11px] text-[var(--dg-text-primary)] truncate leading-tight">{s.title}</div>
<div className="text-[10px] dg-text-muted mt-0.5">
{i18n.t('aiops.roundsTokensSummary', { rounds: s.rounds, tokens: s.total_tokens || 0 })}
{s.rounds}轮 · {s.total_tokens || 0}t
</div>
</div>
<Badge className={`text-[9px] px-1 py-0 border shrink-0 ${statusBadge(s.status)}`}>
{s.status === 'done' ? i18n.t('common.finish') : s.status === 'running' ? i18n.t('aiops.agentclihistory.s5fb0d006') : s.status === 'aborted' ? i18n.t('aiops.agentclihistory.sd8d7ca77') : i18n.t('system.loginFailed')}
{s.status === 'done' ? '完成' : s.status === 'running' ? '进行' : s.status === 'aborted' ? '中断' : '失败'}
</Badge>
<button
type="button"
@@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner';
import { aiApi } from '@/api';
import { buildCapacityInsightPrompt, type CapacityForecast } from './predictUtils';
import i18n from '@/i18n';
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown';
export function PredictiveCapacityTab({
forecasts,
@@ -28,7 +28,7 @@ export function PredictiveCapacityTab({
const res = await aiApi.chat([{ role: 'user', content: prompt }]);
setInsight(res?.content || '');
} catch {
toast.error(i18n.t('aiops.predictivecapac.s2d17e3f7'));
toast.error('AI 解读失败,请检查大模型配置或稍后重试');
} finally {
setAnalyzing(false);
}
@@ -40,7 +40,7 @@ export function PredictiveCapacityTab({
<div className="flex items-center justify-between">
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<HardDrive className="h-5 w-5 dg-text-yellow" />
{i18n.t('aiops.capacityWarning')}
容量规划预警
</CardTitle>
{!loading && forecasts.length > 0 && (
<Button
@@ -55,7 +55,7 @@ export function PredictiveCapacityTab({
) : (
<Sparkles className="h-3.5 w-3.5 mr-1" />
)}
{i18n.t('aiops.aiDeepAnalysis')}
AI 深度解读
</Button>
)}
</div>
@@ -63,10 +63,10 @@ export function PredictiveCapacityTab({
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12 dg-text-muted">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> {i18n.t('aiops.loadingCapacityData')}
<Loader2 className="h-5 w-5 animate-spin mr-2" /> 加载容量数据...
</div>
) : forecasts.length === 0 ? (
<div className="text-center py-12 dg-text-muted"> {i18n.t('aiops.predictivecapac.sf803eb75')} </div>
<div className="text-center py-12 dg-text-muted">暂无容量数据</div>
) : (
<div className="space-y-4">
{/* AI 深度解读结果(点击按钮后展示) */}
@@ -74,9 +74,13 @@ export function PredictiveCapacityTab({
<div className="p-4 rounded-lg border border-purple-500/30 bg-purple-500/5">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="h-4 w-4 dg-text-purple" />
<span className="text-sm font-medium text-purple-300">{i18n.t('aiops.aiDeepAnalysis')}</span>
<span className="text-sm font-medium text-purple-300">AI 深度解读</span>
</div>
<p className="text-sm text-[var(--dg-text-secondary)] whitespace-pre-wrap break-words">{insight}</p>
{looksLikeMarkdown(insight) ? (
<AgentMarkdown content={insight} />
) : (
<p className="text-sm text-[var(--dg-text-secondary)] whitespace-pre-wrap break-words">{insight}</p>
)}
</div>
)}
{forecasts.map((forecast, idx) => (
@@ -97,22 +101,24 @@ export function PredictiveCapacityTab({
}
>
{forecast.daysUntilThreshold <= 7
? i18n.t('aiops.predictivecapac.scb0ed5ef')
? '紧急'
: forecast.daysUntilThreshold <= 14
? i18n.t('kubernetes.event.warning')
: i18n.t('agent.heartbeatNormal')}
? '警告'
: '正常'}
</Badge>
</div>
<div className="mb-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="dg-text-secondary">{i18n.t('aiops.sevenDayForecast')}</span>
<span className="dg-text-secondary">7天使用率预测</span>
<span className="dg-text-secondary">
{forecast.daysUntilThreshold >= 999 ? (
i18n.t('aiops.predictutilsts.s179fd5a9')
'增长平缓,暂无触顶风险'
) : (
<>
{i18n.t('aiops.expectedReachThreshold', { days: forecast.daysUntilThreshold })}
预计{' '}
<span className="dg-text-yellow font-medium">{forecast.daysUntilThreshold}天</span>{' '}
后达到阈值
</>
)}
</span>
@@ -129,9 +135,9 @@ export function PredictiveCapacityTab({
</div>
</div>
<div className="flex items-center justify-between text-xs mt-1">
<span className="dg-text-muted">{i18n.t('aiops.currentUsageLabel', { n: forecast.currentUsage })}</span>
<span className="dg-text-yellow">{i18n.t('aiops.predictedUsageLabel', { n: forecast.predictedUsage })}</span>
<span className="dg-text-red">{i18n.t('aiops.thresholdLabel', { n: forecast.threshold })}</span>
<span className="dg-text-muted">当前 {forecast.currentUsage}%</span>
<span className="dg-text-yellow">预测 {forecast.predictedUsage}%</span>
<span className="dg-text-red">阈值 {forecast.threshold}%</span>
</div>
</div>
@@ -139,7 +145,7 @@ export function PredictiveCapacityTab({
<div className="flex items-start gap-2">
<Zap className="h-4 w-4 dg-text-green mt-0.5" />
<div>
<span className="text-sm dg-text-secondary">{i18n.t('aiops.aiSuggestionColon')}</span>
<span className="text-sm dg-text-secondary">AI建议:</span>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{forecast.recommendation}</p>
</div>
</div>
@@ -0,0 +1,346 @@
import { useState } from 'react';
import {
Wallet,
TrendingUp,
TrendingDown,
Minus,
Loader2,
Sparkles,
Zap,
AlertTriangle,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner';
import { aiApi } from '@/api';
import {
buildCostInsightPrompt,
trendColorClass,
type ComputedCostPredictions,
} from './predictUtils';
import {
formatCurrency,
TrendChart,
providerLabels,
ProviderDot,
} from '@/components/cloud/CloudBillingHelpers';
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown';
export function PredictiveCostTab({
costData,
loading,
}: {
costData: ComputedCostPredictions;
loading: boolean;
}) {
const [insight, setInsight] = useState('');
const [analyzing, setAnalyzing] = useState(false);
const { summary, dimensionForecasts, budgetRisks, billingPattern, recentDailyAvg } = costData;
const hasData = summary.monthCost > 0 || summary.monthTrend.length > 0;
const getTrendIcon = (trend: string) => {
switch (trend) {
case 'up':
return <TrendingUp className="h-4 w-4 dg-text-red" />;
case 'down':
return <TrendingDown className="h-4 w-4 dg-text-green" />;
default:
return <Minus className="h-4 w-4 dg-text-secondary" />;
}
};
const analyze = async () => {
const prompt = buildCostInsightPrompt(costData);
if (!prompt) return;
setAnalyzing(true);
try {
const res = await aiApi.chat([{ role: 'user', content: prompt }]);
setInsight(res?.content || '');
} catch {
toast.error('AI 解读失败,请检查大模型配置或稍后重试');
} finally {
setAnalyzing(false);
}
};
return (
<div className="space-y-4">
<Card className="dg-card">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Wallet className="h-5 w-5 dg-text-emerald" />
云成本预测总览
</CardTitle>
{!loading && hasData && (
<Button
variant="outline"
size="sm"
onClick={analyze}
disabled={analyzing}
className="border-purple-500/30 text-purple-300 hover:text-purple-200 h-7"
>
{analyzing ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5 mr-1" />
)}
AI 深度解读
</Button>
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12 dg-text-muted">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> 加载账单数据...
</div>
) : !hasData ? (
<div className="text-center py-12 dg-text-muted">
暂无账单数据,请先在云账单管理中录入费用记录
</div>
) : (
<div className="space-y-4">
{insight && (
<div className="p-4 rounded-lg border border-purple-500/30 bg-purple-500/5">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="h-4 w-4 dg-text-purple" />
<span className="text-sm font-medium text-purple-300">AI 深度解读</span>
</div>
{looksLikeMarkdown(insight) ? (
<AgentMarkdown content={insight} />
) : (
<p className="text-sm text-[var(--dg-text-secondary)] whitespace-pre-wrap break-words">
{insight}
</p>
)}
</div>
)}
{billingPattern === 'front_loaded' && (
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-500/30 bg-amber-500/5 text-sm">
<AlertTriangle className="h-4 w-4 dg-text-yellow shrink-0 mt-0.5" />
<span className="dg-text-secondary">
账单呈月初集中、近7日日均 {formatCurrency(recentDailyAvg)},可能含预付费或资源已停用;预测已按近期运行费率外推。
</span>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50">
<p className="text-xs dg-text-muted mb-1">本月已花</p>
<p className="text-lg font-bold text-[var(--dg-text-primary)]">
{formatCurrency(summary.monthCost)}
</p>
</div>
<div className="p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50">
<p className="text-xs dg-text-muted mb-1">预测月末</p>
<p className={`text-lg font-bold ${trendColorClass(summary.trend)}`}>
{formatCurrency(summary.predictedMonthEnd)}
</p>
</div>
<div className="p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50">
<p className="text-xs dg-text-muted mb-1">日均费用</p>
<p className="text-lg font-bold text-[var(--dg-text-primary)]">
{formatCurrency(summary.dailyAvg)}
</p>
</div>
<div className="p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50">
<p className="text-xs dg-text-muted mb-1">距月末</p>
<p className="text-lg font-bold text-[var(--dg-text-primary)]">
{summary.daysRemaining} 天
</p>
<div className="flex items-center gap-1 mt-1 text-xs">
{getTrendIcon(summary.trend)}
<span className={trendColorClass(summary.trend)}>
{summary.trend === 'up' ? '上升' : summary.trend === 'down' ? '下降' : '平稳'}
</span>
<span className="dg-text-muted ml-1">置信 {summary.confidence}%</span>
</div>
</div>
</div>
<div className="p-4 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50">
<p className="text-sm font-medium text-[var(--dg-text-primary)] mb-3">本月日费用趋势</p>
<TrendChart data={summary.monthTrend} />
</div>
</div>
)}
</CardContent>
</Card>
{!loading && dimensionForecasts.length > 0 && (
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-[var(--dg-text-primary)] text-base">分维度成本预测</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{dimensionForecasts.map((dim, idx) => (
<div
key={idx}
className="p-4 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{dim.provider && <ProviderDot provider={dim.provider} />}
<span className="font-medium text-[var(--dg-text-primary)]">
{dim.dimension}
</span>
{dim.provider && (
<span className="text-xs dg-text-muted">
{providerLabels[dim.provider] || dim.provider}
</span>
)}
</div>
<div className="flex items-center gap-1 text-sm">
{getTrendIcon(dim.trend)}
<span className={trendColorClass(dim.trend)}>
{dim.trend === 'up' ? '上升' : dim.trend === 'down' ? '下降' : '平稳'}
</span>
</div>
</div>
<div className="grid grid-cols-3 gap-3 mb-3 text-sm">
<div>
<p className="text-xs dg-text-muted">本月累计</p>
<p className="font-semibold text-[var(--dg-text-primary)]">
{formatCurrency(dim.currentMonth)}
</p>
</div>
<div>
<p className="text-xs dg-text-muted">预测月末</p>
<p className={`font-semibold ${trendColorClass(dim.trend)}`}>
{formatCurrency(dim.predictedMonthEnd)}
</p>
</div>
<div>
<p className="text-xs dg-text-muted">日均</p>
<p className="font-semibold text-[var(--dg-text-primary)]">
{formatCurrency(dim.dailyAvg)}
</p>
</div>
</div>
<div className="p-3 bg-[var(--dg-bg-secondary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-start gap-2">
<Zap className="h-4 w-4 dg-text-green mt-0.5 shrink-0" />
<p className="text-sm text-[var(--dg-text-secondary)]">{dim.recommendation}</p>
</div>
</div>
</div>
))}
</CardContent>
</Card>
)}
{!loading && (
<Card className="dg-card">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2 text-base">
<AlertTriangle className="h-5 w-5 dg-text-amber" />
预算触线风险
</CardTitle>
<span className="text-xs dg-text-muted">基于账单预警规则与费用趋势推算</span>
</div>
</CardHeader>
<CardContent>
{budgetRisks.length === 0 ? (
<div className="text-center py-8 dg-text-muted text-sm">
暂无预算风险,可在云账单管理中配置预警规则
</div>
) : (
<div className="space-y-3">
{budgetRisks.map((risk, idx) => (
<div
key={idx}
className="p-4 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-medium text-[var(--dg-text-primary)]">{risk.ruleName}</h4>
<Badge variant="outline" className="text-xs border-[var(--dg-border)]">
{risk.provider}
</Badge>
<Badge
variant="outline"
className={
risk.probability >= 70
? 'border-red-500/30 dg-text-red bg-red-500/10'
: risk.probability >= 50
? 'border-amber-500/30 dg-text-yellow bg-amber-500/10'
: 'border-blue-500/30 dg-text-blue bg-blue-500/10'
}
>
{risk.metric === 'daily' ? '日预算' : '月预算'}
</Badge>
</div>
<p className="text-sm dg-text-secondary mt-2">
当前 {formatCurrency(risk.current)} / 阈值 {formatCurrency(risk.threshold)}
,预测 {formatCurrency(risk.predicted)}
</p>
<div className="flex items-center gap-4 mt-2 text-xs dg-text-muted">
<span>{risk.timeframe}</span>
<span>
触线概率{' '}
<span
className={
risk.probability >= 70 ? 'dg-text-red' : 'dg-text-yellow'
}
>
{risk.probability}%
</span>
</span>
</div>
</div>
<div className="w-14 h-14 relative shrink-0">
<svg className="w-full h-full transform -rotate-90">
<circle
cx="28"
cy="28"
r="24"
stroke="currentColor"
strokeWidth="4"
fill="transparent"
className="text-[var(--dg-text-muted)]"
/>
<circle
cx="28"
cy="28"
r="24"
stroke="currentColor"
strokeWidth="4"
fill="transparent"
strokeDasharray={`${risk.probability * 1.51} 151`}
className={
risk.probability >= 70
? 'dg-text-red'
: risk.probability >= 50
? 'dg-text-yellow'
: 'dg-text-blue'
}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-bold text-[var(--dg-text-primary)]">
{risk.probability}%
</span>
</div>
</div>
</div>
<div className="mt-3 p-3 bg-[var(--dg-bg-secondary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-start gap-2">
<Zap className="h-4 w-4 dg-text-green mt-0.5 shrink-0" />
<p className="text-sm text-[var(--dg-text-secondary)]">{risk.suggestedAction}</p>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}
+499 -129
View File
@@ -1,5 +1,4 @@
import type { JsonData } from "@/types/common";
import i18n from '@/i18n';
/**
* AI 预测分析模块的纯计算工具 — 类型定义、常量、线性回归、预测推算
*
@@ -30,28 +29,78 @@ export interface CapacityForecast {
recommendation: string;
}
export interface AnomalyPrediction {
metric: string;
predictedAnomaly: string;
/** 日费用数据点 */
export interface DailyCostPoint {
date: string;
cost: number;
}
/** 单维度(全部 / 云厂商)成本预测 */
export interface CostDimensionForecast {
dimension: string;
provider?: string;
currentMonth: number;
predictedMonthEnd: number;
dailyAvg: number;
trend: 'up' | 'down' | 'stable';
confidence: number;
recommendation: string;
}
/** 预算触线风险(对接 BillingAlertRule) */
export interface BudgetRiskForecast {
ruleName: string;
provider: string;
metric: 'daily' | 'monthly';
threshold: number;
current: number;
predicted: number;
probability: number;
timeframe: string;
suggestedAction: string;
}
/** 成本预测汇总 */
export interface CostPredictionSummary {
monthCost: number;
predictedMonthEnd: number;
todayCost: number;
yesterdayCost: number;
dailyAvg: number;
daysRemaining: number;
trend: 'up' | 'down' | 'stable';
confidence: number;
monthTrend: DailyCostPoint[];
providerDist: { provider: string; cost: number; percent: number }[];
}
export interface ComputedCostPredictions {
summary: CostPredictionSummary;
dimensionForecasts: CostDimensionForecast[];
budgetRisks: BudgetRiskForecast[];
budgetRiskCount: number;
/** 近 7 日日均(用于识别预付费后按量趋零) // Logan.Li */
recentDailyAvg: number;
/** 账单形态:steady 平稳 / front_loaded 月初集中 / sparse 样本不足 */
billingPattern: 'steady' | 'front_loaded' | 'sparse';
/** 本月 Top 服务消费 */
topServices: { service: string; cost: number }[];
}
// ============ 常量 ============
/** 指标定义:后端 metric name → 展示标签 / 告警表达式前缀 / 阈值 */
export const METRIC_DEFS = [
{ key: 'cpu', label: i18n.t('aiops.predictutilsts.s7054bc34'), exprKey: 'cpu_usage', threshold: 85 },
{ key: 'memory', label: i18n.t('kubernetes.cluster.memoryUsage'), exprKey: 'memory_usage', threshold: 85 },
{ key: 'disk', label: i18n.t('monitor.hostDisk'), exprKey: 'disk_usage', threshold: 90 },
{ key: 'cpu', label: 'CPU使用率', exprKey: 'cpu_usage', threshold: 85 },
{ key: 'memory', label: '内存使用率', exprKey: 'memory_usage', threshold: 85 },
{ key: 'disk', label: '磁盘使用率', exprKey: 'disk_usage', threshold: 90 },
] as const;
export const TIME_RANGE_LABELS: Record<string, string> = {
'1h': i18n.t('aiops.predictutilsts.s75719bb8'),
'24h': i18n.t('aiops.predictutilsts.sfc78646e'),
'7d': i18n.t('aiops.predictutilsts.s5b5a820c'),
'30d': i18n.t('aiops.predictutilsts.s933ab0a6'),
'1h': '1小时后',
'24h': '24小时后',
'7d': '7天后',
'30d': '30天后',
};
export const TIME_RANGE_HOURS: Record<string, number> = {
@@ -97,45 +146,27 @@ export function extractSeries( metrics: JsonData[], name: string): number[] {
.map((m) => Number(m.value) || 0);
}
/** 从告警表达式中解析阈值,如 "cpu_usage > 80" → 80 */
export function parseThreshold(expr: string): number | null {
const m = expr.match(/[><=]\s*([\d.]+)/);
return m ? parseFloat(m[1]) : null;
}
/** 容量规划建议 */
export function capacityRecommendation(metricKey: string, days: number): string {
const level = days <= 7 ? 'urgent' : days <= 14 ? 'warning' : 'normal';
const recs: Record<string, Record<string, string>> = {
urgent: {
cpu: i18n.t('aiops.predictutilsts.s4d0bd643'),
memory: i18n.t('aiops.predictutilsts.sb4593f3a'),
disk: i18n.t('aiops.predictutilsts.s6c25383f'),
cpu: 'CPU增长趋势明显,建议尽快扩容计算资源或优化高负载进程',
memory: '内存增长趋势明显,建议增加内存或排查内存泄漏',
disk: '磁盘空间即将耗尽,建议立即扩容或清理历史数据与日志',
},
warning: {
cpu: i18n.t('aiops.predictutilsts.s8ea826f4'),
memory: i18n.t('aiops.predictutilsts.sb08bd5fc'),
disk: i18n.t('aiops.predictutilsts.s4cdda5dc'),
cpu: 'CPU使用率呈上升趋势,建议持续监控并准备扩容计划',
memory: '内存使用率呈上升趋势,建议优化应用内存使用',
disk: '磁盘使用率持续增长,建议规划数据归档和清理策略',
},
normal: {
cpu: i18n.t('aiops.predictutilsts.sfa295c44'),
memory: i18n.t('aiops.predictutilsts.s7156a4c0'),
disk: i18n.t('aiops.predictutilsts.s0073f3a3'),
cpu: 'CPU使用正常,建议持续监控',
memory: '内存使用正常,建议持续监控',
disk: '磁盘空间充足,建议持续监控',
},
};
return recs[level]?.[metricKey] || i18n.t('aiops.predictutilsts.sfeb368e6');
}
/** 异常建议操作 */
export function anomalyAction(metricKey: string): string {
const actions: Record<string, string> = {
cpu: i18n.t('aiops.predictutilsts.sdf4c1339'),
memory: i18n.t('aiops.predictutilsts.sdf7d1c13'),
disk: i18n.t('aiops.predictutilsts.sb0436cd9'),
service: i18n.t('aiops.predictutilsts.s71b29ff9'),
pod: i18n.t('aiops.predictutilsts.s0c1a3cd3'),
};
return actions[metricKey] || i18n.t('aiops.predictutilsts.se5bd8880');
return recs[level]?.[metricKey] || '建议持续监控资源使用趋势';
}
// ============ 综合预测推算 ============
@@ -144,8 +175,6 @@ export function anomalyAction(metricKey: string): string {
export interface ComputePredictionsInput {
rawMetrics: JsonData[];
serverStats?: JsonData;
alertEvents: JsonData[];
alertRules: JsonData[];
selectedTimeRange: string; // '1h' | '24h' | '7d' | '30d'
}
@@ -153,21 +182,19 @@ export interface ComputePredictionsInput {
export interface ComputedPredictions {
predictions: PredictionMetric[];
capacityForecasts: CapacityForecast[];
dedupedAnomalies: AnomalyPrediction[];
accuracy: number;
predictedAlertCount: number;
capacityWarningCount: number;
}
/**
* 综合预测推算:基于时序指标 + 告警规则,计算指标预测、容量规划、异常预测。
* 主组件 AIPredictiveAnalytics.tsx 直接调用此函数获取所有展示数据。
* 综合预测推算:基于时序指标计算指标预测与容量规划。
* 主组件 AIPredictiveAnalytics.tsx 直接调用此函数获取展示数据。
* // Logan.Li
*/
export function computePredictions(input: ComputePredictionsInput): ComputedPredictions {
const { rawMetrics, serverStats, alertEvents, alertRules, selectedTimeRange } = input;
const { rawMetrics, serverStats, selectedTimeRange } = input;
const horizonHours = TIME_RANGE_HOURS[selectedTimeRange] || 24;
const timeRangeLabel = TIME_RANGE_LABELS[selectedTimeRange] || i18n.t('aiops.predictutilsts.sfc78646e');
const timeRangeLabel = TIME_RANGE_LABELS[selectedTimeRange] || '24小时后';
// ---- 指标预测 ----
const predictions: PredictionMetric[] = METRIC_DEFS.map((def) => {
@@ -207,9 +234,9 @@ export function computePredictions(input: ComputePredictionsInput): ComputedPred
daysUntilThreshold = daysUntilThreshold < 1 ? 1 : daysUntilThreshold;
}
const resourceNames: Record<string, string> = {
cpu: i18n.t('aiops.predictutilsts.se67014cc'),
memory: i18n.t('aiops.predictutilsts.s2050241a'),
disk: i18n.t('aiops.predictutilsts.sf9724444'),
cpu: '服务器CPU算力',
memory: '服务器内存',
disk: '存储磁盘空间',
};
return {
resource: resourceNames[def.key] || def.label,
@@ -222,89 +249,18 @@ export function computePredictions(input: ComputePredictionsInput): ComputedPred
};
}).filter((f) => f.currentUsage > 0);
// ---- 异常预测(基于告警规则 + 趋势 + 事件频率) ----
const anomalyPredictions: AnomalyPrediction[] = [];
for (const rule of alertRules) {
if (!rule.enabled) continue;
const threshold = parseThreshold(rule.expression || '');
const exprLower = (rule.expression || '').toLowerCase();
const matchedDef = METRIC_DEFS.find((def) => exprLower.includes(def.exprKey));
if (matchedDef && threshold) {
const series = extractSeries(rawMetrics, matchedDef.key);
const { slope } = linearRegression(series);
const avgFallback =
matchedDef.key === 'cpu'
? serverStats.avg_cpu
: matchedDef.key === 'memory'
? serverStats.avg_memory
: serverStats.avg_disk;
const current = series.length > 0 ? series[series.length - 1] : avgFallback || 0;
const ratio = threshold > 0 ? current / threshold : 0;
let probability = Math.round(ratio * 55);
if (slope > 0.05) probability += 15;
const eventCount = alertEvents.filter(
(a) => a.rule_id === rule.id || a.rule_name === rule.name,
).length;
probability += Math.min(20, eventCount * 3);
probability = Math.max(5, Math.min(90, probability));
// 时间范围:基于斜率估算何时触及阈值
let timeframe = i18n.t('aiops.next24Hours');
if (slope > 0.01 && threshold > current) {
const hours = (threshold - current) / slope;
if (hours < 4) timeframe = i18n.t('aiops.next4Hours');
else if (hours < 12) timeframe = i18n.t('aiops.next12Hours');
else if (hours < 24) timeframe = i18n.t('aiops.next24Hours');
else timeframe = i18n.t('aiops.nextNDays', { n: Math.ceil(hours / 24) });
}
anomalyPredictions.push({
metric: rule.name,
predictedAnomaly: rule.description || i18n.t('aiops.mayTrigger', { name: rule.name }),
probability,
timeframe,
suggestedAction: anomalyAction(matchedDef.key),
});
} else {
// 非 metric 规则(如服务不可用、Pod重启)基于事件频率
const eventCount = alertEvents.filter(
(a) => a.rule_id === rule.id || a.rule_name === rule.name,
).length;
if (eventCount > 0) {
const exprKey = exprLower.includes('service') ? 'service' : exprLower.includes('pod') ? 'pod' : 'service';
anomalyPredictions.push({
metric: rule.name,
predictedAnomaly: rule.description || i18n.t('aiops.mayRecur', { name: rule.name }),
probability: Math.min(60, 20 + eventCount * 5),
timeframe: i18n.t('aiops.predictutilsts.s658d6e80'),
suggestedAction: anomalyAction(exprKey),
});
}
}
}
// 去重:同一指标名只保留概率最高的一条
const dedupedAnomalies = anomalyPredictions
.sort((a, b) => b.probability - a.probability)
.filter((item, idx, arr) => arr.findIndex((x) => x.metric === item.metric) === idx);
// ---- 概览统计 ----
const avgR2 = METRIC_DEFS.length > 0
? METRIC_DEFS.reduce((sum, def) => sum + linearRegression(extractSeries(rawMetrics, def.key)).r2, 0) /
METRIC_DEFS.length
: 0;
const accuracy = Math.round(78 + avgR2 * 17);
const predictedAlertCount = dedupedAnomalies.filter((a) => a.probability >= 50).length;
const capacityWarningCount = capacityForecasts.filter((f) => f.daysUntilThreshold <= 14).length;
return {
predictions,
capacityForecasts,
dedupedAnomalies,
accuracy,
predictedAlertCount,
capacityWarningCount,
};
}
@@ -328,18 +284,432 @@ export function trendColorClass(trend: string): string {
export function buildCapacityInsightPrompt(forecasts: CapacityForecast[]): string {
if (forecasts.length === 0) return '';
const lines = forecasts.map((f) => {
const risk = f.daysUntilThreshold >= 999 ? i18n.t('aiops.predictutilsts.s179fd5a9') : i18n.t('aiops.daysToThreshold', { days: f.daysUntilThreshold });
return i18n.t('aiops.forecastSummary', { resource: f.resource, current: f.currentUsage, predicted: f.predictedUsage, threshold: f.threshold, risk });
const risk = f.daysUntilThreshold >= 999 ? '增长平缓,暂无触顶风险' : `${f.daysUntilThreshold} 天后触及阈值`;
return `- ${f.resource}:当前 ${f.currentUsage}% → 7天后预测 ${f.predictedUsage}%(阈值 ${f.threshold}%),${risk}`;
});
return [
i18n.t('aiops.predictutilsts.s3b46e5a0'),
'你是 AIOps 容量规划专家。基于以下服务器资源容量预测数据,给出综合风险评估与可执行的运维建议:',
'',
i18n.t('aiops.predictutilsts.s586bac5d'),
'【容量预测数据】',
...lines,
'',
i18n.t('aiops.predictutilsts.sdf6ebee3'),
i18n.t('aiops.predictutilsts.s534b6893'),
i18n.t('aiops.predictutilsts.saf173e7a'),
i18n.t('aiops.predictutilsts.s4ed1d035'),
'要求:',
'1. 指出最紧急的风险资源(触及阈值最快者优先)',
'2. 给出针对性处置建议(扩容 / 清理 / 优化,具体到操作命令或步骤)',
'3. Markdown 输出,用 **加粗小标题** 分段(如 **紧急风险**、**处置建议**),每段 1~2 条要点,总字数 300 字以内',
].join('\n');
}
// ============ 云成本预测 ============
const PROVIDER_LABELS: Record<string, string> = {
aliyun: '阿里云',
tencent: '腾讯云',
aws: 'AWS',
huawei: '华为云',
azure: 'Azure',
};
/** 本月第一天 YYYY-MM-DD */
export function monthStartDate(ref = new Date()): string {
return `${ref.getFullYear()}-${String(ref.getMonth() + 1).padStart(2, '0')}-01`;
}
/** 本月剩余天数(不含今天) */
export function daysRemainingInMonth(ref = new Date()): number {
const lastDay = new Date(ref.getFullYear(), ref.getMonth() + 1, 0).getDate();
return Math.max(0, lastDay - ref.getDate());
}
/** 账单明细按日聚合 */
export function aggregateDailyCosts(
records: JsonData[],
filter?: { startDate?: string; provider?: string },
): DailyCostPoint[] {
const map = new Map<string, number>();
for (const r of records) {
const date = String(r.billing_date || '');
if (!date) continue;
if (filter?.startDate && date < filter.startDate) continue;
if (filter?.provider && r.provider !== filter.provider) continue;
map.set(date, (map.get(date) || 0) + (Number(r.cost) || 0));
}
return Array.from(map.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([date, cost]) => ({ date, cost }));
}
/** 本月累计费用 */
export function sumMonthCost(records: JsonData[], provider?: string): number {
const start = monthStartDate();
return records.reduce((sum, r) => {
const date = String(r.billing_date || '');
if (date < start) return sum;
if (provider && r.provider !== provider) return sum;
return sum + (Number(r.cost) || 0);
}, 0);
}
function costTrendFromSlope(slope: number): 'up' | 'down' | 'stable' {
if (slope > 1) return 'up';
if (slope < -1) return 'down';
return 'stable';
}
/** 成本优化建议 */
export function costRecommendation(trend: 'up' | 'down' | 'stable', ratio: number): string {
if (trend === 'up' && ratio >= 0.85) {
return '费用增长较快且接近预算,建议排查高消费服务、清理闲置资源并优化 CDN/存储计费';
}
if (trend === 'up') {
return '费用呈上升趋势,建议按云厂商与服务维度复盘账单,锁定异常增长项';
}
if (trend === 'down') {
return '费用呈下降趋势,可维持当前优化策略并持续监控';
}
return '费用整体平稳,建议设置月度预算预警并定期核对账单';
}
/** 预算风险建议 */
function budgetRiskAction(metric: 'daily' | 'monthly', provider: string): string {
const scope = provider && provider !== '全部' ? `${provider} ` : '';
if (metric === 'daily') {
return `检查${scope}今日高消费服务,暂停非必要资源或调整弹性伸缩策略`;
}
return `复盘${scope}本月账单构成,优化存储/CDN/计算规格,必要时下调预算外资源配额`;
}
interface MonthEndForecast {
currentMonth: number;
predictedMonthEnd: number;
dailyAvg: number;
recentDailyAvg: number;
billingPattern: 'steady' | 'front_loaded' | 'sparse';
trend: 'up' | 'down' | 'stable';
confidence: number;
series: DailyCostPoint[];
}
/** 识别账单形态(预付费常表现为月初大额、后续近零) // Logan.Li */
function detectBillingPattern(series: DailyCostPoint[]): 'steady' | 'front_loaded' | 'sparse' {
const active = series.filter((d) => d.cost > 0);
if (active.length < 2) return 'sparse';
const total = series.reduce((s, d) => s + d.cost, 0);
const recent = series.slice(-Math.min(7, series.length));
const recentAvg = recent.reduce((s, d) => s + d.cost, 0) / recent.length;
const overallAvg = total / series.length;
if (overallAvg > 0 && recentAvg < overallAvg * 0.25) return 'front_loaded';
return 'steady';
}
function recentRunRate(series: DailyCostPoint[], days = 7): number {
const recent = series.slice(-Math.min(days, series.length));
if (recent.length === 0) return 0;
return recent.reduce((s, d) => s + d.cost, 0) / recent.length;
}
/** 本月 Top 服务消费 */
function topServicesByCost(records: JsonData[], limit = 5): { service: string; cost: number }[] {
const start = monthStartDate();
const map = new Map<string, number>();
records.forEach((r) => {
const date = String(r.billing_date || '');
if (date < start) return;
const svc = String(r.service || '其他');
map.set(svc, (map.get(svc) || 0) + (Number(r.cost) || 0));
});
return [...map.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([service, cost]) => ({ service, cost: Math.round(cost * 100) / 100 }));
}
/** 基于日费用序列预测月末总费用 */
function forecastMonthEnd(
records: JsonData[],
statsMonthCost: number,
fallbackTrend: DailyCostPoint[],
provider?: string,
): MonthEndForecast {
const start = monthStartDate();
const monthSeries = aggregateDailyCosts(records, { startDate: start, provider });
const series = monthSeries.length >= 2 ? monthSeries : fallbackTrend;
const costs = series.map((d) => d.cost);
const { slope, r2 } = linearRegression(costs);
const currentMonth =
monthSeries.length > 0 ? monthSeries.reduce((s, d) => s + d.cost, 0) : statsMonthCost;
const daysLeft = daysRemainingInMonth();
const dailyAvg = costs.length > 0 ? costs.reduce((a, b) => a + b, 0) / costs.length : 0;
const recentDailyAvg = recentRunRate(series);
const billingPattern = detectBillingPattern(series);
// 预付费/前高后平:用近 7 日运行费率外推,避免线性回归高估或矛盾 // Logan.Li
const extrapolation =
billingPattern === 'front_loaded' ? recentDailyAvg * daysLeft : slope * daysLeft;
const predictedMonthEnd = Math.max(0, Math.round((currentMonth + extrapolation) * 100) / 100);
let confidence = Math.round(55 + r2 * 40);
if (billingPattern === 'front_loaded') confidence = Math.max(45, confidence - 15);
if (billingPattern === 'sparse') confidence = Math.min(confidence, 50);
return {
currentMonth,
predictedMonthEnd,
dailyAvg: Math.round(dailyAvg * 100) / 100,
recentDailyAvg: Math.round(recentDailyAvg * 100) / 100,
billingPattern,
trend: costTrendFromSlope(billingPattern === 'front_loaded' ? recentDailyAvg - dailyAvg : slope),
confidence,
series: monthSeries.length > 0 ? monthSeries : fallbackTrend,
};
}
/** computeCostPredictions 输入 */
export interface ComputeCostPredictionsInput {
billingStats: JsonData | null;
billingRecords: JsonData[];
billingAlertRules: JsonData[];
}
/**
* 云成本预测:基于账单日粒度时序 + 预算预警规则,推算月末费用与触线风险。
* // Logan.Li
*/
export function computeCostPredictions(input: ComputeCostPredictionsInput): ComputedCostPredictions {
const { billingStats, billingRecords, billingAlertRules } = input;
const empty: ComputedCostPredictions = {
summary: {
monthCost: 0,
predictedMonthEnd: 0,
todayCost: 0,
yesterdayCost: 0,
dailyAvg: 0,
daysRemaining: daysRemainingInMonth(),
trend: 'stable',
confidence: 0,
monthTrend: [],
providerDist: [],
},
dimensionForecasts: [],
budgetRisks: [],
budgetRiskCount: 0,
recentDailyAvg: 0,
billingPattern: 'sparse',
topServices: [],
};
if (!billingStats) return empty;
const monthTrend: DailyCostPoint[] = Array.isArray(billingStats.month_trend)
? billingStats.month_trend.map((d: JsonData) => ({
date: String(d.date || ''),
cost: Number(d.cost) || 0,
}))
: [];
const providerDist: { provider: string; cost: number; percent: number }[] = Array.isArray(
billingStats.provider_dist,
)
? billingStats.provider_dist.map((p: JsonData) => ({
provider: String(p.provider || ''),
cost: Number(p.cost) || 0,
percent: Number(p.percent) || 0,
}))
: [];
const overall = forecastMonthEnd(
billingRecords,
Number(billingStats.month_cost) || 0,
monthTrend,
);
const summary: CostPredictionSummary = {
monthCost: Number(billingStats.month_cost) || overall.currentMonth,
predictedMonthEnd: overall.predictedMonthEnd,
todayCost: Number(billingStats.today_cost) || 0,
yesterdayCost: Number(billingStats.yesterday_cost) || 0,
dailyAvg: overall.dailyAvg,
daysRemaining: daysRemainingInMonth(),
trend: overall.trend,
confidence: overall.confidence,
monthTrend: overall.series.length > 0 ? overall.series : monthTrend,
providerDist,
};
// 全部 + 各云厂商维度预测
const dimensionForecasts: CostDimensionForecast[] = [];
const allRatio =
summary.predictedMonthEnd > 0 ? summary.monthCost / summary.predictedMonthEnd : 0;
dimensionForecasts.push({
dimension: '全部云账号',
currentMonth: summary.monthCost,
predictedMonthEnd: summary.predictedMonthEnd,
dailyAvg: summary.dailyAvg,
trend: summary.trend,
confidence: summary.confidence,
recommendation: costRecommendation(summary.trend, allRatio),
});
const providers = new Set<string>();
providerDist.forEach((p) => providers.add(p.provider));
billingRecords.forEach((r) => {
if (r.provider) providers.add(String(r.provider));
});
for (const provider of providers) {
if (!provider) continue;
const provForecast = forecastMonthEnd(billingRecords, 0, monthTrend, provider);
const provMonth =
provForecast.currentMonth > 0 ? provForecast.currentMonth : sumMonthCost(billingRecords, provider);
const ratio = provForecast.predictedMonthEnd > 0 ? provMonth / provForecast.predictedMonthEnd : 0;
dimensionForecasts.push({
dimension: PROVIDER_LABELS[provider] || provider,
provider,
currentMonth: provMonth,
predictedMonthEnd: provForecast.predictedMonthEnd,
dailyAvg: provForecast.dailyAvg,
trend: provForecast.trend,
confidence: provForecast.confidence,
recommendation: costRecommendation(provForecast.trend, ratio),
});
}
dimensionForecasts.sort((a, b) => b.predictedMonthEnd - a.predictedMonthEnd);
// 预算触线风险
const budgetRisks: BudgetRiskForecast[] = [];
const activeAlerts: JsonData[] = Array.isArray(billingStats.alerts) ? billingStats.alerts : [];
for (const rule of billingAlertRules) {
if (!rule.enabled) continue;
const threshold = Number(rule.threshold) || 0;
if (threshold <= 0) continue;
const metric = (rule.metric === 'daily' ? 'daily' : 'monthly') as 'daily' | 'monthly';
const ruleProvider = String(rule.provider || '');
const providerLabel = ruleProvider ? PROVIDER_LABELS[ruleProvider] || ruleProvider : '全部';
let current = 0;
let predicted = 0;
let slope = 0;
if (metric === 'daily') {
current = Number(billingStats.today_cost) || 0;
if (ruleProvider) {
const today = new Date().toISOString().slice(0, 10);
current = billingRecords
.filter((r) => r.billing_date === today && r.provider === ruleProvider)
.reduce((s, r) => s + (Number(r.cost) || 0), 0);
}
const recent = aggregateDailyCosts(billingRecords, ruleProvider ? { provider: ruleProvider } : undefined);
const costs = recent.slice(-7).map((d) => d.cost);
slope = linearRegression(costs).slope;
predicted = Math.max(0, current + slope);
} else {
const provForecast = ruleProvider
? forecastMonthEnd(billingRecords, 0, monthTrend, ruleProvider)
: overall;
current = ruleProvider ? sumMonthCost(billingRecords, ruleProvider) : summary.monthCost;
predicted = provForecast.predictedMonthEnd;
const costs = provForecast.series.map((d) => d.cost);
slope = linearRegression(costs).slope;
}
const active = activeAlerts.find((a) => a.rule_id === rule.id || a.rule_name === rule.name);
if (active) current = Number(active.current) || current;
const ratio = threshold > 0 ? predicted / threshold : 0;
let probability = Math.round(ratio * 50);
if (slope > 0.5) probability += 15;
if (current >= threshold) probability += 25;
probability = Math.max(5, Math.min(92, probability));
let timeframe = metric === 'daily' ? '今日' : '本月末';
if (slope > 0.01 && threshold > current) {
const days = (threshold - current) / slope;
if (days < 1) timeframe = metric === 'daily' ? '今日内' : '1 天内';
else if (days <= 7) timeframe = `${Math.ceil(days)} 天内`;
else timeframe = `约 ${Math.ceil(days)} 天后`;
}
if (probability >= 30 || current >= threshold * 0.7) {
budgetRisks.push({
ruleName: String(rule.name || '预算规则'),
provider: providerLabel,
metric,
threshold,
current: Math.round(current * 100) / 100,
predicted: Math.round(predicted * 100) / 100,
probability,
timeframe,
suggestedAction: budgetRiskAction(metric, providerLabel),
});
}
}
budgetRisks.sort((a, b) => b.probability - a.probability);
const budgetRiskCount = budgetRisks.filter((r) => r.probability >= 50).length;
return {
summary,
dimensionForecasts,
budgetRisks,
budgetRiskCount,
recentDailyAvg: overall.recentDailyAvg,
billingPattern: overall.billingPattern,
topServices: topServicesByCost(billingRecords),
};
}
/**
* 构建成本预测「AI 深度解读」prompt。
* // Logan.Li
*/
export function buildCostInsightPrompt(data: ComputedCostPredictions): string {
const { summary, dimensionForecasts, budgetRisks, recentDailyAvg, billingPattern, topServices } = data;
if (summary.monthCost <= 0 && dimensionForecasts.length === 0) return '';
const patternHint =
billingPattern === 'front_loaded'
? '账单呈月初集中、近期趋零,可能含包年包月预付费或资源已停用,预测已按近7日运行费率外推'
: billingPattern === 'sparse'
? '日账单样本不足,预测置信度较低'
: '日账单相对平稳,按趋势外推';
const dimLines = dimensionForecasts.slice(0, 6).map(
(d) =>
`- ${d.dimension}:本月 ${d.currentMonth.toFixed(2)} 元 → 预测月末 ${d.predictedMonthEnd.toFixed(2)} 元(趋势 ${d.trend})`,
);
const riskLines =
budgetRisks.length > 0
? budgetRisks.slice(0, 5).map(
(r) =>
`- ${r.ruleName}(${r.provider}):当前 ${r.current} / 阈值 ${r.threshold},预测 ${r.predicted},触线概率 ${r.probability}%`,
)
: ['- 暂无预算触线风险'];
const serviceLines =
topServices.length > 0
? topServices.map((s) => `- ${s.service}:${s.cost.toFixed(2)} 元`)
: ['- 暂无服务维度明细'];
return [
'你是 FinOps 云成本优化专家。基于以下云账单预测数据,给出综合成本风险评估与可执行的降本建议:',
'',
'【总览】',
`- 本月已花:${summary.monthCost.toFixed(2)} 元`,
`- 预测月末:${summary.predictedMonthEnd.toFixed(2)} 元(置信 ${summary.confidence}%)`,
`- 全月日均:${summary.dailyAvg.toFixed(2)} 元;近7日日均:${recentDailyAvg.toFixed(2)} 元;剩余 ${summary.daysRemaining} 天`,
`- 账单形态:${patternHint}`,
'',
'【Top 服务消费】',
...serviceLines,
'',
'【分维度预测】',
...dimLines,
'',
'【预算风险】',
...riskLines,
'',
'输出要求(Markdown,中文):',
'1. 用 **加粗小标题** 分段,每段下 1 条要点,例如:**数据疑点**、**闲置资源清理**、**计费模式优化**、**存储生命周期**',
'2. 若全月日均与近7日日均、预测月末存在明显矛盾,必须在「数据疑点」中说明原因(预付费/资源停机等)',
'3. 结合 Top 服务给出可落地建议(EIP/云盘/ECS规格/OSS生命周期/预留实例等)',
'4. 选 3~5 条最相关建议,总字数 350 字以内,不要空泛套话',
].join('\n');
}
@@ -0,0 +1,276 @@
// 云账号详情侧栏 — 概览 / 区域 / 账单 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
CheckCircle2,
CloudDownload,
Loader2,
MapPin,
RefreshCw,
Wallet,
XCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { cloudApi, billingApi } from '@/api';
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
import type { JsonData } from '@/types/common';
interface CloudAccountDetailSheetProps {
account: JsonData | null;
open: boolean;
onOpenChange: (open: boolean) => void;
providerLabels: Record<string, string>;
onSyncRegions?: () => void;
onSyncBilling?: () => void;
onImportHosts?: () => void;
}
export function CloudAccountDetailSheet({
account,
open,
onOpenChange,
providerLabels,
onSyncRegions,
onSyncBilling,
onImportHosts,
}: CloudAccountDetailSheetProps) {
const { t } = useTranslation();
const [tab, setTab] = useState('overview');
const [regions, setRegions] = useState<JsonData[]>([]);
const [regionsLoading, setRegionsLoading] = useState(false);
const [syncingRegions, setSyncingRegions] = useState(false);
const [billings, setBillings] = useState<JsonData[]>([]);
const [billingsLoading, setBillingsLoading] = useState(false);
const [syncingBilling, setSyncingBilling] = useState(false);
const accountId = account?.id as number | string | undefined;
const loadRegions = useCallback(async () => {
if (!accountId) return;
setRegionsLoading(true);
try {
const res = await cloudApi.getAccountRegions(accountId);
setRegions(res?.list || []);
} catch {
setRegions([]);
} finally {
setRegionsLoading(false);
}
}, [accountId]);
const loadBillings = useCallback(async () => {
if (!accountId) return;
setBillingsLoading(true);
try {
const monthStart = new Date().toISOString().slice(0, 7) + '-01';
const res = await billingApi.list({
cloud_account_id: Number(accountId),
start_date: monthStart,
page_size: 20,
});
setBillings(res?.list || []);
} catch {
setBillings([]);
} finally {
setBillingsLoading(false);
}
}, [accountId]);
useEffect(() => {
if (!open || !accountId) return;
setTab('overview');
loadRegions();
loadBillings();
}, [open, accountId, loadRegions, loadBillings]);
const handleSyncRegions = async () => {
if (!accountId) return;
setSyncingRegions(true);
try {
await cloudApi.syncRegions(accountId);
await loadRegions();
onSyncRegions?.();
toast.success(t('cloud.syncRegionsSuccess'));
} catch (e) {
toast.error(t('cloud.syncRegionsFailed'), { description: (e as JsonData).message });
} finally {
setSyncingRegions(false);
}
};
const handleSyncBilling = async () => {
if (!accountId) return;
setSyncingBilling(true);
try {
const res = await cloudApi.syncBilling(accountId);
await loadBillings();
onSyncBilling?.();
const errs = res?.errors || [];
if (errs.length > 0) {
toast.warning(t('cloud.syncBillingPartial'), { description: errs.join('\n') });
} else {
toast.success(t('cloud.syncBillingSuccess'), {
description: t('cloud.syncBillingResult', {
date: res.billing_date,
created: res.created,
updated: res.updated,
}),
});
}
} catch (e) {
toast.error(t('cloud.syncBillingFailed'), { description: (e as JsonData).message });
} finally {
setSyncingBilling(false);
}
};
if (!account) return null;
const provider = String(account.provider || '');
const serverCount = account.server_count ?? account.serverCount ?? 0;
const regionCount = account.region_count ?? account.regionCount ?? regions.length;
const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0;
const lastSync = account.last_sync_at || account.lastSyncAt || account.last_sync;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-xl overflow-y-auto">
<SheetHeader className="border-b border-[var(--dg-border)] pb-4">
<div className="flex items-start gap-3 pr-8">
<div
className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center shrink-0"
style={{ borderBottom: `2px solid ${getProviderColor(provider)}` }}
>
<CloudBrandIcon provider={provider} className="h-6 w-6" />
</div>
<div className="min-w-0">
<SheetTitle className="truncate">{account.name}</SheetTitle>
<SheetDescription>
{providerLabels[provider] || provider}
{account.status === 'active' ? (
<Badge className="dg-status-running border-0 ml-2">
<CheckCircle2 className="h-3 w-3 mr-1" />
{t('cloud.normal')}
</Badge>
) : account.status === 'inactive' ? (
<Badge className="dg-status-stopped border-0 ml-2">
<XCircle className="h-3 w-3 mr-1" />
{t('cloud.stopped')}
</Badge>
) : null}
</SheetDescription>
</div>
</div>
</SheetHeader>
<Tabs value={tab} onValueChange={setTab} className="px-1 pt-2">
<TabsList className="w-full grid grid-cols-3">
<TabsTrigger value="overview">{t('cloud.tabOverview')}</TabsTrigger>
<TabsTrigger value="regions">{t('cloud.tabRegions')}</TabsTrigger>
<TabsTrigger value="billing">{t('cloud.tabBilling')}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4 mt-4">
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg p-3 bg-[var(--dg-bg-tertiary)]">
<p className="text-xs dg-text-secondary">{t('cloud.monthlyFee')}</p>
<p className="text-lg font-medium text-[var(--dg-green)]">¥{Number(monthlyCost).toLocaleString()}</p>
</div>
<div className="rounded-lg p-3 bg-[var(--dg-bg-tertiary)]">
<p className="text-xs dg-text-secondary">{t('cloud.serverCount')}</p>
<p className="text-lg font-medium">{serverCount}</p>
</div>
<div className="rounded-lg p-3 bg-[var(--dg-bg-tertiary)]">
<p className="text-xs dg-text-secondary">{t('cloud.regionCount')}</p>
<p className="text-lg font-medium">{regionCount}</p>
</div>
</div>
{lastSync && (
<p className="text-xs dg-text-muted">
{t('cloud.syncTime')}: {new Date(lastSync).toLocaleString()}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" className="dg-btn-secondary" disabled={syncingRegions} onClick={handleSyncRegions}>
{syncingRegions ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <MapPin className="h-4 w-4 mr-1" />}
{t('cloud.syncRegions')}
</Button>
<Button size="sm" variant="outline" className="dg-btn-secondary" disabled={syncingBilling} onClick={handleSyncBilling}>
{syncingBilling ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Wallet className="h-4 w-4 mr-1" />}
{t('cloud.syncBilling')}
</Button>
{onImportHosts && (
<Button size="sm" className="dg-btn-primary" onClick={onImportHosts}>
<CloudDownload className="h-4 w-4 mr-1" />
{t('cloud.importHosts')}
</Button>
)}
</div>
</TabsContent>
<TabsContent value="regions" className="mt-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm dg-text-secondary">{t('cloud.regionListHint')}</span>
<Button size="sm" variant="outline" className="dg-btn-secondary h-8" disabled={syncingRegions} onClick={handleSyncRegions}>
{syncingRegions ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</Button>
</div>
{regionsLoading ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin dg-text-muted" /></div>
) : regions.length === 0 ? (
<p className="text-sm dg-text-muted text-center py-8">{t('cloud.noRegions')}</p>
) : (
<div className="space-y-2 max-h-[50vh] overflow-y-auto">
{regions.map((r) => (
<div key={r.id || r.code} className="flex items-center justify-between px-3 py-2 rounded-md bg-[var(--dg-bg-tertiary)] text-sm">
<span className="font-medium">{r.name || r.code}</span>
<span className="dg-text-muted text-xs">{r.code}</span>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="billing" className="mt-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm dg-text-secondary">{t('cloud.billingMonthHint')}</span>
<Button size="sm" variant="outline" className="dg-btn-secondary h-8" disabled={syncingBilling} onClick={handleSyncBilling}>
{syncingBilling ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</Button>
</div>
{billingsLoading ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin dg-text-muted" /></div>
) : billings.length === 0 ? (
<p className="text-sm dg-text-muted text-center py-8">{t('cloud.noBillingRecords')}</p>
) : (
<div className="space-y-2 max-h-[50vh] overflow-y-auto">
{billings.map((b) => (
<div key={b.id} className="flex items-center justify-between px-3 py-2 rounded-md bg-[var(--dg-bg-tertiary)] text-sm">
<div>
<p className="font-medium">{b.service || '-'}</p>
<p className="text-xs dg-text-muted">{b.billing_date} · {b.region || '-'}</p>
</div>
<span className="text-[var(--dg-green)] font-medium">
¥{Number(b.cost || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
);
}
@@ -24,9 +24,15 @@ import {
XCircle,
RefreshCw,
Loader2,
MapPin,
Wallet,
CloudDownload,
} from 'lucide-react';
import { toast } from 'sonner';
import { cloudApi } from '@/api';
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
import { CloudAccountDetailSheet } from './CloudAccountDetailSheet';
import { CloudServerImport } from '@/components/server/CloudServerImport';
import { PageShell } from '@/components/layout/PageShell';
import { PageLoading } from '@/components/layout/PageLoading';
import { PageEmpty } from '@/components/layout/PageEmpty';
@@ -63,9 +69,71 @@ export function CloudAccountManager() {
monthly_cost: '',
status: 'active',
});
const [detailAccount, setDetailAccount] = useState<JsonData>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importAccountId, setImportAccountId] = useState<number | string | undefined>();
const [actionLoading, setActionLoading] = useState<Record<string, string>>({});
const providerLabels = getProviderLabels(t);
const setCardAction = (accountId: string | number, action: string | null) => {
setActionLoading((prev) => {
const next = { ...prev };
const key = String(accountId);
if (action) next[key] = action;
else delete next[key];
return next;
});
};
const openDetail = (account: JsonData) => {
setDetailAccount(account);
setDetailOpen(true);
};
const openImport = (account: JsonData) => {
setImportAccountId(account.id);
setImportOpen(true);
};
const handleQuickSyncRegions = async (account: JsonData) => {
setCardAction(account.id, 'regions');
try {
await cloudApi.syncRegions(account.id);
toast.success(t('cloud.syncRegionsSuccess'));
loadData();
} catch (e) {
toast.error(t('cloud.syncRegionsFailed'), { description: (e as JsonData).message });
} finally {
setCardAction(account.id, null);
}
};
const handleQuickSyncBilling = async (account: JsonData) => {
setCardAction(account.id, 'billing');
try {
const res = await cloudApi.syncBilling(account.id);
const errs = res?.errors || [];
if (errs.length > 0) {
toast.warning(t('cloud.syncBillingPartial'), { description: errs.join('\n') });
} else {
toast.success(t('cloud.syncBillingSuccess'), {
description: t('cloud.syncBillingResult', {
date: res.billing_date,
created: res.created,
updated: res.updated,
}),
});
}
loadData();
} catch (e) {
toast.error(t('cloud.syncBillingFailed'), { description: (e as JsonData).message });
} finally {
setCardAction(account.id, null);
}
};
const resetForm = () => {
setEditingAccount(null);
setFormData({
@@ -178,7 +246,7 @@ export function CloudAccountManager() {
onChange={(e) => setSelectedProvider(e.target.value)}
className="px-3 py-2 h-9 rounded-md text-sm dg-input"
>
<option value="all">{t('cloud.allPlatforms')}</option>
<option value="all">{t('cloud.allProviders')}</option>
<option value="aws">AWS</option>
<option value="aliyun">{t('cloud.aliyun')}</option>
<option value="tencent">{t('cloud.tencent')}</option>
@@ -244,12 +312,18 @@ export function CloudAccountManager() {
<Card key={account.id} className="dg-card dg-card-hover">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center" style={{ borderBottom: `2px solid ${getProviderColor(account.provider)}` }}>
<div className="flex items-start gap-3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center shrink-0" style={{ borderBottom: `2px solid ${getProviderColor(account.provider)}` }}>
<CloudBrandIcon provider={account.provider} className="h-6 w-6" />
</div>
<div>
<h3 className="font-medium text-[var(--dg-text-primary)]">{account.name}</h3>
<div className="min-w-0">
<button
type="button"
className="font-medium text-[var(--dg-text-primary)] hover:text-[var(--dg-blue)] text-left truncate block max-w-full"
onClick={() => openDetail(account)}
>
{account.name}
</button>
<p className="text-sm dg-text-secondary mt-0.5">{providerLabels[account.provider] || account.provider}</p>
<div className="flex items-center gap-3 mt-2">
{accessKey && (
@@ -314,6 +388,40 @@ export function CloudAccountManager() {
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-[var(--dg-border)]">
<Button
variant="outline"
size="sm"
className="h-7 text-xs dg-btn-secondary"
disabled={actionLoading[String(account.id)] === 'regions'}
onClick={() => handleQuickSyncRegions(account)}
>
{actionLoading[String(account.id)] === 'regions'
? <Loader2 className="h-3 w-3 mr-1 animate-spin" />
: <MapPin className="h-3 w-3 mr-1" />}
{t('cloud.syncRegions')}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs dg-btn-secondary"
disabled={actionLoading[String(account.id)] === 'billing'}
onClick={() => handleQuickSyncBilling(account)}
>
{actionLoading[String(account.id)] === 'billing'
? <Loader2 className="h-3 w-3 mr-1 animate-spin" />
: <Wallet className="h-3 w-3 mr-1" />}
{t('cloud.syncBilling')}
</Button>
<Button
size="sm"
className="h-7 text-xs dg-btn-primary"
onClick={() => openImport(account)}
>
<CloudDownload className="h-3 w-3 mr-1" />
{t('cloud.importHosts')}
</Button>
</div>
</CardContent>
</Card>
);
@@ -349,7 +457,7 @@ export function CloudAccountManager() {
</div>
<div className="space-y-1.5">
<Label>
{t('cloud.platformLabel')} <span className="text-[var(--dg-red)]">*</span>
{t('cloud.providerLabel')} <span className="text-[var(--dg-red)]">*</span>
</Label>
<select
value={formData.provider}
@@ -434,6 +542,31 @@ export function CloudAccountManager() {
</DialogFooter>
</DialogContent>
</Dialog>
<CloudAccountDetailSheet
account={detailAccount}
open={detailOpen}
onOpenChange={setDetailOpen}
providerLabels={providerLabels}
onSyncRegions={loadData}
onSyncBilling={loadData}
onImportHosts={() => {
if (detailAccount) {
setDetailOpen(false);
openImport(detailAccount);
}
}}
/>
<CloudServerImport
open={importOpen}
initialAccountId={importAccountId}
onClose={() => {
setImportOpen(false);
setImportAccountId(undefined);
loadData();
}}
/>
</PageShell>
);
}
@@ -20,6 +20,9 @@ import {
RefreshCw,
Wallet,
Bell,
CloudDownload,
Loader2,
Info,
} from 'lucide-react';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
@@ -62,6 +65,7 @@ export function CloudBilling() {
const [editingRecord, setEditingRecord] = useState<JsonData>(null);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<JsonData>(null);
const [syncing, setSyncing] = useState(false);
const loadStats = useCallback(async () => {
setStatsLoading(true);
@@ -121,6 +125,37 @@ export function CloudBilling() {
if (activeTab === 'alerts') loadRules();
};
// 从云账号 AK 同步昨日账单(当前支持阿里云) // Logan.Li
const handleSyncYesterday = async () => {
setSyncing(true);
try {
const res = await billingApi.sync();
const errs = res?.errors || [];
if (errs.length > 0) {
toast.warning(t('cloud.syncBillingPartial'), {
description: `${t('cloud.syncBillingResult', {
date: res.billing_date,
created: res.created,
updated: res.updated,
})}\n${errs.join('\n')}`,
});
} else {
toast.success(t('cloud.syncBillingSuccess'), {
description: t('cloud.syncBillingResult', {
date: res.billing_date,
created: res.created,
updated: res.updated,
}),
});
}
refreshAll();
} catch (e) {
toast.error(t('cloud.syncBillingFailed'), { description: (e as JsonData).message });
} finally {
setSyncing(false);
}
};
const openAddRecord = () => {
setEditingRecord(null);
setRecordDialogOpen(true);
@@ -234,6 +269,17 @@ export function CloudBilling() {
}
actions={(
<>
<Button
variant="outline"
size="sm"
className="dg-btn-secondary"
onClick={handleSyncYesterday}
disabled={syncing}
title={t('cloud.syncBillingHint')}
>
{syncing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <CloudDownload className="h-4 w-4 mr-1" />}
{t('cloud.syncYesterday')}
</Button>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={refreshAll}>
<RefreshCw className="h-4 w-4 mr-1" />{t('common.refresh')}
</Button>
@@ -256,6 +302,16 @@ export function CloudBilling() {
<PageLoading />
) : stats ? (
<div className="space-y-4">
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40 p-3">
<div className="flex items-start gap-2 text-sm text-[var(--dg-text-secondary)]">
<Info className="h-4 w-4 dg-text-blue shrink-0 mt-0.5" />
<div className="space-y-1">
<p>{t('cloud.syncBillingInfo')}</p>
<p className="text-xs dg-text-muted">{t('cloud.syncBillingTiming')}</p>
</div>
</div>
</div>
{stats.alerts?.length > 0 && (
<div className="dg-alert-banner">
<div className="flex items-center gap-2 mb-3">
@@ -0,0 +1,32 @@
import type { ReactNode } from "react"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { cn } from "@/lib/utils"
type EmptyStateProps = {
title: ReactNode
description?: ReactNode
icon?: ReactNode
action?: ReactNode
className?: string
}
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
return (
<Empty className={cn("border border-dashed border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]", className)}>
<EmptyHeader>
{icon ? <EmptyMedia variant="icon">{icon}</EmptyMedia> : null}
<EmptyTitle className="text-[var(--dg-text-primary)]">{title}</EmptyTitle>
{description ? <EmptyDescription>{description}</EmptyDescription> : null}
</EmptyHeader>
{action ? <EmptyContent>{action}</EmptyContent> : null}
</Empty>
)
}
@@ -0,0 +1,23 @@
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
type FilterBarProps = {
left?: ReactNode
right?: ReactNode
className?: string
}
export function FilterBar({ left, right, className }: FilterBarProps) {
return (
<div
className={cn(
"flex flex-col gap-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-3 md:flex-row md:items-center md:justify-between",
className,
)}
>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">{left}</div>
{right ? <div className="flex shrink-0 flex-wrap items-center gap-2">{right}</div> : null}
</div>
)
}
@@ -0,0 +1,41 @@
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
type MetricCardProps = {
title: ReactNode
value: ReactNode
description?: ReactNode
icon?: ReactNode
trend?: ReactNode
className?: string
}
export function MetricCard({ title, value, description, icon, trend, className }: MetricCardProps) {
return (
<section
className={cn(
"min-w-0 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4 shadow-[var(--shadow-card)]",
className,
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="truncate text-sm text-[var(--dg-text-secondary)]">{title}</div>
<div className="truncate text-2xl font-semibold tracking-normal text-[var(--dg-text-primary)]">{value}</div>
</div>
{icon ? (
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]">
{icon}
</div>
) : null}
</div>
{(description || trend) ? (
<div className="mt-3 flex min-w-0 flex-wrap items-center gap-2 text-xs text-[var(--dg-text-muted)]">
{trend ? <span className="shrink-0">{trend}</span> : null}
{description ? <span className="truncate">{description}</span> : null}
</div>
) : null}
</section>
)
}
@@ -0,0 +1,79 @@
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
type PageShellProps = {
children: ReactNode
className?: string
}
type PageHeaderProps = {
title: ReactNode
description?: ReactNode
actions?: ReactNode
className?: string
}
type PageSectionProps = {
children: ReactNode
title?: ReactNode
description?: ReactNode
actions?: ReactNode
className?: string
contentClassName?: string
}
export function PageShell({ children, className }: PageShellProps) {
return (
<main className={cn("flex min-h-0 w-full flex-col gap-4 p-4 md:p-6", className)}>
{children}
</main>
)
}
export function PageHeader({ title, description, actions, className }: PageHeaderProps) {
return (
<header className={cn("flex flex-col gap-3 md:flex-row md:items-start md:justify-between", className)}>
<div className="min-w-0 space-y-1">
<h1 className="truncate text-xl font-semibold tracking-normal text-[var(--dg-text-primary)] md:text-2xl">
{title}
</h1>
{description ? (
<p className="max-w-3xl text-sm leading-6 text-[var(--dg-text-secondary)]">{description}</p>
) : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</header>
)
}
export function PageSection({
children,
title,
description,
actions,
className,
contentClassName,
}: PageSectionProps) {
const hasHeader = title || description || actions
return (
<section
className={cn(
"rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] shadow-[var(--shadow-card)]",
className,
)}
>
{hasHeader ? (
<div className="flex flex-col gap-3 border-b border-[var(--dg-border)] px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 space-y-1">
{title ? <h2 className="truncate text-base font-medium text-[var(--dg-text-primary)]">{title}</h2> : null}
{description ? <p className="text-sm text-[var(--dg-text-secondary)]">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</div>
) : null}
<div className={cn("min-w-0 p-4", contentClassName)}>{children}</div>
</section>
)
}
@@ -0,0 +1,34 @@
import type { ComponentProps } from "react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
type StatusTone = "success" | "warning" | "danger" | "info" | "neutral" | "running" | "offline"
const toneClassName: Record<StatusTone, string> = {
success: "border-[color-mix(in_srgb,var(--dg-green)_35%,transparent)] bg-[color-mix(in_srgb,var(--dg-green)_14%,transparent)] text-[var(--dg-green)]",
warning: "border-[color-mix(in_srgb,var(--dg-yellow)_38%,transparent)] bg-[color-mix(in_srgb,var(--dg-yellow)_15%,transparent)] text-[var(--dg-yellow)]",
danger: "border-[color-mix(in_srgb,var(--dg-red)_38%,transparent)] bg-[color-mix(in_srgb,var(--dg-red)_14%,transparent)] text-[var(--dg-red)]",
info: "border-[color-mix(in_srgb,var(--dg-blue)_35%,transparent)] bg-[color-mix(in_srgb,var(--dg-blue)_14%,transparent)] text-[var(--dg-blue)]",
neutral: "border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]",
running: "border-[color-mix(in_srgb,var(--dg-cyan)_38%,transparent)] bg-[color-mix(in_srgb,var(--dg-cyan)_14%,transparent)] text-[var(--dg-cyan)]",
offline: "border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]",
}
type StatusBadgeProps = ComponentProps<typeof Badge> & {
tone?: StatusTone
dot?: boolean
}
export function StatusBadge({ tone = "neutral", dot = false, className, children, ...props }: StatusBadgeProps) {
return (
<Badge
variant="outline"
className={cn("gap-1.5 rounded-md px-2 py-0.5 font-normal", toneClassName[tone], className)}
{...props}
>
{dot ? <span className="size-1.5 rounded-full bg-current" /> : null}
{children}
</Badge>
)
}
@@ -0,0 +1,21 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface TerminalLoadingOverlayProps {
show: boolean;
label?: string;
className?: string;
}
export function TerminalLoadingOverlay({ show, label = '连接中...', className }: TerminalLoadingOverlayProps) {
if (!show) return null;
return (
<div className={cn('absolute inset-0 z-10 flex items-center justify-center bg-black/35', className)}>
<div className="flex flex-col items-center gap-2 text-white/85">
<Loader2 className="h-7 w-7 animate-spin" />
{label && <span className="text-xs">{label}</span>}
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./EmptyState"
export * from "./FilterBar"
export * from "./MetricCard"
export * from "./PageShell"
export * from "./StatusBadge"
@@ -38,6 +38,7 @@ import { PageShell } from '@/components/layout/PageShell';
import { PageLoading } from '@/components/layout/PageLoading';
import { ContentPanel } from '@/components/layout/ContentPanel';
import { StatStrip } from '@/components/ui/stat-strip';
import type { JsonData } from '@/types/common';
export function AppReleaseManager() {
const { t } = useTranslation();
@@ -45,9 +46,9 @@ export function AppReleaseManager() {
const [searchQuery, setSearchQuery] = useState('');
const [envFilter, setEnvFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [releases, setReleases] = useState<any[]>([]);
const [releases, setReleases] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(true);
const [selectedRelease, setSelectedRelease] = useState<any | null>(null);
const [selectedRelease, setSelectedRelease] = useState<JsonData | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const loadData = async () => {
@@ -76,7 +77,7 @@ export function AppReleaseManager() {
});
// 回滚发布 // Logan.Li
const handleRollback = async ( id: any) => {
const handleRollback = async ( id: string | number) => {
try {
await releaseApi.rollbackRelease(id);
loadData();
@@ -41,6 +41,7 @@ import { PageShell } from '@/components/layout/PageShell';
import { PageLoading } from '@/components/layout/PageLoading';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { StatStrip } from '@/components/ui/stat-strip';
import type { JsonData } from '@/types/common';
export function ReleaseManager() {
const { t } = useTranslation();
@@ -48,9 +49,9 @@ export function ReleaseManager() {
const [searchQuery, setSearchQuery] = useState('');
const [envFilter, setEnvFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [releases, setReleases] = useState<any[]>([]);
const [releases, setReleases] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(true);
const [selectedRelease, setSelectedRelease] = useState<any | null>(null);
const [selectedRelease, setSelectedRelease] = useState<JsonData | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
const [isApproveDialogOpen, setIsApproveDialogOpen] = useState(false);
@@ -80,7 +81,7 @@ export function ReleaseManager() {
return matchesSearch && matchesEnv && matchesStatus;
});
const handleDeploy = async ( id: any, appName: string, version: string) => {
const handleDeploy = async ( id: string | number, appName: string, version: string) => {
const ok = await _confirm({
title: '确认部署',
description: `确定部署 ${appName} ${version} 到目标环境?`,
@@ -95,7 +96,7 @@ export function ReleaseManager() {
}
};
const handleRollback = async ( id: any, appName: string, version: string) => {
const handleRollback = async ( id: string | number, appName: string, version: string) => {
const ok = await _confirm({
title: '确认回滚',
description: `确定回滚 ${appName} ${version}?此操作将恢复到上一版本。`,
@@ -1,5 +1,4 @@
import { useRef, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
@@ -11,8 +10,10 @@ import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { Maximize2, Minimize2, X, FolderOpen } from 'lucide-react';
import { cn } from '@/lib/utils';
import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils';
import { useUserStore } from '@/store';
import { pipelineApi } from '@/api';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
import type { JsonData } from '@/types/common';
interface WorkspaceTermDialogProps {
@@ -31,7 +32,6 @@ export function WorkspaceTermDialog({
runId,
buildNumber,
}: WorkspaceTermDialogProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
@@ -39,10 +39,12 @@ export function WorkspaceTermDialog({
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [workspace, setWorkspace] = useState('');
const [hasOutput, setHasOutput] = useState(false);
// 获取 workspace 路径
useEffect(() => {
if (!open || !runId) return;
setHasOutput(false);
pipelineApi.getWorkspace(runId)
.then((data: JsonData) => setWorkspace(data?.workspace_path || ''))
.catch(() => {});
@@ -85,30 +87,29 @@ export function WorkspaceTermDialog({
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
setHasOutput(true);
term.write(msg.data);
}
} catch {
setHasOutput(true);
term.write(typeof event.data === 'string' ? event.data : '');
}
};
ws.onopen = () => {
setConnected(true);
if (containerRef.current) {
term.open(containerRef.current);
requestAnimationFrame(() => {
try { fit.fit(); term.focus(); } catch { /* noop */ }
});
}
mountXterm(term, fit, () => containerRef.current, ws);
};
ws.onclose = () => {
setConnected(false);
term.write(`\r\n\x1b[31m${t('deploy.runDetail.connectionClosed')}\x1b[0m\r\n`);
setHasOutput(true);
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
};
ws.onerror = () => {
term.write(`\r\n\x1b[31m${t('deploy.runDetail.connectionError')}\x1b[0m\r\n`);
setHasOutput(true);
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
return () => {
@@ -119,31 +120,24 @@ export function WorkspaceTermDialog({
};
}, [open, runId]);
// ResizeObserver for fit
// 容器尺寸变化时 fit 并同步远端 PTY,避免缩放后黑边或铺不满。 // Logan.Li
useEffect(() => {
if (!open) return;
const container = containerRef.current;
if (!container) return;
let timer: ReturnType<typeof setTimeout>;
const ro = new ResizeObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => {
if (fitRef.current && termRef.current) {
try {
fitRef.current.fit();
termRef.current.focus();
if (wsRef.current?.readyState === WebSocket.OPEN) {
const { cols, rows } = termRef.current;
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
}
} catch { /* noop */ }
}
}, 60);
});
ro.observe(container);
return () => { clearTimeout(timer); ro.disconnect(); };
}, [open]);
let cleanup: (() => void) | undefined;
let retry = 0;
const attach = () => {
const container = containerRef.current;
const fit = fitRef.current;
const term = termRef.current;
if (!container || !fit || !term) {
if (retry++ < 30) requestAnimationFrame(attach);
return;
}
cleanup = attachTerminalResize(container, fit, term, wsRef.current);
};
attach();
return () => cleanup?.();
}, [open, fullscreen]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -160,14 +154,14 @@ export function WorkspaceTermDialog({
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
>
<DialogTitle className="sr-only">{t('deploy.pipeline.workspaceTerminal', { buildNumber })}</DialogTitle>
<DialogDescription className="sr-only">{t('deploy.pipeline.buildDir')}</DialogDescription>
<DialogTitle className="sr-only">Workspace 终端 #{buildNumber}</DialogTitle>
<DialogDescription className="sr-only">流水线构建目录终端</DialogDescription>
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-sm min-w-0">
<div className={cn('w-2 h-2 rounded-full shrink-0', connected ? 'bg-emerald-400' : 'bg-red-400')} />
<FolderOpen className="h-4 w-4 text-[var(--dg-text-secondary)] shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium truncate">
#{buildNumber} {t('deploy.runDetail.buildDir')}
#{buildNumber} 构建目录
</span>
{workspace && (
<span className="text-[var(--dg-text-muted)] text-xs truncate hidden sm:inline">
@@ -190,11 +184,10 @@ export function WorkspaceTermDialog({
</button>
</div>
</div>
<div
ref={containerRef}
className="bg-[#0a0e14]"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
/>
<div className="relative bg-[#0a0e14]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}>
<div ref={containerRef} className="xterm-terminal-host h-full" />
<TerminalLoadingOverlay show={!hasOutput} label="正在进入工作区..." />
</div>
</DialogContent>
</Dialog>
);
@@ -37,6 +37,11 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
const [sshPassword, setSshPassword] = useState('');
const [sshPrivateKey, setSshPrivateKey] = useState('');
const [sshPublicKey, setSshPublicKey] = useState('');
// RDP 字段 // Logan.Li
const [rdpUsername, setRdpUsername] = useState('Administrator');
const [rdpPassword, setRdpPassword] = useState('');
const [rdpDomain, setRdpDomain] = useState('');
const [rdpSecurity, setRdpSecurity] = useState('nla');
// Docker 字段
const [dockerServer, setDockerServer] = useState('');
const [dockerUsername, setDockerUsername] = useState('');
@@ -75,6 +80,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
setIsActive(editData.is_active);
// 编辑时不预填类型字段,仅修改用户填写的项 // Logan.Li
setSshUser(''); setSshPort('22'); setSshPassword(''); setSshPrivateKey(''); setSshPublicKey('');
setRdpUsername(''); setRdpPassword(''); setRdpDomain(''); setRdpSecurity('nla');
setDockerServer(''); setDockerUsername(''); setDockerPassword('');
setTlsCert(''); setTlsKey(''); setTlsCa('');
setAkskProvider(''); setAkskAccessKey(''); setAkskSecretKey(''); setAkskRegion('');
@@ -84,6 +90,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
} else {
setName(''); setVarName(''); setSecretType('ssh'); setDescription(''); setTags(''); setIsActive(true);
setSshUser('root'); setSshPort('22'); setSshPassword(''); setSshPrivateKey(''); setSshPublicKey('');
setRdpUsername('Administrator'); setRdpPassword(''); setRdpDomain(''); setRdpSecurity('nla');
setDockerServer(''); setDockerUsername(''); setDockerPassword('');
setTlsCert(''); setTlsKey(''); setTlsCa('');
setAkskProvider(''); setAkskAccessKey(''); setAkskSecretKey(''); setAkskRegion('');
@@ -115,6 +122,13 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
if (sshPassword.trim()) payload.ssh_password = sshPassword.trim();
if (sshPrivateKey.trim()) payload.ssh_private_key = sshPrivateKey.trim();
if (sshPublicKey.trim()) payload.ssh_public_key = sshPublicKey.trim();
} else if (secretType === 'rdp') {
if (!rdpUsername.trim()) { toast.error('请填写 RDP 用户'); return; }
if (!isEdit && !rdpPassword.trim()) { toast.error('请填写 RDP 密码'); return; }
if (rdpUsername.trim()) payload.rdp_username = rdpUsername.trim();
if (rdpPassword.trim()) payload.rdp_password = rdpPassword.trim();
if (rdpDomain.trim()) payload.rdp_domain = rdpDomain.trim();
payload.rdp_security = rdpSecurity.trim() || 'nla';
} else if (secretType === 'docker') {
if (dockerServer.trim()) payload.docker_server = dockerServer.trim();
if (dockerUsername.trim()) payload.docker_username = dockerUsername.trim();
@@ -275,6 +289,36 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
</div>
)}
{secretType === 'rdp' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Windows 用户 <span className="dg-text-red">*</span></Label>
<Input value={rdpUsername} onChange={e => setRdpUsername(e.target.value)} placeholder="Administrator"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">安全模式</Label>
<Select value={rdpSecurity} onValueChange={setRdpSecurity}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nla">NLA</SelectItem>
<SelectItem value="tls">TLS</SelectItem>
<SelectItem value="rdp">标准 RDP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">域</Label>
<Input value={rdpDomain} onChange={e => setRdpDomain(e.target.value)} placeholder="可选"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div><FieldWithToggle label="Windows 密码" value={rdpPassword} onChange={setRdpPassword}
placeholder="Windows 登录密码" fieldKey="rdp_pwd" required /></div>
</div>
)}
{secretType === 'docker' && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
@@ -394,4 +438,4 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
</DialogContent>
</Dialog>
);
}
}
@@ -1,27 +1,28 @@
// 凭据类型配置常量 // Logan.Li
import {
Key, Server, Container, Shield, Cloud, Database, FileText, Globe,
Key, Server, Container, Shield, Cloud, Database, FileText, Globe, Monitor,
} from 'lucide-react';
import type { SecretType } from './types';
import i18n from '@/i18n';
export const TYPE_CONFIG: Record<SecretType, { label: string; icon: typeof Key; color: string; desc: string }> = {
ssh: { label: i18n.t('system.sshKeyLabel'), icon: Server, color: 'dg-text-yellow', desc: i18n.t('pipeline.constantsts.sedc7f9e9') },
docker: { label: i18n.t('system.dockerCredential'), icon: Container, color: 'dg-text-cyan', desc: i18n.t('pipeline.constantsts.sbd6cf171') },
tls: { label: i18n.t('pipeline.constantsts.sfa5a64de'), icon: Shield, color: 'dg-text-green', desc: i18n.t('pipeline.constantsts.s43f00c61') },
aksk: { label: i18n.t('pipeline.constantsts.sb82698a1'), icon: Cloud, color: 'dg-text-purple', desc: i18n.t('pipeline.constantsts.s564e98a9') },
database: { label: i18n.t('system.dbCredential'), icon: Database, color: 'dg-text-blue', desc: i18n.t('pipeline.constantsts.se47498d3') },
api: { label: i18n.t('system.apiKeys'), icon: Globe, color: 'dg-text-orange', desc: i18n.t('pipeline.constantsts.sb3edf0a4') },
generic: { label: i18n.t('pipeline.constantsts.s1d319bb7'), icon: FileText, color: 'dg-text-secondary', desc: i18n.t('pipeline.constantsts.s51b5b280') },
ssh: { label: 'SSH 密钥', icon: Server, color: 'dg-text-yellow', desc: 'SSH 私钥/公钥/密码' },
rdp: { label: 'Windows RDP 密码', icon: Monitor, color: 'dg-text-blue', desc: 'Windows 远程桌面用户名/密码' },
docker: { label: 'Docker 凭证', icon: Container, color: 'dg-text-cyan', desc: 'Docker Registry 登录凭据' },
tls: { label: 'TLS 证书', icon: Shield, color: 'dg-text-green', desc: 'TLS/SSL 证书与私钥' },
aksk: { label: 'AKSK 密钥', icon: Cloud, color: 'dg-text-purple', desc: '云服务 AccessKey/SecretKey' },
database: { label: '数据库凭证', icon: Database, color: 'dg-text-blue', desc: '数据库连接信息' },
api: { label: 'API 密钥', icon: Globe, color: 'dg-text-orange', desc: 'API Token/Key + 端点' },
generic: { label: '通用凭证', icon: FileText, color: 'dg-text-secondary', desc: '自定义 Key/Value 键值对' },
};
export const TYPE_BADGE_CLASS: Record<SecretType, string> = {
ssh: 'bg-amber-500/20 dg-text-yellow border-amber-500/30',
rdp: 'bg-blue-500/20 dg-text-blue border-blue-500/30',
docker: 'bg-cyan-500/20 dg-text-cyan border-cyan-500/30',
tls: 'bg-green-500/20 dg-text-green border-green-500/30',
aksk: 'bg-purple-500/20 dg-text-purple border-purple-500/30',
database: 'bg-blue-500/20 dg-text-blue border-blue-500/30',
api: 'bg-orange-500/20 dg-text-orange border-orange-500/30',
generic: 'dg-badge-muted',
};
};
@@ -15,4 +15,4 @@ export interface Credential {
updated_at: string;
}
export type SecretType = 'ssh' | 'docker' | 'tls' | 'aksk' | 'database' | 'api' | 'generic';
export type SecretType = 'ssh' | 'rdp' | 'docker' | 'tls' | 'aksk' | 'database' | 'api' | 'generic';
@@ -56,7 +56,7 @@ export function JobRoleCreateDialog({
const patternHint = useMemo(() => {
if (!pattern.trim()) return t('rbac.jobRole.patternHint.empty');
try {
// eslint-disable-next-line no-new
new RegExp(pattern);
return preview.length > 0 ? t('rbac.jobRole.patternHint.matched', { count: preview.length }) : t('rbac.jobRole.patternHint.noMatch');
} catch {
@@ -3,6 +3,7 @@
import type { TFunction } from 'i18next';
import { Badge } from '@/components/ui/badge';
import {
type LucideIcon,
Clock,
Check,
XCircle,
@@ -28,7 +29,7 @@ const ENV_LABEL_KEY: Record<string, string> = {
// 发布状态徽章(审批流:待审批/已审批/已驳回/发布中/发布成功/发布失败/已回滚) // Logan.Li
export function getStatusBadge(status: string, t: TFunction) {
const map: Record<string, { icon: any; labelKey: string; cls: string }> = {
const map: Record<string, { icon: LucideIcon; labelKey: string; cls: string }> = {
pending: { icon: Clock, labelKey: 'deploy.status.pendingApproval', cls: 'border-amber-500/30 dg-text-yellow bg-amber-500/10' },
approved: { icon: Check, labelKey: 'deploy.status.approved', cls: 'border-purple-500/30 dg-text-purple bg-purple-500/10' },
rejected: { icon: XCircle, labelKey: 'deploy.status.rejected', cls: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10' },
@@ -10,14 +10,14 @@ import { Badge } from '@/components/ui/badge';
import { Plus, Trash2, Save, Loader2, Tags, FileText, Hash, Lock } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { saveDeploymentManifest } from './deployment-manifest-save';
import i18n from '@/i18n';
import type { JsonData } from '@/types/common';
interface DeploymentEditBasicProps {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: (updated?: any) => void;
manifest: JsonData | null;
onSaved: (updated?: JsonData) => void;
onCancel: () => void;
isCreate?: boolean;
}
@@ -42,7 +42,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest);
const [rawManifest, setRawManifest] = useState<any>(manifest || {});
const [rawManifest, setRawManifest] = useState<JsonData>(manifest || {});
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [replicas, setReplicas] = useState(1);
const [labels, setLabels] = useState<Array<{ key: string; value: string }>>([]);
@@ -94,7 +94,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
const merged = { ...kvToObj(prev), ...sel };
return Object.entries(merged).map(([key, value]) => ({ key, value }));
});
toast({ title: i18n.t('kubernetes.deploymenteditb.s8bf97a5b'), description: i18n.t('kubernetes.deploymenteditb.s841d0da6') });
toast({ title: '已同步', description: 'Selector 标签已合并到 Pod 模板标签' });
};
const save = async () => {
@@ -107,7 +107,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
base.metadata = base.metadata || {};
if (isCreate) {
const trimmed = resName.trim();
if (!trimmed) throw new Error(i18n.t('k8s.hpa.nameRequired'));
if (!trimmed) throw new Error('名称不能为空');
base.metadata.name = trimmed;
base.metadata.namespace = namespace;
}
@@ -128,10 +128,10 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
delete base.spec.template.metadata.labels;
}
const savedName = await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: isCreate ? i18n.t('monitor.dashboard.custom.toastCreateSuccess') : i18n.t('kubernetes.deploymenteditb.sa59c4d53'), description: isCreate ? `${savedName} 已创建` : `${savedName} 更新已提交` });
toast({ title: isCreate ? '创建成功' : '基本信息已保存', description: isCreate ? `${savedName} 已创建` : `${savedName} 更新已提交` });
onSaved(base);
} catch (e) {
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); }
};
@@ -140,7 +140,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
<Card className="dg-card">
<CardContent className="p-12 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto dg-text-muted" />
<p className="dg-text-muted mt-3 text-sm"> {i18n.t('monitor.logPanel.logResults.loading')} </p>
<p className="dg-text-muted mt-3 text-sm">加载中...</p>
</CardContent>
</Card>
);
@@ -175,7 +175,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
onChange={e => setReplicas(Math.max(0, Number(e.target.value) || 0))}
className="w-32 dg-input"
/>
<span className="text-xs dg-text-muted"> {i18n.t('kubernetes.deploymenteditb.s6e47fc82')} </span>
<span className="text-xs dg-text-muted">期望运行的 Pod 实例数量</span>
</div>
</CardContent>
</Card>
@@ -185,12 +185,12 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Lock className="h-4 w-4 dg-text-muted" />
{isCreate ? i18n.t('kubernetes.deploymenteditb.sc8de32ed') : i18n.t('kubernetes.deploymenteditb.sc4d9eb56')}
{isCreate ? '基本标识' : '基本标识(只读)'}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 grid grid-cols-2 gap-4">
<div>
<Label className="dg-text-secondary text-xs"> {i18n.t('kubernetes.deploymenteditb.sab724785')} </Label>
<Label className="dg-text-secondary text-xs">名称 (name)</Label>
<Input
value={isCreate ? resName : deploymentName}
readOnly={!isCreate}
@@ -199,7 +199,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
/>
</div>
<div>
<Label className="dg-text-secondary text-xs"> {i18n.t('kubernetes.deploymenteditb.s00e648c6')} </Label>
<Label className="dg-text-secondary text-xs">命名空间 (namespace)</Label>
<Input value={namespace} readOnly
className="mt-1 bg-[var(--dg-bg-primary)] border-[var(--dg-border)] dg-text-muted font-mono text-sm cursor-not-allowed" />
</div>
@@ -221,7 +221,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
</div>
</CardHeader>
<CardContent className="pt-0 space-y-1.5">
{labels.length === 0 && <p className="text-[11px] dg-text-muted"> {i18n.t('monitor.logPanel.logResults.noLabels')} </p>}
{labels.length === 0 && <p className="text-[11px] dg-text-muted">无标签</p>}
{labels.map((l, i) => (
<div key={i} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={l.key} placeholder="app"
@@ -253,7 +253,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
</CardHeader>
<CardContent className="pt-0 space-y-1.5">
<p className="text-[11px] text-amber-500/70 mb-1">Selector 决定哪些 Pod 归属此 Deployment。修改时必须确保与 Pod Template Labels 匹配,否则 Deployment 将无法管理 Pod。</p>
{selectorLabels.length === 0 && <p className="text-[11px] dg-text-muted"> {i18n.t('kubernetes.service.noSelector')} </p>}
{selectorLabels.length === 0 && <p className="text-[11px] dg-text-muted">无选择器</p>}
{selectorLabels.map((s, i) => (
<div key={i} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={s.key} placeholder="app"
@@ -289,7 +289,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
</CardHeader>
<CardContent className="pt-0 space-y-1.5">
<p className="text-[11px] dg-text-muted mb-1">Pod 模板标签必须包含 Selector 全部键值,否则 Deployment 无法管理 Pod</p>
{templateLabels.length === 0 && <p className="text-[11px] dg-text-muted"> {i18n.t('kubernetes.deploymenteditb.se9c9f60d')} </p>}
{templateLabels.length === 0 && <p className="text-[11px] dg-text-muted">无模板标签</p>}
{templateLabels.map((l, i) => (
<div key={i} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={l.key} placeholder="app"
@@ -320,7 +320,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
</div>
</CardHeader>
<CardContent className="pt-0 space-y-1.5">
{annotations.length === 0 && <p className="text-[11px] dg-text-muted"> {i18n.t('kubernetes.deploymenteditb.sbe1e7c07')} </p>}
{annotations.length === 0 && <p className="text-[11px] dg-text-muted">无注解</p>}
{annotations.map((a, i) => (
<div key={i} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={a.key} placeholder="deployment.kubernetes.io/revision"
@@ -342,7 +342,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
保存将通过 <code className="dg-text-secondary">PUT application/yaml</code> 提交(自动剥离 status/managedFields 等只读字段)
</p>
<div className="flex gap-2">
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={onCancel}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={onCancel}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={saving} onClick={save}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
保存
@@ -10,8 +10,9 @@ import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { Terminal, Maximize2, Minimize2, X, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils';
import { useUserStore } from '@/store';
import i18n from '@/i18n';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
interface KubectlTerminalProps {
open: boolean;
@@ -28,6 +29,7 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [error, setError] = useState('');
const [hasOutput, setHasOutput] = useState(false);
// 重置全屏状态
useEffect(() => {
@@ -37,6 +39,7 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
// 连接终端
useEffect(() => {
if (!open || !clusterId) return;
setHasOutput(false);
const term = new XTerm({
cursorBlink: true,
@@ -79,40 +82,33 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
setHasOutput(true);
term.write(msg.data);
}
} catch {
if (typeof event.data === 'string') term.write(event.data);
if (typeof event.data === 'string') {
setHasOutput(true);
term.write(event.data);
}
}
};
ws.onopen = () => {
setConnected(true);
setError('');
if (containerRef.current) {
term.open(containerRef.current);
requestAnimationFrame(() => {
try {
fit.fit();
} catch { /* noop */ }
});
// 延迟 focus:确保 Dialog 渲染完成后再聚焦终端
setTimeout(() => {
try {
term.focus();
} catch { /* noop */ }
}, 150);
}
mountXterm(term, fit, () => containerRef.current, ws);
};
ws.onclose = () => {
setConnected(false);
term.write(i18n.t('kubernetes.kubectlterminal.sf4ee00d1'));
setHasOutput(true);
term.write('\r\n\x1b[31m[连接已断开]\x1b[0m\r\n');
};
ws.onerror = () => {
setError(i18n.t('aiops.wsConnectFailed'));
term.write(i18n.t('kubernetes.podexecterminal.sc8107726'));
setError('WebSocket 连接失败');
setHasOutput(true);
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
return () => {
@@ -128,35 +124,24 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
};
}, [open, clusterId]);
// ResizeObserver 监听容器变化自动 fit
// ResizeObserver 监听容器变化自动 fit,并同步远端 PTY 尺寸。 // Logan.Li
useEffect(() => {
if (!open) return;
const container = containerRef.current;
if (!container) return;
let resizeTimer: ReturnType<typeof setTimeout>;
const ro = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (fitRef.current && termRef.current && wsRef.current) {
try {
fitRef.current.fit();
termRef.current.focus();
const { cols, rows } = termRef.current;
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
}
} catch { /* noop */ }
}
}, 60);
});
ro.observe(container);
return () => {
clearTimeout(resizeTimer);
ro.disconnect();
let cleanup: (() => void) | undefined;
let retry = 0;
const attach = () => {
const container = containerRef.current;
const fit = fitRef.current;
const term = termRef.current;
if (!container || !fit || !term) {
if (retry++ < 30) requestAnimationFrame(attach);
return;
}
cleanup = attachTerminalResize(container, fit, term, wsRef.current);
};
}, [open]);
attach();
return () => cleanup?.();
}, [open, fullscreen]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -202,11 +187,10 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
</div>
{/* 终端容器 */}
<div
ref={containerRef}
className="bg-[#0f172a]"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
/>
<div className="relative bg-[#0f172a]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}>
<div ref={containerRef} className="xterm-terminal-host h-full" />
<TerminalLoadingOverlay show={!hasOutput && !error} label="正在连接集群..." />
</div>
</DialogContent>
</Dialog>
);
@@ -67,7 +67,7 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
{ time: t('common.duration.min', { count: 2 }), cpu: Math.max(0, cpu * 0.96), memory: Math.max(0, mem * 0.97) },
{ time: t('k8s.cluster.now'), cpu, memory: mem },
];
}, [clusterMetrics, t]); // eslint-disable-line react-hooks/exhaustive-deps
}, [clusterMetrics, t]);
const loadClusterDetail = async (clusterCode: string) => {
try {
@@ -20,9 +20,10 @@ import {
Terminal, Maximize2, Minimize2, X, Loader2, RotateCw, Box,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
import i18n from '@/i18n';
export interface PodExecTerminalProps {
open: boolean;
@@ -62,6 +63,7 @@ export function PodExecTerminal({
const [fullscreen, setFullscreen] = useState(false);
const [container, setContainer] = useState<string>(initialContainer || containers[0] || '');
const [shell, setShell] = useState<string>('sh');
const [hasOutput, setHasOutput] = useState(false);
// ─── 重置 ───
useEffect(() => {
@@ -92,6 +94,7 @@ export function PodExecTerminal({
}
setConnState('connecting');
setHasOutput(false);
term.clear();
term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} shell=${shell} ...]\x1b[0m`);
@@ -115,32 +118,39 @@ export function PodExecTerminal({
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
setHasOutput(true);
term.write(msg.data);
} else if (msg.status === 2 || msg.error) {
const errMsg = msg.data || msg.message || msg.error || i18n.t('kubernetes.podexecterminal.s05f6e923');
setHasOutput(true);
const errMsg = msg.data || msg.message || msg.error || '执行错误';
if (errMsg.includes('not found') && shell === 'bash') {
term.writeln(i18n.t('kubernetes.podexecterminal.s14481be5'));
term.writeln('\r\n\x1b[33m⚠ bash 不可用,请切换 /bin/sh 后重连。\x1b[0m');
errorShownRef.current = true;
return;
}
term.write(errMsg.includes('\x1b') ? errMsg : `\r\n\x1b[31m${errMsg}\x1b[0m\r\n`);
}
} catch {
if (typeof event.data === 'string') term.write(event.data);
if (typeof event.data === 'string') {
setHasOutput(true);
term.write(event.data);
}
}
};
ws.onclose = (_ev) => {
setConnState((s) => (s === 'error' ? s : 'closed'));
setHasOutput(true);
if (!errorShownRef.current) {
term.write(i18n.t('kubernetes.podexecterminal.s123d9435'));
term.write('\r\n\x1b[33m[连接已关闭]\x1b[0m\r\n');
}
};
ws.onerror = (ev) => {
console.error('[PodExec:connect] WS error event fired, readyState=', ws.readyState, 'type=', ev.type);
setConnState('error');
term.write(i18n.t('kubernetes.podexecterminal.sc8107726'));
setHasOutput(true);
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
}, [clusterId, namespace, podName, container, shell]);
@@ -200,8 +210,7 @@ export function PodExecTerminal({
}
});
term.open(el);
requestAnimationFrame(() => { try { fit.fit(); } catch { /* noop */ } });
mountXterm(term, fit, () => el, wsRef.current);
mountedRef.current = true;
connect();
@@ -232,45 +241,37 @@ export function PodExecTerminal({
useEffect(() => {
if (open && termRef.current && mountedRef.current) {
connect();
} else {
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [container, shell]);
// ─── ResizeObserver ───
// ─── ResizeObserver:同步容器尺寸和远端 PTY 尺寸 // Logan.Li ───
useEffect(() => {
if (!open) return;
const el = containerRef.current;
if (!el) return;
let timer: ReturnType<typeof setTimeout>;
const ro = new ResizeObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => {
if (fitRef.current && termRef.current) {
try {
fitRef.current.fit();
termRef.current.focus();
const { cols, rows } = termRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
}
} catch { /* noop */ }
}
}, 60);
});
ro.observe(el);
return () => { clearTimeout(timer); ro.disconnect(); };
let cleanup: (() => void) | undefined;
let retry = 0;
const attach = () => {
const container = containerRef.current;
const fit = fitRef.current;
const term = termRef.current;
if (!container || !fit || !term) {
if (retry++ < 30) requestAnimationFrame(attach);
return;
}
cleanup = attachTerminalResize(container, fit, term, wsRef.current);
};
attach();
return () => cleanup?.();
}, [open, fullscreen]);
// ─── UI ───
const statusColor = connState === 'connected' ? 'bg-emerald-400'
: connState === 'error' ? 'bg-red-400'
: connState === 'closed' ? 'bg-[var(--dg-bg-active)]' : 'bg-amber-400';
const statusText = connState === 'connected' ? i18n.t('agent.agentConnected')
: connState === 'connecting' ? i18n.t('common.connecting')
: connState === 'error' ? i18n.t('kubernetes.podexecterminal.sd5235918')
: connState === 'closed' ? i18n.t('agent.agentDisconnected') : i18n.t('common.idle');
const statusText = connState === 'connected' ? '已连接'
: connState === 'connecting' ? '连接中'
: connState === 'error' ? '连接错误'
: connState === 'closed' ? '已断开' : '空闲';
const handleTerminalClick = useCallback(() => {
try { termRef.current?.focus(); } catch { /* noop */ }
@@ -290,7 +291,7 @@ export function PodExecTerminal({
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '960px', width: '92vw' }}
>
<DialogTitle className="sr-only">exec — {podName}</DialogTitle>
<DialogDescription className="sr-only"> {i18n.t('kubernetes.podexecterminal.s1a62d44d')} </DialogDescription>
<DialogDescription className="sr-only">进入 Pod 容器执行命令</DialogDescription>
{/* 工具栏 */}
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--dg-border)] flex-wrap">
@@ -306,7 +307,7 @@ export function PodExecTerminal({
<Select value={container} onValueChange={setContainer}>
<SelectTrigger className="h-8 w-[150px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<Box className="h-3 w-3 mr-1 text-[var(--dg-text-muted)]" />
<SelectValue placeholder={i18n.t('kubernetes.container.title')} />
<SelectValue placeholder="容器" />
</SelectTrigger>
<SelectContent>{containers.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}</SelectContent>
</Select>
@@ -318,25 +319,27 @@ export function PodExecTerminal({
<SelectContent>{SHELLS.map(s => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-8 px-2 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
onClick={() => { connect(); toast({ title: i18n.t('kubernetes.podexecterminal.sad127ce8') }); }} title={i18n.t('kubernetes.podexecterminal.s114d0e25')}>
onClick={() => { connect(); toast({ title: '正在重连…' }); }} title="重连">
<RotateCw className="h-3.5 w-3.5" />
</Button>
<button onClick={() => setFullscreen(!fullscreen)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]" title={fullscreen ? i18n.t('common.exitFullscreen2') : i18n.t('monitor.fullscreen')}>
<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)]" title={i18n.t('agent.portClosed')}>
<button onClick={() => onOpenChange(false)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 终端容器 */}
<div
ref={containerRef}
className="bg-[#0f172a] cursor-text"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
onClick={handleTerminalClick}
/>
<div className="relative bg-[#0f172a]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}>
<div
ref={containerRef}
className="xterm-terminal-host h-full cursor-text"
onClick={handleTerminalClick}
/>
<TerminalLoadingOverlay show={connState === 'connecting' && !hasOutput} label="正在进入容器..." />
</div>
</DialogContent>
</Dialog>
);
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
import { useTranslation } from 'react-i18next';
import { getErrorMessage } from '@/lib/utils';
export interface CreateHPADialogProps {
clusterCode: string;
@@ -16,7 +16,6 @@ export interface CreateHPADialogProps {
}
export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCreated }: CreateHPADialogProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
@@ -30,7 +29,7 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
const handleCreate = async () => {
if (!form.name.trim()) {
toast({ title: t('k8s.hpa.nameRequired'), variant: 'destructive' });
toast({ title: '名称不能为空', variant: 'destructive' });
return;
}
setLoading(true);
@@ -45,46 +44,46 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
cpuPercent: form.cpuPercent,
memPercent: form.memPercent,
});
toast({ title: t('k8s.hpa.createSuccess') });
toast({ title: 'HPA 创建成功' });
setOpen(false);
onCreated();
setForm({ name: '', minReplicas: 1, maxReplicas: 10, cpuPercent: 80, memPercent: 0 });
} catch (e: any) {
toast({ title: t('k8s.hpa.createFailed'), description: e?.message || String(e), variant: 'destructive' });
} catch (e: unknown) {
toast({ title: '创建失败', description: getErrorMessage(e), variant: 'destructive' });
} finally { setLoading(false); }
};
return (
<>
<Button variant="outline" size="sm" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => setOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1" />{t('k8s.hpa.create')}
<Plus className="h-3.5 w-3.5 mr-1" />创建 HPA
</Button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setOpen(false)}>
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl w-[480px] max-w-[92vw] max-h-[85vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-bold text-[var(--dg-text-primary)] px-6 pt-6 pb-4 shrink-0">{t('k8s.hpa.create')}</h2>
<h2 className="text-lg font-bold text-[var(--dg-text-primary)] px-6 pt-6 pb-4 shrink-0">创建 HPA</h2>
<div className="flex-1 min-h-0 overflow-y-auto px-6 space-y-3">
<div>
<Label className="dg-text-secondary text-sm">{t('common.name')}</Label>
<Label className="dg-text-secondary text-sm">名称</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={`hpa-${deploymentName}`}
className="dg-input mt-1" />
</div>
<div>
<Label className="dg-text-secondary text-sm">{t('k8s.hpa.target')}</Label>
<p className="text-[var(--dg-text-secondary)] text-sm mt-1 bg-[var(--dg-bg-tertiary)] rounded px-3 py-2 truncate" title={t('k8s.hpa.targetSummary', { deployment: deploymentName, namespace })}>
{t('k8s.hpa.targetSummary', { deployment: deploymentName, namespace })}
<Label className="dg-text-secondary text-sm">目标</Label>
<p className="text-[var(--dg-text-secondary)] text-sm mt-1 bg-[var(--dg-bg-tertiary)] rounded px-3 py-2 truncate" title={`Deployment: ${deploymentName} · 命名空间: ${namespace}`}>
Deployment: {deploymentName} · 命名空间: {namespace}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="dg-text-secondary text-sm">{t('k8s.hpa.minReplicas')}</Label>
<Label className="dg-text-secondary text-sm">最小副本数</Label>
<Input type="number" value={form.minReplicas} min={1}
onChange={(e) => setForm({ ...form, minReplicas: Number(e.target.value) })}
className="dg-input mt-1" />
</div>
<div>
<Label className="dg-text-secondary text-sm">{t('k8s.hpa.maxReplicas')}</Label>
<Label className="dg-text-secondary text-sm">最大副本数</Label>
<Input type="number" value={form.maxReplicas} min={1}
onChange={(e) => setForm({ ...form, maxReplicas: Number(e.target.value) })}
className="dg-input mt-1" />
@@ -92,13 +91,13 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="dg-text-secondary text-sm">{t('k8s.hpa.cpuTarget')}</Label>
<Label className="dg-text-secondary text-sm">CPU 目标 (%)</Label>
<Input type="number" value={form.cpuPercent} min={0} max={100}
onChange={(e) => setForm({ ...form, cpuPercent: Number(e.target.value) })}
className="dg-input mt-1" />
</div>
<div>
<Label className="dg-text-secondary text-sm">{t('k8s.hpa.memTarget')} <span className="dg-text-muted">{t('common.optional')}</span></Label>
<Label className="dg-text-secondary text-sm">内存目标 (%) <span className="dg-text-muted">可选</span></Label>
<Input type="number" value={form.memPercent} min={0} max={100}
onChange={(e) => setForm({ ...form, memPercent: Number(e.target.value) })}
className="dg-input mt-1" />
@@ -106,9 +105,9 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
</div>
</div>
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--dg-border)] shrink-0">
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={() => setOpen(false)}>{t('common.cancel')}</Button>
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={() => setOpen(false)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={loading} onClick={handleCreate}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : t('common.create')}
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : '创建'}
</Button>
</div>
</div>
@@ -116,4 +115,4 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
)}
</>
);
}
}
@@ -2,16 +2,16 @@
import { kubernetesApi } from '@/api';
import { prepareManifestForSave } from './k8s-manifest-utils';
import { isCreateDeployment } from './deployment-constants';
import i18n from '@/i18n';
import type { JsonData } from '@/types/common';
export async function saveDeploymentManifest(
clusterCode: string,
namespace: string,
deploymentName: string,
manifest: Record<string, any>,
manifest: JsonData,
): Promise<string> {
const resName = String(manifest?.metadata?.name || '').trim();
if (!resName) throw new Error(i18n.t('kubernetes.deploymentmanif.scb497faa'));
if (!resName) throw new Error('Deployment 名称不能为空');
const yaml = await prepareManifestForSave(manifest);
if (isCreateDeployment(deploymentName)) {
await kubernetesApi.applyYAML(clusterCode, yaml);
@@ -19,4 +19,4 @@ export async function saveDeploymentManifest(
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', resName, yaml);
}
return resName;
}
}
@@ -7,9 +7,10 @@ import { ArrowRight, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { joinImageRef, parseImageRef } from '../image-ref-utils';
import { ModalShell } from './ModalShell';
import type { JsonData } from '@/types/common';
export interface UpdateImageModalProps {
deployment: any;
deployment: JsonData;
newImage: string;
setNewImage: (value: string) => void;
actionLoading: string;
@@ -153,4 +154,4 @@ export function UpdateImageModal({
</div>
</ModalShell>
);
}
}
@@ -378,7 +378,7 @@ export function cleanYAML(yaml: string): string {
// 系统 volumeMount 条目
if (inVolumeMounts && trimmed.startsWith('- name:')) {
// 检查这个 volume mount 块内是否有系统路径
let blockLines: string[] = [line];
const blockLines: string[] = [line];
let j = i + 1;
let isSystem = false;
while (j < lines.length) {
@@ -427,7 +427,7 @@ export function cleanYAML(yaml: string): string {
}
// 末尾清理
let result = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
const result = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
return result + '\n';
}
+49 -54
View File
@@ -1,73 +1,69 @@
import { Link, useLocation } from 'react-router-dom';
import { ChevronRight, Home } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
/**
* Route name mapping - breadcrumb display names
*/
const ROUTE_NAME_MAP: Record<string, string> = {
'': i18n.t('menu.dashboard'),
'servers': i18n.t('menu.servers'),
'cloud-accounts': i18n.t('menu.cloudAccounts'),
'domains': i18n.t('menu.domains'),
'regions': i18n.t('menu.regions'),
'platforms': i18n.t('menu.platforms'),
'cloud-billing': i18n.t('menu.cloudBilling'),
'': '仪表盘',
'servers': '服务器管理',
'cloud-accounts': '多云账号',
'domains': '域名管理',
'cloud-billing': '云账单',
'kubernetes': 'Kubernetes',
'clusters': i18n.t('menu.clusterList'),
'nodes': i18n.t('menu.nodeManagement'),
'workloads': i18n.t('menu.workloads'),
'clusters': '集群列表',
'nodes': '节点管理',
'workloads': '工作负载',
'deployments': 'Deployments',
'statefulsets': 'StatefulSets',
'daemonsets': 'DaemonSets',
'cronjobs': i18n.t('menu.cronJobs'),
'cronjobs': '定时任务',
'services': 'Services',
'ingresses': 'Ingresses',
'configmaps': 'ConfigMaps',
'secrets': 'Secrets',
'hpas': 'HPA',
'pvcs': i18n.t('menu.storage'),
'pvcs': '存储',
'events': i18n.t('menu.events'),
'topology': i18n.t('menu.topology'),
'namespace': i18n.t('k8s.column.namespace'),
'workload': i18n.t('menu.workloads'),
'view': i18n.t('common.view'),
'edit': i18n.t('common.edit'),
'pipelines': i18n.t('menu.pipelines'),
'deploy': i18n.t('menu.deploy'),
'configureTools': i18n.t('menu.deployConfig'),
'groups': i18n.t('pipeline.userGroup'),
'rbac': i18n.t('system.permissions'),
'monitor-center': i18n.t('menu.resourceMonitor'),
'log-center': i18n.t('menu.logCenter'),
'events': '事件',
'topology': '架构拓扑',
'namespace': '命名空间',
'workload': '工作负载',
'view': '查看',
'edit': '编辑',
'pipelines': '流水线',
'deploy': '应用发布',
'configureTools': '发布配置',
'groups': '用户组',
'rbac': '权限管理',
'monitor-center': '资源监控',
'log-center': '日志中心',
'loki': 'Loki',
'es': 'ES',
'logs': i18n.t('menu.logCenter'),
'trace': i18n.t('menu.trace'),
'alert-center': i18n.t('menu.alertCenter'),
'datasources': i18n.t('menu.datasources'),
'dashboards': i18n.t('menu.grafana'),
'host-monitor': i18n.t('monitor.hostMonitor'),
'logs': '日志中心',
'trace': '链路追踪',
'alert-center': '告警中心',
'datasources': '数据源',
'dashboards': '监控大屏',
'host-monitor': '主机监控',
'alerts': '监控告警',
'approval': i18n.t('menu.approval'),
'approval-resource': i18n.t('menu.approval'),
'approval-app': i18n.t('menu.approvalApp'),
'approval-permission': i18n.t('menu.approvalPermission'),
'approval-workflow': i18n.t('menu.approvalWorkflow'),
'scripts': i18n.t('menu.scripts'),
'approval': '资源申请',
'approval-resource': '资源申请',
'approval-app': '应用申请',
'approval-permission': '权限审批',
'approval-workflow': '审批流程',
'scripts': '脚本库',
'ansible': 'Ansible',
'ai-assistant': i18n.t('menu.aiAssistant'),
'ai-alert-analyzer': i18n.t('aiops.alertAnalysis'),
'ai-predictive': i18n.t('aiops.predictive'),
'users': i18n.t('menu.users'),
'roles': i18n.t('menu.roles'),
'logs-audit': i18n.t('system.auditLog'),
'terminal-audit': i18n.t('system.terminal'),
'credentials': i18n.t('menu.credentials'),
'settings': i18n.t('menu.settings'),
'ai-assistant': '智能助手',
'ai-alert-analyzer': '告警分析',
'ai-predictive': '预测分析',
'users': '用户管理',
'roles': '角色权限',
'logs-audit': '操作审计',
'terminal-audit': '终端审计',
'credentials': '凭证配置',
'settings': '系统设置',
};
interface BreadcrumbItem {
@@ -83,13 +79,12 @@ interface BreadcrumbItem {
* 支持动态参数(如 clusterCode, namespace, name)显示
*/
export function Breadcrumb() {
const { t } = useTranslation();
const location = useLocation();
const breadcrumbs = useMemo<BreadcrumbItem[]>(() => {
const paths = location.pathname.split('/').filter(Boolean);
if (paths.length === 0) {
return [{ name: t('menu.dashboard'), path: '/', isLast: true }];
return [{ name: '仪表盘', path: '/', isLast: true }];
}
const items: BreadcrumbItem[] = [];
@@ -105,10 +100,10 @@ export function Breadcrumb() {
// 日志中心子路由区分 // Logan.Li
if (segment === 'alerts' && paths[index - 1] === 'log-center') {
displayName = t('menu.logAlerts');
displayName = '日志告警';
}
if (segment === 'datasources' && paths[index - 1] === 'log-center') {
displayName = t('menu.logLibrary');
displayName = '日志库';
}
if (!displayName) {
@@ -138,13 +133,13 @@ export function Breadcrumb() {
return (
<nav
aria-label={t('common.breadcrumbNav')}
aria-label="面包屑导航"
className="flex items-center gap-1.5 text-sm animate-fade-in"
>
<Link
to="/"
className="flex items-center gap-1 text-[var(--dg-text-muted)] hover:text-[var(--dg-blue)] transition-colors duration-200"
title={t('menu.dashboard')}
title="仪表盘"
>
<Home className="h-3.5 w-3.5" />
</Link>
+4 -8
View File
@@ -22,9 +22,7 @@ import {
Shield,
Users,
Network,
CloudCog,
Globe,
MapPin,
Key,
Brain,
Activity,
@@ -65,10 +63,8 @@ const menuItems: MenuItem[] = [
id: 'assets', name: '资产管理', labelKey: 'menu.assets', icon: Server, permission: 'server:view',
children: [
{ id: 'servers', name: '服务器管理', labelKey: 'menu.servers', icon: Database, path: '/servers', permission: 'server:view' },
{ id: 'cloud-accounts', name: '云账号管理', labelKey: 'menu.cloudAccounts', icon: Key, path: '/cloud-accounts', permission: 'cloud:account' },
{ id: 'cloud-accounts', name: '多云账号', labelKey: 'menu.cloudAccounts', icon: Key, path: '/cloud-accounts', permission: 'cloud:account' },
{ id: 'domains', name: '域名管理', labelKey: 'menu.domains', icon: Globe, path: '/domains', permission: 'cloud:domain' },
{ id: 'regions', name: '区域管理', labelKey: 'menu.regions', icon: MapPin, path: '/regions', permission: 'cloud:account' },
{ id: 'platforms', name: '云平台', labelKey: 'menu.platforms', icon: CloudCog, path: '/platforms', permission: 'cloud:account' },
{ id: 'cloud-billing', name: '云账单', labelKey: 'menu.cloudBilling', icon: BarChart3, path: '/cloud-billing', permission: 'cloud:billing' },
],
},
@@ -148,7 +144,7 @@ const menuItems: MenuItem[] = [
children: [
{ id: 'ai-assistant', name: '智能助手', labelKey: 'menu.aiAssistant', icon: Bot, path: '/ai-assistant', permission: 'aiops:assistant' },
{ id: 'ai-alert-analyzer', name: '告警分析', labelKey: 'menu.aiAlertAnalyzer', icon: AlertTriangle, path: '/ai-alert-analyzer', permission: 'aiops:analysis' },
{ id: 'ai-predictive', name: '容量预测', labelKey: 'menu.aiPredictive', icon: BarChart3, path: '/ai-predictive', permission: 'aiops:forecast' },
{ id: 'ai-predictive', name: '预测分析', labelKey: 'menu.aiPredictive', icon: BarChart3, path: '/ai-predictive', permission: 'aiops:forecast' },
{ id: 'ai-agent-cli', name: 'Agent CLI', labelKey: 'menu.aiAgentCli', icon: Terminal, path: '/ai-agent-cli', permission: 'aiops:agent-cli' },
{ id: 'ai-llm-config', name: '大模型设置', labelKey: 'menu.aiLlmConfig', icon: Settings, path: '/ai-llm-config', permission: 'aiops:llm-config' },
],
@@ -351,8 +347,8 @@ export function Sidebar() {
// Listen for keyboard shortcut
useEffect(() => {
const handler = () => toggleSidebar();
window.addEventListener('toggle-sidebar' as any, handler);
return () => window.removeEventListener('toggle-sidebar' as any, handler);
window.addEventListener('toggle-sidebar', handler);
return () => window.removeEventListener('toggle-sidebar', handler);
}, [toggleSidebar]);
// F3: 按权限过滤菜单(递归)
@@ -1,11 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// 监控大屏 — 标签页切换 Agent / Zabbix / Prometheus + 自定义仪表盘 // Logan.Li
// 监控大屏 — 标签页切换 Agent / Zabbix / Prometheus // Logan.Li
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Monitor,
Plus,
RefreshCw,
Maximize2,
Minimize2,
@@ -29,7 +28,6 @@ import {
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 {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
@@ -37,8 +35,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { useUserStore } from '@/store';
import { dashboardApi, promDatasourceApi, zabbixDatasourceApi, agentApi, serverApi } from '@/api';
import { GrafanaPanelContent } from './GrafanaPanelContent';
import { promDatasourceApi, zabbixDatasourceApi, agentApi, serverApi } from '@/api';
import { ZabbixDashboard } from './ZabbixDashboard';
import { PrometheusDashboard } from './PrometheusDashboard';
import { asList } from '@/lib/agent-utils';
@@ -52,12 +49,11 @@ import { PageLoading } from '@/components/layout/PageLoading';
import { PageEmpty } from '@/components/layout/PageEmpty';
// ═══════════ Tab 配置 ═══════════
type TabId = 'agent' | 'zabbix' | 'prometheus' | 'dashboard';
type TabId = 'agent' | 'zabbix' | 'prometheus';
const getTabs = (t: (key: string) => string): { id: TabId; label: string; icon: React.ReactNode }[] => [
{ id: 'agent', label: t('monitor.dashboard.tab.agent'), icon: <Server className="w-3.5 h-3.5" /> },
{ id: 'zabbix', label: 'Zabbix', icon: <Activity className="w-3.5 h-3.5" /> },
{ id: 'prometheus', label: 'Prometheus', icon: <Activity className="w-3.5 h-3.5" /> },
{ id: 'dashboard', label: t('monitor.dashboard.tab.dashboard'), icon: <Monitor className="w-3.5 h-3.5" /> },
];
// ═══════════ Agent 主机监控表格 ═══════════
@@ -426,7 +422,11 @@ const AgentTab: React.FC = () => {
<td className="px-2 py-2.5">
<Checkbox checked={selected.has(String(h.server_id))} onCheckedChange={(c) => {
const next = new Set(selected);
c ? next.add(String(h.server_id)) : next.delete(String(h.server_id));
if (c) {
next.add(String(h.server_id));
} else {
next.delete(String(h.server_id));
}
setSelected(next);
}} />
</td>
@@ -480,16 +480,65 @@ const AgentTab: React.FC = () => {
};
// ═══════════ 数据源 Wrapper ═══════════
interface MonitorDatasource {
id: number | string;
name?: string;
url: string;
username?: string;
password?: string;
is_default?: boolean;
}
const normalizeDatasourceList = (r: any): MonitorDatasource[] => {
const list = (r?.data || r || []) as MonitorDatasource[];
return [...list].sort((a, b) => {
if (!!a.is_default !== !!b.is_default) return a.is_default ? -1 : 1;
return String(a.name || a.url).localeCompare(String(b.name || b.url));
});
};
const DatasourceToolbar: React.FC<{
sources: MonitorDatasource[];
activeId: string;
onChange: (id: string) => void;
onRefresh: () => void;
refreshing?: boolean;
}> = ({ sources, activeId, onChange, onRefresh, refreshing }) => (
<div className="flex items-center gap-2 mb-3 flex-wrap">
<Select value={activeId} onValueChange={onChange}>
<SelectTrigger className="w-[280px] h-8 text-xs dg-input">
<SelectValue placeholder="选择数据源" />
</SelectTrigger>
<SelectContent className="dg-input">
{sources.map(ds => (
<SelectItem key={String(ds.id)} value={String(ds.id)}>
{ds.name || ds.url}{ds.is_default ? ' · 默认' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8 text-xs gap-1" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn('w-3 h-3', refreshing && 'animate-spin')} />刷新数据源
</Button>
<span className="text-[11px] text-[var(--dg-text-muted)]">共 {sources.length} 个数据源,当前仅查询选中项</span>
</div>
);
const ZabbixTab: React.FC = () => {
const { t } = useTranslation();
const [sources, setSources] = useState<MonitorDatasource[]>([]);
const [ds, setDs] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
zabbixDatasourceApi.list().then(( r: any) => {
const list = r?.data || r || [];
setDs(list.find(( d: any) => d.is_default) || list[0] || null);
}).catch(() => {}).finally(() => setLoading(false));
}, []);
const fetchSources = async () => {
setLoading(true);
try {
const list = normalizeDatasourceList(await zabbixDatasourceApi.list());
setSources(list);
setDs(current => list.find(d => String(d.id) === String(current?.id)) || list[0] || null);
} catch { setSources([]); setDs(null); }
finally { setLoading(false); }
};
useEffect(() => { fetchSources(); }, []);
if (loading) return <PageLoading label={t('monitor.dashboard.zabbix.loading')} />;
if (!ds) return (
<PageEmpty
@@ -498,19 +547,29 @@ const ZabbixTab: React.FC = () => {
action={<a href="/datasources" className="text-xs dg-text-blue hover:underline inline-flex items-center gap-1">{t('monitor.dashboard.zabbix.goConfig')} <ExternalLink className="w-3 h-3" /></a>}
/>
);
return <ZabbixDashboard dsUrl={ds.url} dsUsername={ds.username} dsPassword={ds.password} />;
return (
<div>
<DatasourceToolbar sources={sources} activeId={String(ds.id)} onChange={(id) => setDs(sources.find(item => String(item.id) === id) || ds)} onRefresh={fetchSources} refreshing={loading} />
<ZabbixDashboard key={String(ds.id)} dsUrl={ds.url} dsUsername={ds.username} dsPassword={ds.password} />
</div>
);
};
const PrometheusTab: React.FC = () => {
const { t } = useTranslation();
const [sources, setSources] = useState<MonitorDatasource[]>([]);
const [ds, setDs] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
promDatasourceApi.list().then(( r: any) => {
const list = r?.data || r || [];
setDs(list.find(( d: any) => d.is_default) || list[0] || null);
}).catch(() => {}).finally(() => setLoading(false));
}, []);
const fetchSources = async () => {
setLoading(true);
try {
const list = normalizeDatasourceList(await promDatasourceApi.list());
setSources(list);
setDs(current => list.find(d => String(d.id) === String(current?.id)) || list[0] || null);
} catch { setSources([]); setDs(null); }
finally { setLoading(false); }
};
useEffect(() => { fetchSources(); }, []);
if (loading) return <PageLoading label={t('monitor.dashboard.prometheus.loading')} />;
if (!ds) return (
<PageEmpty
@@ -519,92 +578,10 @@ const PrometheusTab: React.FC = () => {
action={<a href="/datasources" className="text-xs dg-text-blue hover:underline inline-flex items-center gap-1">{t('monitor.dashboard.zabbix.goConfig')} <ExternalLink className="w-3 h-3" /></a>}
/>
);
return <PrometheusDashboard dsUrl={ds.url} />;
};
// ═══════════ 自定义仪表盘 ═══════════
interface Dashboard { id: string; name: string; description: string; datasource: string; datasourceUrl: string; panels: Panel[]; createdAt: string; updatedAt: string; createdBy: string; isDefault: boolean; }
export interface Panel { id: string; title: string; type: string; query: string; datasource: string; width: number; height: number; x: number; y: number; options?: any; }
const DashboardTab: React.FC = () => {
const { t } = useTranslation();
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [active, setActive] = useState<Dashboard | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [nd, setNd] = useState({ name: '', description: '', datasource: 'prometheus' as const, datasourceUrl: '' });
const [np, setNp] = useState({ title: '', type: 'graph' as const, query: '', width: 6, height: 4 });
const [refreshing, setRefreshing] = useState(false);
const fetch = async () => {
setRefreshing(true);
try {
const res = await dashboardApi.getDashboards();
const list = res.data || [];
setDashboards(list);
if (list.length > 0 && !active) setActive(list[0]);
} catch (e: any) {
toast.error(t('monitor.dashboard.custom.toastRefreshFailed', { msg: (e?.message || t('monitor.dashboard.agent.toast.unknownError')) }));
} finally {
setRefreshing(false);
}
};
useEffect(() => { fetch(); }, []);
const create = async () => {
if (!nd.name) { toast.error(t('monitor.dashboard.custom.toastNameRequired')); return; }
try { await dashboardApi.createDashboard({ ...nd, panels: [] }); toast.success(t('monitor.dashboard.custom.toastCreateSuccess')); setCreateOpen(false); setNd({ name: '', description: '', datasource: 'prometheus', datasourceUrl: '' }); fetch(); } catch { toast.error(t('monitor.dashboard.custom.toastCreateFailed')); }
};
const addPanel = async () => {
if (!np.title || !np.query || !active) return;
try {
const p: Panel = { id: Date.now().toString(), ...np, datasource: active.datasource, x: 0, y: 0, options: { legend: true, tooltip: true, refreshInterval: 30 } };
await dashboardApi.updateDashboard(active.id, { ...active, panels: [...active.panels, p] });
toast.success(t('monitor.dashboard.custom.toastPanelAdded')); setAddOpen(false); setNp({ title: '', type: 'graph', query: '', width: 6, height: 4 }); fetch();
} catch { toast.error(t('monitor.dashboard.custom.toastPanelFailed')); }
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Select value={active?.id} onValueChange={id => { const d = dashboards.find(x => x.id === id); if (d) setActive(d); }}>
<SelectTrigger className="w-[240px] h-8 text-xs"><SelectValue placeholder={t('monitor.dashboard.custom.selectDashboard')} /></SelectTrigger>
<SelectContent>{dashboards.map(d => <SelectItem key={d.id} value={d.id}>{d.name}{d.isDefault ? ` ${t('monitor.dashboard.custom.defaultTag')}` : ''}</SelectItem>)}</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={fetch} disabled={refreshing}>
<RefreshCw className={cn('w-3 h-3 mr-1', refreshing && 'animate-spin')} />{t('monitor.dashboard.refresh')}
</Button>
{active && <Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={() => setAddOpen(true)}><Plus className="w-3 h-3 mr-1" />{t('monitor.dashboard.custom.addPanel')}</Button>}
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={() => setCreateOpen(true)}><Plus className="w-3 h-3 mr-1" />{t('monitor.dashboard.custom.create')}</Button>
</div>
{active ? (
active.panels.length === 0 ? (
<PageEmpty icon={<BarChart3 className="w-10 h-10" />} title={t('monitor.dashboard.custom.emptyNoPanels')} className="py-16" />
) : (
<div className="grid grid-cols-12 gap-3">
{active.panels.map(p => (
<Card key={p.id} style={{ gridColumn: `span ${p.width}`, minHeight: `${p.height * 80}px` }}>
<CardHeader className="py-2 px-3"><CardTitle className="text-[11px] font-medium">{p.title}</CardTitle></CardHeader>
<CardContent className="p-3 pt-0"><GrafanaPanelContent panel={p} datasourceUrl={active.datasourceUrl} /></CardContent>
</Card>
))}
</div>
)
) : (
<PageEmpty icon={<Monitor className="w-10 h-10" />} title={t('monitor.dashboard.custom.emptyNoDashboards')} className="py-16" />
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}><DialogContent><DialogHeader><DialogTitle>{t('monitor.dashboard.custom.createTitle')}</DialogTitle></DialogHeader>
<div className="space-y-3"><div><Label>{t('monitor.dashboard.custom.name')}</Label><Input value={nd.name} onChange={e => setNd({ ...nd, name: e.target.value })} /></div>
<div><Label>{t('monitor.dashboard.custom.datasource')}</Label><Select value={nd.datasource} onValueChange={v => setNd({ ...nd, datasource: v as any })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="prometheus">Prometheus</SelectItem><SelectItem value="zabbix">Zabbix</SelectItem><SelectItem value="skywalking">SkyWalking</SelectItem><SelectItem value="custom">Custom</SelectItem></SelectContent></Select></div></div>
<DialogFooter><Button variant="outline" onClick={() => setCreateOpen(false)}>{t('monitor.dashboard.custom.cancel')}</Button><Button onClick={create}>{t('monitor.dashboard.custom.createBtn')}</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={addOpen} onOpenChange={setAddOpen}><DialogContent><DialogHeader><DialogTitle>{t('monitor.dashboard.custom.addPanelTitle')}</DialogTitle></DialogHeader>
<div className="space-y-3"><div><Label>{t('monitor.dashboard.custom.panelTitle')}</Label><Input value={np.title} onChange={e => setNp({ ...np, title: e.target.value })} /></div>
<div><Label>{t('monitor.dashboard.custom.panelType')}</Label><Select value={np.type} onValueChange={v => setNp({ ...np, type: v as any })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="graph">{t('monitor.dashboard.custom.typeGraph')}</SelectItem><SelectItem value="stat">{t('monitor.dashboard.custom.typeStat')}</SelectItem><SelectItem value="gauge">{t('monitor.dashboard.custom.typeGauge')}</SelectItem><SelectItem value="table">{t('monitor.dashboard.custom.typeTable')}</SelectItem></SelectContent></Select></div>
<div><Label>{t('monitor.dashboard.custom.panelQuery')}</Label><Input value={np.query} onChange={e => setNp({ ...np, query: e.target.value })} /></div></div>
<DialogFooter><Button variant="outline" onClick={() => setAddOpen(false)}>{t('monitor.dashboard.custom.cancel')}</Button><Button onClick={addPanel}>{t('monitor.dashboard.custom.addBtn')}</Button></DialogFooter></DialogContent></Dialog>
<div>
<DatasourceToolbar sources={sources} activeId={String(ds.id)} onChange={(id) => setDs(sources.find(item => String(item.id) === id) || ds)} onRefresh={fetchSources} refreshing={loading} />
<PrometheusDashboard key={String(ds.id)} dsUrl={ds.url} />
</div>
);
};
@@ -638,7 +615,6 @@ const GrafanaDashboard: React.FC = () => {
{tab === 'agent' && <AgentTab />}
{tab === 'zabbix' && <ZabbixTab />}
{tab === 'prometheus' && <PrometheusTab />}
{tab === 'dashboard' && <DashboardTab />}
</PageShell>
);
};
@@ -1,252 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// 面板内容渲染 — 执行 PromQL 查询并渲染真实数据 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { TrendingUp, Loader2, AlertCircle } from 'lucide-react';
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
import type { Panel } from './GrafanaDashboard';
import { prometheusProxyApi, promDatasourceApi } from '@/api';
import i18n from '@/i18n';
// 默认 Prometheus URL 缓存
let _defaultPromURL: string | null = null;
async function getDefaultPromURL(dashboardURL?: string): Promise<string> {
// 1. 优先使用仪表盘指定的 URL
if (dashboardURL) return dashboardURL;
// 2. 缓存命中
if (_defaultPromURL) return _defaultPromURL;
// 3. 查询默认 Prometheus 数据源
try {
const res = await promDatasourceApi.list();
const list = (res as any[]) ?? [];
const def = list.find((d: any) => d.is_default) || list[0];
if (def?.url) {
_defaultPromURL = def.url;
return def.url;
}
} catch {
// ignore
}
return '';
}
// 解析 Prometheus range query 结果
function parseRangeResult(promResult: any): { time: string; value: number }[] {
try {
const result = promResult?.data?.result;
if (!Array.isArray(result) || result.length === 0) return [];
// 取第一条时间序列(多序列时合并取均值)
const series = result[0];
const values = series?.values;
if (!Array.isArray(values)) return [];
return values.map((v: any) => {
const ts = v[0] as number;
const val = parseFloat(v[1] as string) || 0;
const d = new Date(ts * 1000);
return {
time: `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
value: Math.round(val * 100) / 100,
};
});
} catch {
return [];
}
}
// 解析 Prometheus instant query 结果
function parseInstantResult(promResult: any): { labels: Record<string, string>; value: number }[] {
try {
const result = promResult?.data?.result;
if (!Array.isArray(result)) return [];
return result.map((r: any) => ({
labels: r.metric ?? {},
value: parseFloat(Array.isArray(r.value) ? r.value[1] : '0') || 0,
}));
} catch {
return [];
}
}
/** 渲染面板内容 */
export function GrafanaPanelContent({ panel, datasourceUrl }: { panel: Panel; datasourceUrl?: string }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [chartData, setChartData] = useState<{ time: string; value: number }[]>([]);
const [instantData, setInstantData] = useState<{ labels: Record<string, string>; value: number }[]>([]);
const fetchData = useCallback(async () => {
if (!panel.query) return;
setLoading(true);
setError('');
try {
const url = await getDefaultPromURL(datasourceUrl);
if (!url) {
setError(i18n.t('monitor.dashboard.prometheus.emptyTitle'));
setLoading(false);
return;
}
if (panel.type === 'graph') {
// 范围查询:最近 1 小时,1 分钟步长
const now = Math.floor(Date.now() / 1000);
const start = now - 3600;
const res = await prometheusProxyApi.queryRange({
url,
query: panel.query,
start: String(start),
end: String(now),
step: '1m',
});
const parsed = parseRangeResult(res);
setChartData(parsed.length > 0 ? parsed : []);
if (parsed.length === 0) setError(i18n.t('kubernetes.configmap.noData'));
} else {
// stat / gauge / table 用即时查询
const res = await prometheusProxyApi.query(url, panel.query);
const parsed = parseInstantResult(res);
setInstantData(parsed);
if (parsed.length === 0) setError(i18n.t('kubernetes.configmap.noData'));
}
} catch (e: any) {
setError(e?.message ? String(e.message).slice(0, 80) : i18n.t('monitor.logPanel.queryFailed'));
} finally {
setLoading(false);
}
}, [panel.query, panel.type, datasourceUrl]);
useEffect(() => {
fetchData();
// 自动刷新(每 30s)
const id = setInterval(fetchData, 30000);
return () => clearInterval(id);
}, [fetchData]);
// Loading 状态
if (loading && chartData.length === 0 && instantData.length === 0) {
return (
<div className="flex items-center justify-center h-full min-h-[120px]">
<Loader2 className="w-5 h-5 animate-spin text-[var(--dg-text-muted)]" />
</div>
);
}
// Error 状态
if (error && chartData.length === 0 && instantData.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full min-h-[120px] text-[var(--dg-text-muted)]">
<AlertCircle className="w-5 h-5 mb-1 opacity-50" />
<span className="text-[11px]">{error}</span>
</div>
);
}
switch (panel.type) {
case 'stat': {
const val = instantData[0]?.value ?? 0;
const unit = (panel.options as any)?.unit ?? '';
return (
<div className="flex flex-col items-center justify-center h-full min-h-[120px]">
<span className="text-4xl font-bold dg-text-blue">
{val > 1000 ? (val / 1000).toFixed(1) + 'K' : val.toFixed(2)}
</span>
{unit && <span className="text-sm text-[var(--dg-text-muted)] mt-2">{unit}</span>}
<div className="flex items-center gap-1 mt-2 dg-text-green text-sm">
<TrendingUp className="w-4 h-4" />
<span> {i18n.t('monitor.hostDashboard.realtime')} </span>
</div>
</div>
);
}
case 'gauge': {
const val = instantData[0]?.value ?? 0;
const pct = Math.min(val, 100);
return (
<div className="flex flex-col items-center justify-center h-full min-h-[120px]">
<div className="relative w-24 h-24">
<svg className="w-full h-full" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="none" stroke="var(--dg-border)" strokeWidth="10" />
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke={pct > 80 ? '#ef4444' : pct > 60 ? '#f59e0b' : '#3b82f6'}
strokeWidth="10"
strokeDasharray={`${(pct / 100) * 251} 251`}
transform="rotate(-90 50 50)"
style={{ transition: 'stroke-dasharray 0.5s ease' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl font-bold">{pct.toFixed(1)}%</span>
</div>
</div>
<span className="text-sm text-[var(--dg-text-muted)] mt-2">{panel.title}</span>
</div>
);
}
case 'table': {
if (instantData.length === 0) return <div className="text-center py-4 text-xs text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.configmap.noData')} </div>;
return (
<div className="overflow-auto max-h-[200px]">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-[var(--dg-border)]">
{Object.keys(instantData[0]?.labels ?? {}).slice(0, 3).map((k) => (
<th key={k} className="text-left py-1 px-2 font-medium text-[var(--dg-text-secondary)]">{k}</th>
))}
<th className="text-right py-1 px-2 font-medium text-[var(--dg-text-secondary)]">value</th>
</tr>
</thead>
<tbody>
{instantData.slice(0, 20).map((row, i) => (
<tr key={i} className="border-b border-[var(--dg-border)]/50">
{Object.entries(row.labels).slice(0, 3).map(([k, v]) => (
<td key={k} className="py-1 px-2 text-[var(--dg-text-secondary)]">{v}</td>
))}
<td className="py-1 px-2 text-right font-mono dg-text-blue">{row.value.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
case 'graph':
default: {
if (chartData.length === 0) {
return <div className="text-center py-4 text-xs text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.configmap.noData')} </div>;
}
return (
<div className="h-full min-h-[120px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 5, right: 10, bottom: 5, left: -20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" opacity={0.3} />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} width={45} />
<Tooltip
contentStyle={{
background: 'var(--dg-bg-secondary)',
border: '1px solid var(--dg-border)',
borderRadius: '6px',
fontSize: '12px',
}}
labelStyle={{ color: 'var(--dg-text-primary)' }}
/>
<Line
type="monotone"
dataKey="value"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
animationDuration={300}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
}
}
@@ -1,14 +1,14 @@
// Prometheus 数据源面板 — 接受外部配置 // Logan.Li
import React, { useState, useEffect } from 'react';
import { Server, Loader2, Activity, Cpu, RefreshCw, AlertTriangle, Container, Zap} from 'lucide-react';
import React, { useState, useEffect, useMemo } from 'react';
import { Server, Loader2, Activity, Cpu, RefreshCw, AlertTriangle, Container, Zap, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { prometheusProxyApi } from '@/api';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
interface Props { dsUrl?: string; }
@@ -37,6 +37,23 @@ const fmtBps = (b: number) => {
if (b > 1e3) return `${(b / 1e3).toFixed(1)} KB/s`;
return `${b.toFixed(1)} B/s`;
};
const fmtCores = (v: number) => v >= 1 ? `${v.toFixed(2)} 核` : `${(v * 1000).toFixed(0)} m`;
const podName = (p: JsonData) => `${p.namespace ? `${p.namespace}/` : ''}${p.pod || 'unknown'}`;
const metricValue = (item?: JsonData): number => Number(item?.value ?? Number.NaN);
const metricKey = (item?: JsonData): string => String(item?.instance || item?.exported_instance || item?.node || item?.nodename || item?.kubernetes_node || '');
const sameMetricTarget = (item: JsonData, keys: Set<string>) => keys.has(metricKey(item));
const promVector = (result: JsonData): JsonData[] => ((result?.data as JsonData)?.result as JsonData[]) || [];
const promFirstValue = (result: JsonData): number => Number(promVector(result)[0]?.value?.[1] || 0);
const promLabelValues = (result: JsonData, label: string): string[] => promVector(result).map((r: JsonData) => String((r.metric as JsonData)?.[label] || '')).filter(Boolean).sort();
const labelMatcher = (application: string, instance: string) => `application="${application.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}", instance="${instance.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
const nodeKeys = (node: JsonData): Set<string> => new Set([
node.instance,
node.exported_instance,
node.node,
node.nodename,
node.kubernetes_node,
].filter(Boolean).map(String));
function parsePrune( result: JsonData[]): JsonData[] {
return (result || []).map((r: JsonData) => {
@@ -59,7 +76,7 @@ const K8sPanel: React.FC<{ d: JsonData}> = ({ d }) => (
]}
/>
<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 dg-text-purple" /> {i18n.t('monitor.prometheusdashb.se7fb2b41')} </div>
<div className="text-xs font-medium mb-2 flex items-center gap-1"><Cpu className="w-3 h-3 dg-text-purple" />节点 CPU / 内存利用率</div>
{(d.nodeCPU || []).map((n: JsonData, i: number) => {
const mem = (d.nodeMem || []).find((m: JsonData) => m.node === n.node);
return <div key={i} className="flex items-center gap-3 text-[11px] mb-1.5"><span className="w-28 truncate" title={n.node}>{n.node}</span><MiniBar val={Number(n.value||0)} label={`${Number(n.value||0).toFixed(1)}%`} /><MiniBar val={Number(mem?.value||0)} label={`${Number(mem?.value||0).toFixed(1)}%`} color="bg-blue-500" /></div>;
@@ -68,33 +85,181 @@ const K8sPanel: React.FC<{ d: JsonData}> = ({ d }) => (
</div>
);
const NodePanel: React.FC<{ d: JsonData}> = ({ d }) => (
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
<table className="w-full text-xs">
<thead className="bg-[var(--dg-bg-tertiary)]"><tr><th className="px-3 py-2 text-left"> {i18n.t('kubernetes.node.title')} </th><th className="px-3 py-2 text-left"> {i18n.t('system.terminalStatus')} </th><th className="px-3 py-2 text-left">CPU</th><th className="px-3 py-2 text-left"> {i18n.t('kubernetes.pod.memory')} </th><th className="px-3 py-2 text-left"> {i18n.t('monitor.dashboard.col.disk')} </th><th className="px-3 py-2 text-left"> {i18n.t('monitor.prometheusdashb.s361ffa7f')} </th><th className="px-3 py-2 text-left"> {i18n.t('monitor.prometheusdashb.s37cc0a9a')} </th></tr></thead>
<tbody>
{(d.nodes || []).map((n: JsonData, i: number) => {
const inst = n.instance || n.exported_instance || '';
const cpu = (d.cpuUsage||[]).find((x: JsonData)=>x.instance===inst);
const mem = (d.memUsage||[]).find((x: JsonData)=>x.instance===inst);
const disk = (d.diskUsage||[]).find((x: JsonData)=>x.instance===inst);
const up = (d.up||[]).find((x: JsonData)=>x.instance===inst);
const rx = (d.netRx||[]).find((x: JsonData)=>x.instance===inst);
const tx = (d.netTx||[]).find((x: JsonData)=>x.instance===inst);
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium">{n.nodename||inst}</td>
<td className="px-3 py-2"><Badge className={`text-[10px] ${up?.value===1?'bg-emerald-500/20 dg-text-green':'bg-red-500/20 dg-text-red'}`}>{up?.value===1?'UP':'DOWN'}</Badge></td>
<td className="px-3 py-2"><MiniBar val={Number(cpu?.value||0)} label={`${Number(cpu?.value||0).toFixed(1)}%`} /></td>
<td className="px-3 py-2"><MiniBar val={Number(mem?.value||0)} label={`${Number(mem?.value||0).toFixed(1)}%`} color="bg-blue-500" /></td>
<td className="px-3 py-2"><MiniBar val={Number(disk?.value||0)} label={`${Number(disk?.value||0).toFixed(1)}%`} color="bg-amber-500" /></td>
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(Number(rx?.value||0))}</td>
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(Number(tx?.value||0))}</td>
</tr>;
})}
</tbody>
</table>
</div>
);
type NodeSortField = 'name' | 'cpu' | 'mem' | 'disk' | 'rx' | 'tx';
const NodePanel: React.FC<{ d: JsonData}> = ({ d }) => {
const [sortField, setSortField] = useState<NodeSortField>('cpu');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const rows = useMemo(() => (d.nodes || []).map((n: JsonData) => {
const keys = nodeKeys(n);
const cpu = (d.cpuUsage || []).find((x: JsonData) => sameMetricTarget(x, keys));
const mem = (d.memUsage || []).find((x: JsonData) => sameMetricTarget(x, keys));
const disk = (d.diskUsage || []).find((x: JsonData) => sameMetricTarget(x, keys));
const rx = (d.netRx || []).find((x: JsonData) => sameMetricTarget(x, keys));
const tx = (d.netTx || []).find((x: JsonData) => sameMetricTarget(x, keys));
return {
name: String(n.nodename || n.node || n.instance || n.exported_instance || 'unknown'),
cpu: metricValue(cpu) || 0,
mem: metricValue(mem) || 0,
disk: metricValue(disk) || 0,
rx: metricValue(rx) || 0,
tx: metricValue(tx) || 0,
};
}), [d]);
const sorted = useMemo(() => [...rows].sort((a, b) => {
const va = a[sortField];
const vb = b[sortField];
if (typeof va === 'number' && typeof vb === 'number') return sortDir === 'asc' ? va - vb : vb - va;
return sortDir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
}), [rows, sortField, sortDir]);
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const safePage = Math.min(page, totalPages);
const paged = sorted.slice((safePage - 1) * pageSize, safePage * pageSize);
const toggleSort = (f: NodeSortField) => {
if (sortField === f) setSortDir(dv => dv === 'asc' ? 'desc' : 'asc');
else { setSortField(f); setSortDir('desc'); }
};
const SI = ({ f }: { f: NodeSortField }) => sortField !== f ? <ArrowUpDown className="w-3 h-3 dg-text-muted" /> : sortDir === 'asc' ? <ArrowUp className="w-3 h-3 dg-text-purple" /> : <ArrowDown className="w-3 h-3 dg-text-purple" />;
return (
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
<table className="w-full text-xs">
<thead className="bg-[var(--dg-bg-tertiary)]"><tr>
{[
{ f: 'name' as NodeSortField, l: '节点' }, { f: 'cpu' as NodeSortField, l: 'CPU' },
{ f: 'mem' as NodeSortField, l: '内存' }, { f: 'disk' as NodeSortField, l: '磁盘' }, { f: 'rx' as NodeSortField, l: '流入' }, { f: 'tx' as NodeSortField, l: '流出' },
].map(({ f, l }) => (
<th key={f} className="px-3 py-2 text-left cursor-pointer select-none" onClick={() => toggleSort(f)}><div className="flex items-center gap-1">{l}<SI f={f} /></div></th>
))}
</tr></thead>
<tbody>
{paged.map((n, i) => (
<tr key={n.name + i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium">{n.name}</td>
<td className="px-3 py-2"><MiniBar val={n.cpu} label={`${n.cpu.toFixed(1)}%`} /></td>
<td className="px-3 py-2"><MiniBar val={n.mem} label={`${n.mem.toFixed(1)}%`} color="bg-blue-500" /></td>
<td className="px-3 py-2"><MiniBar val={n.disk} label={`${n.disk.toFixed(1)}%`} color="bg-amber-500" /></td>
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(n.rx)}</td>
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(n.tx)}</td>
</tr>
))}
</tbody>
</table>
{sorted.length > pageSize && (
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs">
<div className="flex items-center gap-2 text-[var(--dg-text-muted)]">
<span>共 {sorted.length} 个节点</span>
<select className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded px-2 py-0.5 text-xs" value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}>
<option value={10}>10/页</option><option value={20}>20/页</option><option value={50}>50/页</option>
</select>
</div>
<div className="flex items-center gap-1">
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={safePage <= 1} onClick={() => setPage(Math.max(1, safePage - 1))}>上一页</button>
<span className="px-2 text-[var(--dg-text-primary)]">{safePage} / {totalPages}</span>
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={safePage >= totalPages} onClick={() => setPage(Math.min(totalPages, safePage + 1))}>下一页</button>
</div>
</div>
)}
</div>
);
};
const SimpleRank: React.FC<{
title: string;
rows: JsonData[];
valueText: (v: number) => string;
color?: string;
}> = ({ title, rows, valueText, color = 'bg-purple-500' }) => {
const max = Math.max(...rows.map(r => Number(r.value || 0)), 1);
return (
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
<CardHeader className="py-2 px-3">
<CardTitle className="text-xs font-medium">{title}</CardTitle>
</CardHeader>
<CardContent className="p-3 pt-0">
<div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
{rows.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--dg-text-muted)]">暂无数据</div>
) : rows.slice(0, 10).map((p: JsonData, i: number) => {
const value = Number(p.value || 0);
return (
<div key={`${podName(p)}-${i}`} className="flex items-center gap-2 text-[11px]">
<span className="w-6 text-right text-[var(--dg-text-muted)]">{i + 1}</span>
<span className="w-40 truncate" title={podName(p)}>{podName(p)}</span>
<div className="flex-1 h-2 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${Math.max(4, Math.min(100, (value / max) * 100))}%` }} />
</div>
<span className="w-20 text-right font-mono text-[var(--dg-text-secondary)]">{valueText(value)}</span>
</div>
);
})}
</div>
</CardContent>
</Card>
);
};
const PodNetworkRank: React.FC<{ rx: JsonData[]; tx: JsonData[] }> = ({ rx, tx }) => {
const rows = useMemo(() => {
const merged = new Map<string, { name: string; rx: number; tx: number; total: number }>();
for (const item of rx || []) {
const name = podName(item);
const current = merged.get(name) || { name, rx: 0, tx: 0, total: 0 };
current.rx = Number(item.value || 0);
current.total = current.rx + current.tx;
merged.set(name, current);
}
for (const item of tx || []) {
const name = podName(item);
const current = merged.get(name) || { name, rx: 0, tx: 0, total: 0 };
current.tx = Number(item.value || 0);
current.total = current.rx + current.tx;
merged.set(name, current);
}
return [...merged.values()].sort((a, b) => b.total - a.total).slice(0, 10);
}, [rx, tx]);
return (
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
<CardHeader className="py-2 px-3">
<CardTitle className="text-xs font-medium">Pod 流量网卡 Top 10</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-[var(--dg-bg-tertiary)] border-y border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">Pod</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">流入</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">流出</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">总流量</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={4} className="px-3 py-8 text-center text-[var(--dg-text-muted)]">暂无数据</td></tr>
) : rows.map((p, i) => (
<tr key={p.name} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2">
<span className="mr-2 text-[var(--dg-text-muted)]">{i + 1}</span>
<span className="font-medium text-[var(--dg-text-primary)]">{p.name}</span>
</td>
<td className="px-3 py-2 text-right font-mono text-[var(--dg-text-secondary)]">{fmtBps(p.rx)}</td>
<td className="px-3 py-2 text-right font-mono text-[var(--dg-text-secondary)]">{fmtBps(p.tx)}</td>
<td className="px-3 py-2 text-right font-mono text-[var(--dg-text-primary)]">{fmtBps(p.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
};
const PodPanel: React.FC<{ d: JsonData}> = ({ d }) => (
<div className="space-y-4">
@@ -104,21 +269,126 @@ const PodPanel: React.FC<{ d: JsonData}> = ({ d }) => (
{ 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: JsonData, 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"> {i18n.t('monitor.prometheusdashb.sb0f653eb')} </div>{(d.memTop||[]).map((p: JsonData, 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>
<PodNetworkRank rx={d.netRxTop || []} tx={d.netTxTop || []} />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<SimpleRank title="Pod CPU Top 10" rows={d.cpuTop || []} valueText={fmtCores} color="bg-purple-500" />
<SimpleRank title="Pod 内存 Top 10" rows={d.memTop || []} valueText={fmtBytes} color="bg-blue-500" />
</div>
</div>
);
const JvmPanel: React.FC<{ d: JsonData}> = ({ d }) => {
const h = Number(d.heapUsed?.[0]?.value||0);
const uptime = Number(d.uptime?.[0]?.value||0);
const JvmPanel: React.FC<{ dsUrl?: string; fallback: JsonData }> = ({ dsUrl, fallback }) => {
const [applications, setApplications] = useState<string[]>([]);
const [instances, setInstances] = useState<string[]>([]);
const [application, setApplication] = useState('');
const [instance, setInstance] = useState('');
const [loading, setLoading] = useState(false);
const [metrics, setMetrics] = useState<JsonData>({});
useEffect(() => {
if (!dsUrl) return;
let cancelled = false;
prometheusProxyApi.query(dsUrl, 'count by(application) (jvm_memory_used_bytes)')
.then((res: JsonData) => {
if (cancelled) return;
const values = promLabelValues(res, 'application');
setApplications(values);
setApplication(current => current && values.includes(current) ? current : values[0] || '');
})
.catch(() => {
if (!cancelled) setApplications([]);
});
return () => { cancelled = true; };
}, [dsUrl]);
useEffect(() => {
if (!dsUrl || !application) { setInstances([]); setInstance(''); return; }
let cancelled = false;
const app = application.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
prometheusProxyApi.query(dsUrl, `count by(instance) (jvm_memory_used_bytes{application="${app}"})`)
.then((res: JsonData) => {
if (cancelled) return;
const values = promLabelValues(res, 'instance');
setInstances(values);
setInstance(current => current && values.includes(current) ? current : values[0] || '');
})
.catch(() => {
if (!cancelled) setInstances([]);
});
return () => { cancelled = true; };
}, [dsUrl, application]);
useEffect(() => {
if (!dsUrl || !application || !instance) return;
let cancelled = false;
const loadMetrics = async () => {
setLoading(true);
try {
const m = labelMatcher(application, instance);
const [uptime, heapUsed, nonHeapUsed, threadCount, gcPause, cpuLoad] = await Promise.all([
prometheusProxyApi.query(dsUrl, `process_uptime_seconds{${m}}`),
prometheusProxyApi.query(dsUrl, `sum(jvm_memory_used_bytes{${m},area="heap"}) / sum(jvm_memory_max_bytes{${m},area="heap"}) * 100`),
prometheusProxyApi.query(dsUrl, `sum(jvm_memory_used_bytes{${m},area="nonheap"}) / sum(jvm_memory_max_bytes{${m},area="nonheap"}) * 100`),
prometheusProxyApi.query(dsUrl, `jvm_threads_live_threads{${m}}`),
prometheusProxyApi.query(dsUrl, `sum(rate(jvm_gc_pause_seconds_sum{${m}}[5m]))`),
prometheusProxyApi.query(dsUrl, `process_cpu_usage{${m}} * 100`),
]);
if (!cancelled) {
setMetrics({
uptime: promFirstValue(uptime),
heapUsed: promFirstValue(heapUsed),
nonHeapUsed: promFirstValue(nonHeapUsed),
threadCount: promFirstValue(threadCount),
gcPause: promFirstValue(gcPause),
cpuLoad: promFirstValue(cpuLoad),
});
}
} catch {
if (!cancelled) setMetrics({});
} finally {
if (!cancelled) setLoading(false);
}
};
loadMetrics();
return () => { cancelled = true; };
}, [dsUrl, application, instance]);
const h = Number(metrics.heapUsed ?? fallback.heapUsed?.[0]?.value ?? 0);
const uptime = Number(metrics.uptime ?? fallback.uptime?.[0]?.value ?? 0);
const nonHeap = Number(metrics.nonHeapUsed ?? fallback.nonHeapUsed?.[0]?.value ?? 0);
const threads = Number(metrics.threadCount ?? fallback.threadCount?.[0]?.value ?? 0);
const gcPause = Number(metrics.gcPause ?? fallback.gcPause?.[0]?.value ?? 0);
const cpuLoad = Number(metrics.cpuLoad ?? fallback.cpuLoad?.[0]?.value ?? 0);
return (
<div className="grid grid-cols-5 gap-3">
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<Select value={application} onValueChange={setApplication}>
<SelectTrigger className="w-[260px] h-8 text-xs dg-input">
<SelectValue placeholder="选择 Application" />
</SelectTrigger>
<SelectContent className="dg-input">
{applications.map(app => <SelectItem key={app} value={app}>{app}</SelectItem>)}
</SelectContent>
</Select>
<Select value={instance} onValueChange={setInstance}>
<SelectTrigger className="w-[280px] h-8 text-xs dg-input">
<SelectValue placeholder="选择 Instance" />
</SelectTrigger>
<SelectContent className="dg-input">
{instances.map(ins => <SelectItem key={ins} value={ins}>{ins}</SelectItem>)}
</SelectContent>
</Select>
{loading && <span className="text-[11px] text-[var(--dg-text-muted)] inline-flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" />加载 JVM 指标</span>}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold">{uptime>86400?`${(uptime/86400).toFixed(1)}d`:`${(uptime/3600).toFixed(1)}h`}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Uptime</div></CardContent></Card>
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><MiniBar val={h} label={`${h.toFixed(1)}%`} color={h>80?'bg-orange-500':'bg-purple-500'} /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Heap</div></CardContent></Card>
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><MiniBar val={Number(d.nonHeapUsed?.[0]?.value||0)} label={`${Number(d.nonHeapUsed?.[0]?.value||0).toFixed(1)}%`} color="bg-blue-500" /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Non-Heap</div></CardContent></Card>
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold dg-text-green">{d.threadCount?.[0]?.value||0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Threads</div></CardContent></Card>
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold dg-text-yellow">{Number(d.gcPause?.[0]?.value||0).toFixed(3)}</div><div className="text-[10px] text-[var(--dg-text-muted)]">GC Pause (s)</div></CardContent></Card>
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><MiniBar val={nonHeap} label={`${nonHeap.toFixed(1)}%`} color="bg-blue-500" /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Non-Heap</div></CardContent></Card>
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold dg-text-green">{threads || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Threads</div></CardContent></Card>
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold dg-text-yellow">{gcPause.toFixed(3)}</div><div className="text-[10px] text-[var(--dg-text-muted)]">GC Pause (s)</div></CardContent></Card>
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><MiniBar val={cpuLoad} label={`${cpuLoad.toFixed(1)}%`} color="bg-emerald-500" /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Process CPU</div></CardContent></Card>
</div>
</div>
);
};
@@ -144,7 +414,7 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
const data: JsonData = await prometheusProxyApi.connect(u);
setConnected(true); setTargets(data.targets||0);
loadAll(u);
} catch (e) { setConnected(false); setError((e as JsonData).message||i18n.t('aiops.llmTestFailed')); }
} catch (e) { setConnected(false); setError((e as JsonData).message||'连接失败'); }
finally { setLoading(false); }
};
@@ -157,7 +427,9 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
items[k] = Array.isArray(v) ? v : ((v as JsonData)?.data?.result ? parsePrune((v as JsonData).data.result) : v);
if (t==='kubernetes') setK8sD(items); if (t==='node') setNodeD(items);
if (t==='pod') setPodD(items); if (t==='jvm') setJvmD(items);
} catch {}
} catch {
// 单类指标失败不阻断其他指标加载。// Logan.Li
}
}
};
@@ -172,11 +444,11 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
<Activity className="h-4 w-4 dg-text-purple" />
<CardTitle className="text-sm font-medium">Prometheus 监控</CardTitle>
{connected && <Badge className="text-[10px] bg-purple-500/10 dg-text-purple">{targets} targets</Badge>}
{connected && <div className="ml-auto"><Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" /> {i18n.t('monitor.dashboard.refresh')} </Button></div>}
{connected && <div className="ml-auto"><Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button></div>}
</div>
</CardHeader>
<CardContent className="px-4 pb-3 pt-0">
{loading ? (<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin dg-text-purple" /><p className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.prometheusdashb.s5e8eb212')} </p></div>)
{loading ? (<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin dg-text-purple" /><p className="text-xs text-[var(--dg-text-muted)]">连接中…</p></div>)
: error ? (<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs dg-text-red"><AlertTriangle className="h-4 w-4 inline mr-1" />{error}</div>)
: (
<Tabs value={tab} onValueChange={setTab}>
@@ -189,7 +461,7 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
<TabsContent value="kubernetes"><K8sPanel d={k8sD} /></TabsContent>
<TabsContent value="node"><NodePanel d={nodeD} /></TabsContent>
<TabsContent value="pod"><PodPanel d={podD} /></TabsContent>
<TabsContent value="jvm"><JvmPanel d={jvmD} /></TabsContent>
<TabsContent value="jvm"><JvmPanel dsUrl={dsUrl} fallback={jvmD} /></TabsContent>
</Tabs>
)}
</CardContent>
@@ -3,7 +3,6 @@ import React, { useState, useEffect } from 'react';
import {
Server,
Wifi,
Loader2,
AlertTriangle,
Activity,
@@ -39,6 +38,8 @@ export const ZabbixDashboard: React.FC<Props> = ({ dsUrl, dsUsername, dsPassword
const [error, setError] = useState('');
const [sortField, setSortField] = useState<SortField>('cpu_usage');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
useEffect(() => {
if (dsUrl && dsUsername && dsPassword) doConnect(dsUrl, dsUsername, dsPassword);
@@ -65,6 +66,9 @@ export const ZabbixDashboard: React.FC<Props> = ({ dsUrl, dsUsername, dsPassword
if (typeof va === 'number') return sortDir === 'asc' ? va - vb : vb - va;
return sortDir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const safePage = Math.min(page, totalPages);
const paged = sorted.slice((safePage - 1) * pageSize, safePage * pageSize);
const SI = ({ f }: { f: SortField }) => {
if (sortField !== f) return <ArrowUpDown className="w-3 h-3 dg-text-muted" />;
@@ -118,9 +122,9 @@ export const ZabbixDashboard: React.FC<Props> = ({ dsUrl, dsUsername, dsPassword
<div className="flex items-center gap-1">{l}<SI f={f} /></div></th>
))}</tr></thead>
<tbody>
{sorted.map((h, i) => (
{paged.map((h, i) => (
<tr key={h.hostid || i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2.5 whitespace-nowrap"><Badge className={`text-[10px] ${h.available === '1' ? 'bg-emerald-500/20 dg-text-green' : 'bg-red-500/20 dg-text-red'}`}><Wifi className="w-3 h-3 mr-1" />{h.available === '1' ? 'UP' : 'DOWN'}</Badge> <span className="font-medium text-[var(--dg-text-primary)] ml-1">{h.name || h.hostname}</span></td>
<td className="px-3 py-2.5 whitespace-nowrap font-medium text-[var(--dg-text-primary)]">{h.name || h.hostname}</td>
<td className="px-3 py-2.5">{bar(Number(h.cpu_usage || 0), `${Number(h.cpu_usage || 0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5">{bar(Number(h.memory_usage || 0), `${Number(h.memory_usage || 0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5">{bar(Number(h.disk_usage || 0), `${Number(h.disk_usage || 0).toFixed(1)}%`)}</td>
@@ -133,6 +137,21 @@ export const ZabbixDashboard: React.FC<Props> = ({ dsUrl, dsUsername, dsPassword
))}
</tbody>
</table>
{sorted.length > pageSize && (
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs">
<div className="flex items-center gap-2 text-[var(--dg-text-muted)]">
<span>共 {sorted.length} 台主机</span>
<select className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded px-2 py-0.5 text-xs" value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}>
<option value={10}>10/页</option><option value={20}>20/页</option><option value={50}>50/页</option>
</select>
</div>
<div className="flex items-center gap-1">
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={safePage <= 1} onClick={() => setPage(Math.max(1, safePage - 1))}>上一页</button>
<span className="px-2 text-[var(--dg-text-primary)]">{safePage} / {totalPages}</span>
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={safePage >= totalPages} onClick={() => setPage(Math.min(totalPages, safePage + 1))}>下一页</button>
</div>
</div>
)}
</div>
)}
</CardContent>
@@ -3,8 +3,8 @@ import { useState, useEffect } from 'react';
import { X, BellRing, Loader2, Flame, Database, Cloud } from 'lucide-react';
import { alertApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/utils';
import type { LogEngine } from './types-v3';
import i18n from '@/i18n';
interface LogQueryContext {
engine: LogEngine;
@@ -74,11 +74,11 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
notify_channels: [],
});
setSaved(true);
toast({ title: i18n.t('monitor.savequerydialog.sd3dbea92') });
toast({ title: '告警规则已创建' });
onSaved?.();
setTimeout(() => onOpenChange(false), 800);
} catch (e: any) {
toast({ title: i18n.t('monitor.dashboard.custom.toastCreateFailed'), description: e?.message || String(e), variant: 'destructive' });
} catch (e: unknown) {
toast({ title: '创建失败', description: getErrorMessage(e), variant: 'destructive' });
} finally {
setSaving(false);
}
@@ -95,7 +95,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
<div className="flex items-center gap-2">
<BellRing className="h-5 w-5" style={{ color: '#f59e0b' }} />
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}> {i18n.t('monitor.logalertfromque.s5f470749')} </span>
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>创建告警规则</span>
</div>
<button
onClick={() => onOpenChange(false)}
@@ -113,12 +113,12 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded font-medium" style={{ background: `${color}15`, color }}>
<Icon className="h-3 w-3" />{meta.name}
</span>
<span className="text-[11px] truncate" style={{ color: 'var(--dg-text-secondary)' }}>{ctx.datasourceName || i18n.t('monitor.savequerydialog.sddb4ca92')}</span>
<span className="text-[11px] truncate" style={{ color: 'var(--dg-text-secondary)' }}>{ctx.datasourceName || '默认数据源'}</span>
<span className="text-[10px] ml-auto" style={{ color: 'var(--dg-text-muted)' }}>{ctx.timeRange || '1h'}</span>
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('monitor.silenceDialog.ruleNameRequired')} </label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>规则名称 *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
@@ -128,7 +128,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('aiops.llmModelDesc')} </label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>描述</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
@@ -139,7 +139,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('monitor.logalertfromque.sa6c48ab1')} </label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>触发条件 *</label>
<textarea
value={expression}
onChange={e => setExpression(e.target.value)}
@@ -148,26 +148,26 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)' }}
/>
<p className="text-[10px] mt-1" style={{ color: 'var(--dg-text-muted)' }}>
{ctx.engine === 'loki' ? i18n.t('monitor.logalertfromque.s0d669ee6') : ctx.engine === 'sls' ? i18n.t('monitor.logalertfromque.sf769e374') : i18n.t('monitor.logalertfromque.sa079c76e')}
{ctx.engine === 'loki' ? 'LogQL 告警表达式' : ctx.engine === 'sls' ? 'SLS SQL/SPL 查询' : 'ES Lucene 查询'}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('kubernetes.event.eventLevel')} </label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>级别</label>
<select
value={severity}
onChange={e => setSeverity(e.target.value)}
className="w-full h-9 px-2 text-xs rounded dg-input"
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)' }}
>
<option value="info"> {i18n.t('monitor.alertEvents.severity.info')} </option>
<option value="warning"> {i18n.t('kubernetes.event.warning')} </option>
<option value="critical"> {i18n.t('monitor.alertHelper.sevCritical')} </option>
<option value="info">提示</option>
<option value="warning">警告</option>
<option value="critical">严重</option>
</select>
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('monitor.logalertfromque.s5ec177f4')} </label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>持续(秒)</label>
<input
type="number"
value={duration}
@@ -200,7 +200,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
style={{ background: 'var(--dg-blue)' }}
>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{saved ? i18n.t('kubernetes.event.created') : i18n.t('monitor.alertruledialog.s8e1e3dea')}
{saved ? '已创建' : '创建规则'}
</button>
</div>
</div>
@@ -12,9 +12,9 @@ import {
} from '@/components/ui/select';
import { Loader2, Plug, RefreshCw } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/utils';
import { logCenterApi, type LogDataSource, type TestConnectionResult } from '@/api';
import type { LogEngine } from './log-center-types';
import i18n from '@/i18n';
export function LogDatasourceDialog({
open, onOpenChange, engine, editing, onSaved,
@@ -128,12 +128,12 @@ export function LogDatasourceDialog({
: payload();
const result: TestConnectionResult = await logCenterApi.testDatasource(data);
if (result.status === 'ok') {
toast({ title: i18n.t('aiops.llmTestSuccess') });
toast({ title: '连接成功' });
} else {
toast({ title: i18n.t('aiops.llmTestFailed'), description: result.error || i18n.t('monitor.dashboard.agent.toast.unknownError'), variant: 'destructive' });
toast({ title: '连接失败', description: result.error || '未知错误', variant: 'destructive' });
}
} catch (e: any) {
toast({ title: i18n.t('aiops.llmTestFailed'), description: e?.message || String(e), variant: 'destructive' });
} catch (e: unknown) {
toast({ title: '连接失败', description: getErrorMessage(e), variant: 'destructive' });
} finally {
setTesting(false);
}
@@ -148,11 +148,11 @@ export function LogDatasourceDialog({
} else {
await logCenterApi.createDatasource(payload());
}
toast({ title: i18n.t('deploy.toast.saved') });
toast({ title: '已保存' });
onOpenChange(false);
onSaved();
} catch (e: any) {
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: e?.message || String(e), variant: 'destructive' });
} catch (e: unknown) {
toast({ title: '保存失败', description: getErrorMessage(e), variant: 'destructive' });
} finally {
setSaving(false);
}
@@ -163,7 +163,7 @@ export function LogDatasourceDialog({
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{editing ? i18n.t('common.edit') : i18n.t('monitor.dashboard.custom.addBtn')}{engine === 'loki' ? ' Loki' : ' Elasticsearch'} 数据源
{editing ? '编辑' : '添加'}{engine === 'loki' ? ' Loki' : ' Elasticsearch'} 数据源
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
仅保存连接信息,日志本体存储在您的 Loki / ES 集群
@@ -171,17 +171,17 @@ export function LogDatasourceDialog({
</DialogHeader>
<div className="space-y-3 py-1">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.secret.secretName')} </Label>
<Label className="text-xs text-[var(--dg-text-muted)]">名称</Label>
<Input value={name} onChange={e => setName(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.datasourceUrl')} </Label>
<Label className="text-xs text-[var(--dg-text-muted)]">地址</Label>
<Input value={url} onChange={e => setUrl(e.target.value)} placeholder="http://host:port" className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
{engine === 'elasticsearch' && (
<div>
<div className="flex items-center justify-between">
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.logdatasourcedi.s6621b501')} </Label>
<Label className="text-xs text-[var(--dg-text-muted)]">默认索引</Label>
<Button
variant="ghost"
size="sm"
@@ -211,7 +211,7 @@ export function LogDatasourceDialog({
获取索引
</Button>
</div>
<Input value={defaultIndex} onChange={e => setDefaultIndex(e.target.value)} placeholder={i18n.t('monitor.logdatasourcedi.s6064025e')} className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<Input value={defaultIndex} onChange={e => setDefaultIndex(e.target.value)} placeholder="索引名或通配符,如 .monitoring-es-7-*" className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
{/* 已获取索引快捷选择(支持通配) // Logan.Li */}
{indices.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1 max-h-24 overflow-auto">
@@ -246,23 +246,23 @@ export function LogDatasourceDialog({
</div>
)}
<div>
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.dashboard.creds.authType')} </Label>
<Label className="text-xs text-[var(--dg-text-muted)]">认证方式</Label>
<Select value={authType} onValueChange={(v) => setAuthType(v as LogDataSource['auth_type'])}>
<SelectTrigger className="mt-1 h-9 w-full bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<SelectItem value="none" className="text-xs"> {i18n.t('monitor.logdatasourcedi.s7870c3f1')} </SelectItem>
<SelectItem value="none" className="text-xs">无认证</SelectItem>
<SelectItem value="basic" className="text-xs">Basic Auth</SelectItem>
<SelectItem value="bearer" className="text-xs">Bearer Token</SelectItem>
<SelectItem value="custom" className="text-xs"> {i18n.t('monitor.logdatasourcedi.s2e722014')} </SelectItem>
<SelectItem value="custom" className="text-xs">自定义 Headers</SelectItem>
</SelectContent>
</Select>
</div>
{authType === 'basic' && (
<div className="grid grid-cols-2 gap-2">
<Input value={user} onChange={e => setUser(e.target.value)} placeholder={i18n.t('system.userName')} className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<Input type="password" value={pass} onChange={e => setPass(e.target.value)} placeholder={i18n.t('system.passwordLabel')} className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<Input value={user} onChange={e => setUser(e.target.value)} placeholder="用户名" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<Input type="password" value={pass} onChange={e => setPass(e.target.value)} placeholder="密码" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
)}
{authType === 'bearer' && (
@@ -278,23 +278,23 @@ export function LogDatasourceDialog({
const next = [...headers];
next[i].name = e.target.value;
setHeaders(next);
}} placeholder={i18n.t('monitor.logdatasourcedi.sc7f8ebda')} className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
}} placeholder="Header 名,如 X-Scope-OrgID" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<Input value={h.value} onChange={e => {
const next = [...headers];
next[i].value = e.target.value;
setHeaders(next);
}} placeholder={i18n.t('monitor.logdatasourcedi.scf7126fb')} className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
}} placeholder="Header 值" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
))}
<Button variant="outline" size="sm" className="h-7 text-xs border-[var(--dg-border)]" onClick={() => setHeaders([...headers, { name: '', value: '' }])}> {i18n.t('monitor.logdatasourcedi.s86a7a65e')} </Button>
<Button variant="outline" size="sm" className="h-7 text-xs border-[var(--dg-border)]" onClick={() => setHeaders([...headers, { name: '', value: '' }])}>添加 Header</Button>
</div>
)}
<div className="flex items-center justify-between">
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.logdatasourcedi.sa441a86f')} </Label>
<Label className="text-xs text-[var(--dg-text-muted)]">跳过 TLS 校验</Label>
<Switch checked={skipTLS} onCheckedChange={setSkipTLS} />
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('aiops.llmSetDefault')} </Label>
<Label className="text-xs text-[var(--dg-text-muted)]">设为默认</Label>
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
</div>
</div>
@@ -125,9 +125,10 @@ export function LogHistogram({ data, engine, onBarClick, loading, totalBytes, li
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3, stroke: colors.stroke, strokeWidth: 1 }}
onClick={(entry: any) => {
if (onBarClick && entry?.timestamp) {
const start = entry.timestamp;
onClick={(entry: unknown) => {
const point = entry as { timestamp?: string };
if (onBarClick && point.timestamp) {
const start = point.timestamp;
const end = start.slice(0, 13) + ':59:59';
onBarClick(start, end);
}
@@ -3,9 +3,9 @@ import { useState } from 'react';
import { X, Save, BellRing, Loader2 } from 'lucide-react';
import { alertApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/utils';
import type { LogEngine, TimeRange } from './types-v3';
import { ENGINE_META, savedQueryStorage } from './types-v3';
import i18n from '@/i18n';
interface SaveQueryProps {
open: boolean;
@@ -47,24 +47,24 @@ export function SaveQueryDialog({ open, onClose, engine, query, timeRange, datas
<div className="fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={onClose} />
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
<div className="flex items-center gap-2"><Save className="h-4 w-4" style={{ color: 'var(--dg-blue)' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}> {i18n.t('monitor.statChart.saveAsQuery')} </span></div>
<div className="flex items-center gap-2"><Save className="h-4 w-4" style={{ color: 'var(--dg-blue)' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>另存为查询</span></div>
<button onClick={onClose} className="h-7 w-7 flex items-center justify-center" style={{ color: 'var(--dg-text-muted)' }}><X className="h-4 w-4" /></button>
</div>
<div className="p-4 space-y-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('monitor.savequerydialog.s54c06867')} </label>
<input value={name} onChange={e => setName(e.target.value)} placeholder={i18n.t('monitor.savequerydialog.s30168e0e')} className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>查询名称 *</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder="输入查询名称" className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
</div>
<div className="p-2 rounded text-[11px]" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
<span style={{ color: ENGINE_META[engine].color }}>{ENGINE_META[engine].name}</span> · {datasourceName || i18n.t('k8s.storage.default')}<br />
<span style={{ color: ENGINE_META[engine].color }}>{ENGINE_META[engine].name}</span> · {datasourceName || '默认'}<br />
<span className="font-mono">{query}</span><br />
<span style={{ color: 'var(--dg-text-muted)' }}>{timeRange.label} · {timeRange.start} ~ {timeRange.end}</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t" style={{ borderColor: 'var(--dg-border)' }}>
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}> {i18n.t('rbac.jobRole.cancel')} </button>
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>取消</button>
<button onClick={handleSave} disabled={saving || !name.trim() || saved} className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: 'var(--dg-blue)' }}>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? i18n.t('deploy.toast.saved') : i18n.t('task.script.save')}
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? '已保存' : '保存'}
</button>
</div>
</div>
@@ -101,7 +101,7 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
try {
await alertApi.createRule({
name: name.trim(),
description: `从查询页创建: ${datasourceName || i18n.t('monitor.savequerydialog.sddb4ca92')}`,
description: `从查询页创建: ${datasourceName || '默认数据源'}`,
type: 'log',
source: engine === 'sls' ? 'loki' : engine,
expression: query.trim(),
@@ -111,11 +111,11 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
notify_channels: [],
});
setSaved(true);
toast({ title: i18n.t('monitor.savequerydialog.sd3dbea92') });
toast({ title: '告警规则已创建' });
onSave?.();
setTimeout(() => { setSaved(false); onClose(); }, 800);
} catch (e: any) {
toast({ title: i18n.t('monitor.dashboard.custom.toastCreateFailed'), description: e?.message || String(e), variant: 'destructive' });
} catch (e: unknown) {
toast({ title: '创建失败', description: getErrorMessage(e), variant: 'destructive' });
} finally {
setSaving(false);
}
@@ -126,35 +126,35 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
<div className="fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={onClose} />
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
<div className="flex items-center gap-2"><BellRing className="h-4 w-4" style={{ color: '#f59e0b' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}> {i18n.t('monitor.logSaveAsAlert')} </span></div>
<div className="flex items-center gap-2"><BellRing className="h-4 w-4" style={{ color: '#f59e0b' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>另存为告警</span></div>
<button onClick={onClose} className="h-7 w-7 flex items-center justify-center" style={{ color: 'var(--dg-text-muted)' }}><X className="h-4 w-4" /></button>
</div>
<div className="p-4 space-y-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('monitor.savequerydialog.s78e82a11')} </label>
<input value={name} onChange={e => setName(e.target.value)} placeholder={i18n.t('monitor.savequerydialog.sa3a8d765')} className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>告警名称 *</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder="输入告警规则名称" className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('kubernetes.event.eventLevel')} </label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>级别</label>
<select value={severity} onChange={e => setSeverity(e.target.value)} className="w-full h-9 px-2 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}>
<option value="info"> {i18n.t('monitor.alertEvents.severity.info')} </option><option value="warning"> {i18n.t('kubernetes.event.warning')} </option><option value="critical"> {i18n.t('monitor.alertHelper.sevCritical')} </option>
<option value="info">提示</option><option value="warning">警告</option><option value="critical">严重</option>
</select>
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('monitor.alertrulestabts.s65939ccd')} </label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>触发条件</label>
<input value={condition} onChange={e => setCondition(e.target.value)} className="w-full h-9 px-2 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
</div>
</div>
<div className="p-2 rounded text-[11px]" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
<span style={{ color: meta.color }}>{meta.name}</span> · {datasourceName || i18n.t('k8s.storage.default')}<br />
<span style={{ color: meta.color }}>{meta.name}</span> · {datasourceName || '默认'}<br />
<span className="font-mono">{query}</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t" style={{ borderColor: 'var(--dg-border)' }}>
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}> {i18n.t('rbac.jobRole.cancel')} </button>
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>取消</button>
<button onClick={handleSave} disabled={saving || !name.trim() || saved} className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: '#f59e0b' }}>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? i18n.t('kubernetes.event.created') : i18n.t('monitor.savequerydialog.sbc1d9937')}
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? '已创建' : '创建告警'}
</button>
</div>
</div>
@@ -5,6 +5,7 @@ import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, AreaChart, Area, X
import { Save, Play, AlertCircle } from 'lucide-react';
import type { StatTable, ChartType, LogEngine } from './types-v3';
import { ENGINE_META } from './types-v3';
import type { JsonData } from '@/types/common';
interface Props {
statTable?: StatTable;
@@ -69,7 +70,7 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
const chartData = useMemo(() => {
if (!rows.length) return [];
return rows.map(r => {
const obj: Record<string, any> = {};
const obj: JsonData = {};
columns.forEach(c => { obj[c.name] = r[c.name]; });
return obj;
});
@@ -1,7 +1,6 @@
// UnifiedQueryPanel.tsx — 统一查询面板(接入真实 API)// Logan.Li
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { useSearchParams, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Play, Loader2, Clock, Save, ChevronDown, Bell, Link2, Bookmark, Radio } from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { TimeRangePicker } from './TimeRangePicker';
@@ -26,6 +25,7 @@ import { applyESFieldFilter, applyLokiLabelFilter, extractLokiSelector } from '.
import type { LogEngine, TimeRange, ResultTab, ViewMode, LogEntry, LogField, StatTable, LogCluster, QueryHistoryItem, SavedQuery } from './types-v3';
import { ENGINE_META, buildStatTableFromEntries, buildClustersFromEntries, extractFields, generateHistogramData, historyStorage } from './types-v3';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/utils';
// 默认最近5分钟 // Logan.Li
const DEFAULT_TIME_RANGE: TimeRange = {
@@ -51,7 +51,6 @@ const ENGINE_BADGE: Record<'loki' | 'elasticsearch', { label: string; dotColor:
interface Props { engine: LogEngine; onRuleCreated: () => void; }
export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const { t } = useTranslation(['monitor', 'common']);
const { toast } = useToast();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
@@ -76,7 +75,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const [esIndex, setEsIndex] = useState('');
// API 单次查询返回条数上限(后端限制 1000),Loki 用 limit / ES 用 size // Logan.Li
const [queryLimit, setQueryLimit] = useState(1000);
const [timeRange, setTimeRange] = useState<TimeRange>(() => ({ ...DEFAULT_TIME_RANGE, label: t('monitor:logPanel.last5min') }));
const [timeRange, setTimeRange] = useState<TimeRange>(DEFAULT_TIME_RANGE);
const [loading, setLoading] = useState(false);
const [entries, setEntries] = useState<LogEntry[]>([]);
const [statTable, setStatTable] = useState<StatTable | undefined>();
@@ -264,12 +263,12 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
setPage(1);
setHasQueried(true);
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mapped.length });
} catch (e: any) {
const msg = e?.message || String(e);
const title = msg.includes('401') || msg.includes('403') ? t('monitor:logPanel.authFailed')
: msg.includes('404') ? t('monitor:logPanel.apiNotFound')
: msg.includes('无法连接') || msg.includes('timeout') ? t('monitor:logPanel.connFailed')
: t('monitor:logPanel.queryFailed');
} catch (e: unknown) {
const msg = getErrorMessage(e);
const title = msg.includes('401') || msg.includes('403') ? '认证失败'
: msg.includes('404') ? '接口不存在'
: msg.includes('无法连接') || msg.includes('timeout') ? '连接失败'
: '查询失败';
toast({ title, description: msg, variant: 'destructive' });
setEntries([]); setTotalCount(0); setHasQueried(true);
} finally {
@@ -304,7 +303,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
setNextCursor(data?.next_cursor || '');
setSearchAfter(data?.search_after);
} catch (e: unknown) {
toast({ title: t('monitor:logPanel.loadMoreFailed'), description: (e as Error)?.message, variant: 'destructive' });
toast({ title: '加载更多失败', description: (e as Error)?.message, variant: 'destructive' });
} finally {
setLoadingMore(false);
}
@@ -344,8 +343,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const full = `${window.location.origin}${url}`;
const ok = await copyToClipboard(full);
toast({
title: ok ? t('monitor:logPanel.shareCopied') : t('monitor:logPanel.shareCopyFailed'),
description: ok ? t('monitor:logPanel.shareCopiedDesc') : undefined,
title: ok ? '分享链接已复制' : '复制失败',
description: ok ? '收件人打开链接将自动执行查询' : undefined,
variant: ok ? 'default' : 'destructive',
});
}, [location.pathname, query, dsId, esIndex, timeRange, toast]);
@@ -410,37 +409,44 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const mappingFieldNames = useMemo(() => indexedFields.map((f) => f.name), [indexedFields]);
const toggleField = useCallback((n: string) => setSelectedFields(p => p.includes(n) ? p.filter(f => f !== n) : [...p, n]), []);
const handleSaveMenu = (a: 'query' | 'alert') => { setSaveMenuOpen(false); a === 'query' ? setSaveQueryOpen(true) : setSaveAlertOpen(true); };
const handleSaveMenu = (a: 'query' | 'alert') => {
setSaveMenuOpen(false);
if (a === 'query') {
setSaveQueryOpen(true);
} else {
setSaveAlertOpen(true);
}
};
const handleChartClick = useCallback((st: any) => {
if (!st?.activeLabel) return;
const handleChartClick = useCallback((st: { activeLabel?: string }) => {
if (!st.activeLabel) return;
const idx = histogramData.findIndex(d => d.timestamp === st.activeLabel);
if (idx < 0) return;
const sIdx = Math.max(0, idx - 1), eIdx = Math.min(histogramData.length - 1, idx + 1);
setTimeRange(p => ({ ...p, start: histogramData[sIdx].timestamp, end: histogramData[eIdx].timestamp, label: t('monitor:logPanel.custom'), type: 'absolute' as const }));
setTimeRange(p => ({ ...p, start: histogramData[sIdx].timestamp, end: histogramData[eIdx].timestamp, label: '自定义', type: 'absolute' as const }));
}, [histogramData]);
const formatEntryLine = useCallback((e: LogEntry) => buildLogCopyText(e), []);
const handleExportLogs = useCallback(() => {
const logText = sortedEntries.map(formatEntryLine).join('\n');
const b = new Blob([logText], { type: 'text/plain;charset=utf-8' });
const t = sortedEntries.map(formatEntryLine).join('\n');
const b = new Blob([t], { type: 'text/plain;charset=utf-8' });
const u = URL.createObjectURL(b);
const a = document.createElement('a');
a.href = u;
a.download = `${effEngine}-logs-${Date.now()}.txt`;
a.click();
URL.revokeObjectURL(u);
toast({ title: t('monitor:logPanel.exported'), description: t('monitor:logPanel.exportedDesc', { count: sortedEntries.length }) });
toast({ title: '已导出', description: `${sortedEntries.length} 条日志` });
}, [sortedEntries, effEngine, formatEntryLine, toast]);
const handleCopyLogs = useCallback(async () => {
const logText = pagedEntries.map(formatEntryLine).join('\n');
const ok = await copyToClipboard(logText);
const text = pagedEntries.map(formatEntryLine).join('\n');
const ok = await copyToClipboard(text);
if (ok) {
toast({ title: t('monitor:logPanel.copied'), description: t('monitor:logPanel.copiedDesc', { count: pagedEntries.length }) });
toast({ title: '已复制', description: `当前页 ${pagedEntries.length} 条完整日志` });
} else {
toast({ title: t('monitor:logPanel.copyFailed'), description: t('monitor:logPanel.copyFailedDesc'), variant: 'destructive' });
toast({ title: '复制失败', description: '请使用导出或展开单条复制', variant: 'destructive' });
}
}, [pagedEntries, formatEntryLine, toast]);
@@ -464,7 +470,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
onRequestLabelValues={handleRequestLabelValues}
/>
<button onClick={() => { setLiveTail(false); void handleRun(); }} disabled={loading || liveTail || !currentSource || !query.trim()} className="h-10 px-4 rounded text-xs font-medium text-white flex items-center gap-1.5 flex-shrink-0 transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed" style={{ background: '#22c55e' }}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} {t('monitor:logPanel.query')}
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} 查询
</button>
<button
onClick={() => setLiveTail((v) => !v)}
@@ -475,7 +481,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
color: liveTail ? '#ef4444' : 'var(--dg-text-secondary)',
background: liveTail ? 'rgba(239,68,68,0.1)' : 'var(--dg-bg-tertiary)',
}}
title={t('monitor:logPanel.liveTailTitle')}
title="Live Tail — 每 3 秒刷新最近 5 分钟日志"
>
<Radio className={`h-3.5 w-3.5 ${liveTail ? 'animate-pulse' : ''}`} /> Tail
</button>
@@ -486,8 +492,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
<TimeRangePicker value={timeRange} onChange={setTimeRange} />
<select value={dsId} onChange={e => setDsId(e.target.value)} className="h-10 px-2 text-xs rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}>
{sources.length === 0 && <option value="">{t('monitor:logPanel.noDatasource', { name: meta.name })}</option>}
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? t('monitor:logPanel.defaultSuffix') : ''}</option>)}
{sources.length === 0 && <option value="">无可用{meta.name}数据源</option>}
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? ' (默认)' : ''}</option>)}
</select>
{effEngine === 'elasticsearch' && (
@@ -499,7 +505,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
)}
<div className="flex items-center gap-1.5 h-10 px-2 rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)' }}>
<span className="text-xs text-[var(--dg-text-muted)]">{t('monitor:logPanel.limit')}</span>
<span className="text-xs text-[var(--dg-text-muted)]">上限</span>
<input
type="number"
min={1}
@@ -516,24 +522,24 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
<div className="relative flex-shrink-0">
<button onClick={() => setSaveMenuOpen(!saveMenuOpen)} className="h-10 px-3 rounded text-xs flex items-center gap-1" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }}>
<Save className="h-3.5 w-3.5" /> {t('monitor:logPanel.saveAs')} <ChevronDown className="h-3 w-3" />
<Save className="h-3.5 w-3.5" /> 另存为 <ChevronDown className="h-3 w-3" />
</button>
{saveMenuOpen && (<>
<div className="fixed inset-0 z-40" onClick={() => setSaveMenuOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-50 rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<button onClick={() => handleSaveMenu('query')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)' }}><Save className="h-3.5 w-3.5" /> {t('monitor:logPanel.saveAsQueryItem')}</button>
<button onClick={() => handleSaveMenu('alert')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}><Bell className="h-3.5 w-3.5" /> {t('monitor:logPanel.saveAsAlertItem')}</button>
<button onClick={() => handleSaveMenu('query')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)' }}><Save className="h-3.5 w-3.5" /> 查询</button>
<button onClick={() => handleSaveMenu('alert')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}><Bell className="h-3.5 w-3.5" /> 告警</button>
</div>
</>)}
</div>
<button onClick={() => { void handleShareLink(); }} className="h-10 px-2.5 flex items-center gap-1 rounded flex-shrink-0 text-xs" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title={t('monitor:logPanel.shareLinkTitle')}>
<Link2 className="h-3.5 w-3.5" /> {t('monitor:logPanel.share')}
<button onClick={() => { void handleShareLink(); }} className="h-10 px-2.5 flex items-center gap-1 rounded flex-shrink-0 text-xs" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title="复制分享链接">
<Link2 className="h-3.5 w-3.5" /> 分享
</button>
<button onClick={() => setSavedPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title={t('monitor:logPanel.savedQueries')}>
<button onClick={() => setSavedPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title="已保存查询">
<Bookmark className="h-4 w-4" />
</button>
<button onClick={() => setHistoryPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title={t('monitor:logPanel.history')}>
<button onClick={() => setHistoryPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title="查询历史">
<Clock className="h-4 w-4" />
</button>
</div>
@@ -541,7 +547,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
{/* 无数据源时引导用户前往日志库配置 // Logan.Li */}
{sources.length === 0 && (
<div className="p-3 rounded border text-xs" style={{ background: 'rgba(245,158,11,0.1)', borderColor: 'rgba(245,158,11,0.3)', color: '#f59e0b' }}>
{t('monitor:logPanel.noDatasourceHint', { name: meta.name })}
暂无{meta.name}数据源,请先前往「日志库」添加数据源。
</div>
)}
@@ -576,7 +582,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" vertical={false} />
<XAxis dataKey="timestamp" tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={{ stroke: 'var(--dg-border)' }} tickLine={false} tickFormatter={formatHistogramTick} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={false} tickLine={false} width={36} tickFormatter={formatHistogramCount} />
<Tooltip contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }} formatter={(v: number) => [`${v} ${t('monitor:logPanel.chartCountSuffix')}`, t('monitor:logPanel.chartCountLabel')]} labelFormatter={(l: string) => `${t('monitor:logPanel.chartTimePrefix')} ${l}`} />
<Tooltip contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }} formatter={(v: number) => [`${v} 条日志`, '数量']} labelFormatter={(l: string) => `时间: ${l}`} />
<Area type="monotone" dataKey="count" stroke={meta.color} fill={`url(#vol-${effEngine})`} strokeWidth={1.5} dot={false} activeDot={{ r: 4, stroke: meta.color, strokeWidth: 2, fill: 'var(--dg-bg-secondary)' }} />
</AreaChart>
</ResponsiveContainer>
@@ -588,7 +594,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
<div className="rounded-lg border" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="flex items-center gap-0.5 px-2 pt-1.5" style={{ borderBottom: '1px solid var(--dg-border)' }}>
{(['logs', 'stats', 'cluster'] as ResultTab[]).map(tab => {
const labels: Record<ResultTab, string> = { logs: t('monitor:logPanel.tabLogs'), stats: t('monitor:logPanel.tabStats'), cluster: t('monitor:logPanel.tabCluster') };
const labels: Record<ResultTab, string> = { logs: '原始日志', stats: '统计图表', cluster: '日志聚类' };
return (
<button key={tab} onClick={() => setResultTab(tab)} className="px-3 py-1.5 text-[11px] font-medium rounded-t-md transition-colors"
style={{ color: resultTab === tab ? meta.color : 'var(--dg-text-muted)', background: resultTab === tab ? 'var(--dg-bg-secondary)' : 'transparent', borderBottom: resultTab === tab ? `2px solid ${meta.color}` : '2px solid transparent', marginBottom: '-1px' }}>
@@ -610,12 +616,12 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
<div className="flex rounded border overflow-hidden" style={{ borderColor: 'var(--dg-border)' }}>
{(['raw', 'field', 'table'] as const).map(m => (
<button key={m} onClick={() => setViewMode(m)} className="px-3 py-1 text-[11px] transition-colors" style={{ background: viewMode === m ? meta.color : 'var(--dg-bg-tertiary)', color: viewMode === m ? '#fff' : 'var(--dg-text-muted)' }}>
{m === 'raw' ? t('monitor:logPanel.viewRaw') : m === 'field' ? t('monitor:logPanel.viewField') : t('monitor:logPanel.viewTable')}
{m === 'raw' ? '原始日志' : m === 'field' ? '字段视图' : '表格视图'}
</button>
))}
</div>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor:logPanel.truncateTitle')}><input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor:logPanel.truncate')}</label>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor:logPanel.wrapLinesTitle')}><input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor:logPanel.wrapLines')}</label>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title="开启后折叠行仅显示前 500 字预览"><input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> 截断预览</label>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title="长日志自动换行显示"><input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> 换行</label>
</div>
</>)}
{resultTab === 'stats' && <StatChartPanel statTable={statTable} engine={effEngine as LogEngine} query={query} onSaveAsAlert={() => setSaveAlertOpen(true)} onSaveAsQuery={() => setSaveQueryOpen(true)} />}
@@ -627,7 +633,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
{/* 空状态:从未查询 vs 查询返回 0 结果 */}
{!loading && entries.length === 0 && !hasQueried && (
<div className="border rounded-lg flex flex-col items-center justify-center gap-2" style={{ borderColor: 'var(--dg-border)', minHeight: '300px' }}>
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor:logPanel.emptyHint')}</span>
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>输入查询语句并点击「查询」查看结果</span>
<span className="text-xs font-mono px-3 py-1 rounded" style={{ color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}>{meta.queryPlaceholder}</span>
</div>
)}
@@ -639,13 +645,13 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
</svg>
</div>
<div className="text-center">
<p className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor:logPanel.noMatch')}</p>
<p className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>未找到匹配的日志记录</p>
<p className="text-xs mt-1" style={{ color: 'var(--dg-text-muted)' }}>
{t('monitor:logPanel.noMatchDesc', { ms: queryTime })}
查询已执行({queryTime}ms),但当前时间范围和筛选条件下无匹配结果
</p>
</div>
<button onClick={handleRun} className="px-4 py-1.5 rounded text-xs font-medium text-white transition-opacity hover:opacity-90" style={{ background: '#22c55e' }}>
<Play className="h-3.5 w-3.5 inline mr-1" />{t('monitor:logPanel.requeries')}
<Play className="h-3.5 w-3.5 inline mr-1" />重新查询
</button>
</div>
)}
@@ -77,7 +77,7 @@ export function LogRoleCreateDialog({
: t('rbac.logRole.patternHint.labelRegex', { field: matchField });
}
try {
// eslint-disable-next-line no-new
new RegExp(pattern);
return preview.length > 0
? t('rbac.logRole.patternHint.matched', { count: preview.length })
@@ -55,7 +55,7 @@ export function buildLokiSelector(labels: Record<string, string>): string {
}
export function mergeLokiSelector(query: string, labels: Record<string, string>): string {
let q = query.trim();
const q = query.trim();
const selector = buildLokiSelector(labels);
if (LOKI_SELECTOR_RE.test(q)) {
return q.replace(LOKI_SELECTOR_RE, selector);
@@ -6,7 +6,7 @@ export function highlightSyntax(query: string, engine: 'loki' | 'elasticsearch')
const span = (color: string, text: string) => `<span style="color:${color}">${text}</span>`;
if (engine === 'loki') {
return esc.replace(
/("(?:[^"\\]|\\.)*")|([a-zA-Z_][\w-]*)(?==)|(\|json|\|regexp|\|line_format|\|unwrap|\|pattern|\|label_format)|\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b|([{}])|(\|=|\!=|=~|!~|=)/g,
/("(?:[^"\\]|\\.)*")|([a-zA-Z_][\w-]*)(?==)|(\|json|\|regexp|\|line_format|\|unwrap|\|pattern|\|label_format)|\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b|([{}])|(\|=|!=|=~|!~|=)/g,
(m, str, ident, pipe, fn, brace, op) => {
if (str) return span('#22c55e', str);
if (ident) return span('#f59e0b', ident);
@@ -37,4 +37,4 @@ export const ES_KEYWORD_SUGGESTIONS = ['AND ', 'OR ', 'NOT '];
export const COMMON_ES_FIELDS = [
'message', 'level', 'log.level', 'app', 'service', 'host.name', '@timestamp',
'kubernetes.namespace', 'trace_id', 'status', 'method', 'path',
];
];
@@ -1,5 +1,6 @@
import i18n from '@/i18n';
// types-v3.ts — v3 统一日志中心类型定义 // Logan.Li
import type { JsonData } from '@/types/common';
export type LogEngine = 'loki' | 'elasticsearch' | 'sls';
export type ViewMode = 'raw' | 'field' | 'table';
@@ -33,7 +34,7 @@ export interface LogField {
// 统计表格 // Logan.Li
export interface StatTable {
columns: StatColumn[];
rows: Record<string, any>[];
rows: JsonData[];
}
export interface StatColumn {
@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
@@ -20,39 +20,37 @@ import {
ChevronRight,
ChevronLeft,
Server,
RefreshCw,
Check,
MapPin,
Search,
AlertCircle,
RefreshCw, Check, MapPin, Search, AlertCircle,
} from 'lucide-react';
import { cloudApi } from '@/api';
import i18n from '@/i18n';
import { cloudApi, credentialApi } from '@/api';
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
import { ImportCredentialSettings } from './ImportCredentialSettings';
import type { Credential } from '@/components/deploy/credential-manager/types';
interface CloudServerImportProps {
open: boolean;
onClose: () => void;
/** 预选云账号,跳过厂商/账号选择步骤 // Logan.Li */
initialAccountId?: number | string;
}
const cloudProviders = [
{ id: 'aws', name: 'AWS', color: 'bg-orange-500', icon: 'AWS' },
{ id: 'aliyun', name: i18n.t('cloud.aliyun'), color: 'bg-orange-600', icon: 'AL' },
{ id: 'tencent', name: i18n.t('cloud.tencent'), color: 'bg-blue-500', icon: 'TX' },
{ id: 'huawei', name: i18n.t('cloud.huawei'), color: 'bg-red-500', icon: 'HW' },
{ id: 'azure', name: 'Azure', color: 'bg-blue-600', icon: 'AZ' },
{ id: 'aws', name: 'AWS' },
{ id: 'aliyun', name: '阿里云' },
{ id: 'tencent', name: '腾讯云' },
{ id: 'huawei', name: '华为云' },
{ id: 'azure', name: 'Azure' },
];
export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
export function CloudServerImport({ open, onClose, initialAccountId }: CloudServerImportProps) {
const [cloudAccounts, setCloudAccounts] = useState<any[]>([]);
const [step, setStep] = useState(1);
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [selectedAccount, setSelectedAccount] = useState<any>(null);
// 区域相关状态
const [regions, setRegions] = useState<any[]>([]);
const [selectedRegions, setSelectedRegions] = useState<string[]>([]);
const [regionSearch, setRegionSearch] = useState('');
const [isSyncingRegions, setIsSyncingRegions] = useState(false);
// 主机相关状态
const [cloudServers, setCloudServers] = useState<any[]>([]);
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
@@ -60,28 +58,53 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState<{ imported: number; skipped: number } | null>(null);
const [defaultSshPort, setDefaultSshPort] = useState('22');
const [credentials, setCredentials] = useState<any[]>([]);
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('');
const [sshCredentials, setSshCredentials] = useState<Credential[]>([]);
const [rdpCredentials, setRdpCredentials] = useState<Credential[]>([]);
const [selectedSshCredentialId, setSelectedSshCredentialId] = useState<string>('');
const [selectedRdpCredentialId, setSelectedRdpCredentialId] = useState<string>('');
const preselectedRef = useRef<string | null>(null);
const loadAccounts = async () => {
try {
const res: Record<string, any> = await cloudApi.getAccounts();
setCloudAccounts(res?.list || []);
} catch (e) {
console.error(i18n.t('server.cloudserverimpo.sddeee730'), e);
console.error('加载云账号失败:', e);
}
};
useEffect(() => {
if (open) {
loadAccounts();
import('@/api').then(({ credentialApi }) => {
credentialApi.list().then(( res: any) => setCredentials(res?.list || []))
.catch(() => setCredentials([]));
if (!open) return;
loadAccounts();
Promise.all([
credentialApi.list({ secret_type: 'ssh', page_size: 100 }),
credentialApi.list({ secret_type: 'rdp', page_size: 100 }),
])
.then(([sshResp, rdpResp]) => {
setSshCredentials((sshResp?.list ?? []) as Credential[]);
setRdpCredentials((rdpResp?.list ?? []) as Credential[]);
})
.catch(() => {
setSshCredentials([]);
setRdpCredentials([]);
});
}
}, [open]);
// 导入凭证设置:Linux 默认 SSH,Windows 默认 RDP // Logan.Li
const importSettingsPanel = (step >= 3 && !importResult) ? (
<ImportCredentialSettings
compact
defaultSshPort={defaultSshPort}
onDefaultSshPortChange={setDefaultSshPort}
sshCredentials={sshCredentials}
rdpCredentials={rdpCredentials}
selectedSshCredentialId={selectedSshCredentialId}
selectedRdpCredentialId={selectedRdpCredentialId}
onSelectedSshCredentialIdChange={setSelectedSshCredentialId}
onSelectedRdpCredentialIdChange={setSelectedRdpCredentialId}
/>
) : null;
const filteredAccounts = cloudAccounts.filter(
acc => acc.provider === selectedProvider && acc.status === 'active'
);
@@ -103,7 +126,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
const list = await loadRegions(accountId);
setRegions(list);
} catch (e) {
console.error(i18n.t('server.cloudserverimpo.s1d65993d'), e);
console.error('同步区域失败:', e);
throw e;
} finally {
setIsSyncingRegions(false);
@@ -126,6 +149,39 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
}
}, [selectedAccount, loadRegions, syncRegions]);
// 预选账号时直接进入区域选择步骤 // Logan.Li
useEffect(() => {
if (!open) {
preselectedRef.current = null;
return;
}
if (!initialAccountId || cloudAccounts.length === 0) return;
if (preselectedRef.current === String(initialAccountId)) return;
const account = cloudAccounts.find((a) => String(a.id) === String(initialAccountId));
if (!account) return;
preselectedRef.current = String(initialAccountId);
setSelectedProvider(account.provider);
setSelectedAccount(account);
setStep(3);
setRegions([]);
setSelectedRegions([]);
setCloudServers([]);
setSelectedServers([]);
setDiscoverErrors([]);
setImportResult(null);
(async () => {
const local = await loadRegions(account.id);
setRegions(local);
if (local.length === 0) {
try {
await syncRegions(account.id);
} catch {
// 同步失败不阻塞,用户可手动重试
}
}
})();
}, [open, initialAccountId, cloudAccounts, loadRegions, syncRegions]);
// ==================== 主机发现逻辑 ====================
const discoverInstances = useCallback(async () => {
if (!selectedAccount || selectedRegions.length === 0) return;
@@ -138,8 +194,8 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
setCloudServers(res?.list || []);
setDiscoverErrors(res?.errors || []);
} catch (e) {
console.error(i18n.t('server.cloudserverimpo.s5e749a34'), e);
setDiscoverErrors([e?.message || i18n.t('server.cloudserverimpo.s7d43dcbe')]);
console.error('发现主机失败:', e);
setDiscoverErrors([e?.message || '发现主机失败']);
} finally {
setIsDiscovering(false);
}
@@ -153,11 +209,12 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
const instancesToImport = cloudServers.filter(s => selectedServers.includes(s.instance_id));
const res: Record<string, any> = await cloudApi.importInstances(selectedAccount.id, instancesToImport, {
default_port: Number(defaultSshPort) || 22,
credential_id: selectedCredentialId ? Number(selectedCredentialId) : undefined,
ssh_credential_id: selectedSshCredentialId ? Number(selectedSshCredentialId) : undefined,
rdp_credential_id: selectedRdpCredentialId ? Number(selectedRdpCredentialId) : undefined,
});
setImportResult((res as any) || { imported: 0, skipped: 0 });
} catch (e) {
console.error(i18n.t('server.cloudserverimpo.s1b122554'), e);
console.error('导入失败:', e);
} finally {
setIsImporting(false);
}
@@ -214,7 +271,8 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
setDiscoverErrors([]);
setImportResult(null);
setDefaultSshPort('22');
setSelectedCredentialId('');
setSelectedSshCredentialId('');
setSelectedRdpCredentialId('');
};
const filteredRegions = regions.filter(r =>
@@ -224,18 +282,18 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
);
const steps = [
{ id: 1, title: i18n.t('server.cloudserverimpo.s7ef31abb'), icon: Cloud },
{ id: 2, title: i18n.t('server.cloudserverimpo.s02badc25'), icon: Key },
{ id: 3, title: i18n.t('server.cloudserverimpo.s97d03d46'), icon: MapPin },
{ id: 4, title: i18n.t('server.cloudserverimpo.s81eea51a'), icon: CheckCircle },
{ id: 1, title: '云厂商', icon: Cloud },
{ id: 2, title: '云账号', icon: Key },
{ id: 3, title: '选择区域', icon: MapPin },
{ id: 4, title: '导入确认', icon: CheckCircle },
];
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader>
<DialogTitle className="text-xl"> {i18n.t('server.cloudserverimpo.sa558f8a8')} </DialogTitle>
<DialogDescription className="sr-only"> {i18n.t('server.cloudserverimpo.s86968159')} </DialogDescription>
<DialogTitle className="text-xl">导入云主机</DialogTitle>
<DialogDescription>从云账号批量导入主机资产,可选关联 Linux SSH 和 Windows RDP 凭证</DialogDescription>
</DialogHeader>
{/* Step Indicator */}
@@ -263,7 +321,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
{/* Step 1: Select Cloud Provider */}
{step === 1 && (
<div className="space-y-4">
<p className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('server.cloudserverimpo.s7c366475')} </p>
<p className="text-[var(--dg-text-secondary)] text-sm">请选择要导入云主机的云厂商</p>
<div className="grid grid-cols-3 gap-4">
{cloudProviders.map((provider) => (
<Card
@@ -276,8 +334,11 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
onClick={() => setSelectedProvider(provider.id)}
>
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${provider.color} flex items-center justify-center text-white font-bold text-sm`}>
{provider.icon}
<div
className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center shrink-0"
style={{ borderBottom: `2px solid ${getProviderColor(provider.id)}` }}
>
<CloudBrandIcon provider={provider.id} className="h-6 w-6" />
</div>
<span className="font-medium text-[var(--dg-text-primary)]">{provider.name}</span>
</CardContent>
@@ -290,7 +351,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
{/* Step 2: Select Account */}
{step === 2 && (
<div className="space-y-4">
<p className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('server.cloudserverimpo.se1a7870b')} </p>
<p className="text-[var(--dg-text-secondary)] text-sm">请选择云账号(AccessKey)</p>
{filteredAccounts.length > 0 ? (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{filteredAccounts.map((account) => (
@@ -305,7 +366,12 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
>
<CardContent className="p-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Key className="h-5 w-5 dg-text-blue" />
<div
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center shrink-0"
style={{ borderBottom: `2px solid ${getProviderColor(account.provider)}` }}
>
<CloudBrandIcon provider={account.provider} className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-[var(--dg-text-primary)]">{account.name}</p>
<p className="text-xs text-[var(--dg-text-muted)]">
@@ -326,7 +392,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
) : (
<div className="text-center py-8 text-[var(--dg-text-muted)]">
<Key className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p> {i18n.t('server.cloudserverimpo.saa5f0df7')} </p>
<p>该云厂商暂无有效的云账号</p>
</div>
)}
</div>
@@ -350,19 +416,19 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
disabled={isSyncingRegions}
>
<RefreshCw className={`h-3 w-3 mr-1 ${isSyncingRegions ? 'animate-spin' : ''}`} />
{isSyncingRegions ? i18n.t('server.cloudserverimpo.sf787f452') : i18n.t('server.cloudserverimpo.saa44e4f6')}
{isSyncingRegions ? '同步中...' : '刷新区域'}
</Button>
</div>
{isSyncingRegions ? (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin dg-text-blue" />
<p className="text-[var(--dg-text-secondary)]"> {i18n.t('server.cloudserverimpo.s61df99e0')} </p>
<p className="text-[var(--dg-text-secondary)]">正在从云 API 同步区域列表...</p>
</div>
) : regions.length === 0 ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">
<MapPin className="h-10 w-10 mx-auto mb-3 opacity-50" />
<p> {i18n.t('cloud.noRegionData')} </p>
<p>暂无区域数据</p>
<Button
variant="outline"
size="sm"
@@ -380,14 +446,14 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
<div className="relative flex-1">
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)]" />
<Input
placeholder={i18n.t('cloud.searchRegion')}
placeholder="搜索区域名称或编码..."
value={regionSearch}
onChange={(e) => setRegionSearch(e.target.value)}
className="pl-9 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
/>
</div>
<Button variant="outline" size="sm" onClick={handleSelectAllRegions} className="border-[var(--dg-border)]">
{selectedRegions.length === regions.length && regions.length > 0 ? i18n.t('approval.editor.deselectAll') : i18n.t('system.selectAll')}
{selectedRegions.length === regions.length && regions.length > 0 ? '取消全选' : '全选'}
</Button>
</div>
@@ -402,8 +468,8 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
onCheckedChange={handleSelectAllRegions}
/>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]"> {i18n.t('cloud.regionCode')} </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]"> {i18n.t('cloud.regionName')} </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域编码</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域名称</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">Endpoint</th>
</tr>
</thead>
@@ -433,6 +499,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
</div>
</>
)}
{importSettingsPanel}
</div>
)}
@@ -443,7 +510,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
/* 导入结果 */
<div className="text-center py-12">
<CheckCircle className="h-12 w-12 mx-auto mb-3 dg-text-green" />
<p className="text-lg font-medium text-[var(--dg-text-primary)]"> {i18n.t('common.importComplete')} </p>
<p className="text-lg font-medium text-[var(--dg-text-primary)]">导入完成</p>
<div className="flex items-center justify-center gap-6 mt-4">
<Badge className="bg-emerald-500/20 dg-text-green border-0 text-base px-4 py-1">
成功导入 {importResult.imported} 台
@@ -468,7 +535,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
{cloudServers.length > 0 && (
<>
<Button variant="outline" size="sm" onClick={handleSelectAllServers} className="border-[var(--dg-border)]">
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? i18n.t('approval.editor.deselectAll') : i18n.t('system.selectAll')}
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? '取消全选' : '全选'}
</Button>
<Badge className="bg-blue-500/20 dg-text-blue border-0">
已选 {selectedServers.length} 台
@@ -483,7 +550,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
className="border-[var(--dg-border)] dg-text-blue"
>
<RefreshCw className={`h-3 w-3 mr-1 ${isDiscovering ? 'animate-spin' : ''}`} />
{isDiscovering ? i18n.t('server.cloudserverimpo.s5bc3059d') : cloudServers.length > 0 ? i18n.t('server.cloudserverimpo.s569d92b0') : i18n.t('server.cloudserverimpo.sc0b92674')}
{isDiscovering ? '发现中...' : cloudServers.length > 0 ? '重新发现' : '发现主机'}
</Button>
</div>
</div>
@@ -506,7 +573,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 dg-text-yellow text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium"> {i18n.t('server.cloudserverimpo.s6ce90825')} </p>
<p className="font-medium">部分区域发现失败:</p>
{discoverErrors.map((err, i) => (
<p key={i} className="text-xs mt-1 opacity-80">{err}</p>
))}
@@ -518,13 +585,13 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
{isDiscovering ? (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin dg-text-blue" />
<p className="text-[var(--dg-text-secondary)]"> {i18n.t('server.cloudserverimpo.s2401d988')} </p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1"> {i18n.t('server.cloudserverimpo.sbd41e8f6')} </p>
<p className="text-[var(--dg-text-secondary)]">正在发现云主机,请稍候...</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">正在逐区域查询,可能需要数秒</p>
</div>
) : cloudServers.length === 0 && !isDiscovering ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">
<Server className="h-10 w-10 mx-auto mb-3 opacity-50" />
<p> {i18n.t('server.cloudserverimpo.s85f8bc02')} </p>
<p>点击"发现主机"按钮,开始扫描所选区域</p>
</div>
) : (
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[300px] overflow-y-auto">
@@ -537,7 +604,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
onCheckedChange={handleSelectAllServers}
/>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.workload.hostname')} </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
</tr>
</thead>
@@ -562,42 +629,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
</div>
)}
{/* SSH 端口 + 凭证关联 */}
{cloudServers.length > 0 && selectedServers.length > 0 && (
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">
SSH 端口
<span className="text-[var(--dg-text-muted)] ml-1">(导入时统一设置)</span>
</label>
<Input
type="number"
placeholder="22"
value={defaultSshPort}
onChange={(e) => setDefaultSshPort(e.target.value)}
className="w-32 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">
关联凭证
<span className="text-[var(--dg-text-muted)] ml-1">(可选,用于SSH远程管理)</span>
</label>
<select
value={selectedCredentialId}
onChange={(e) => setSelectedCredentialId(e.target.value)}
className="w-full px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-xs"
>
<option value=""> {i18n.t('server.cloudserverimpo.s45bf4d4b')} </option>
{credentials.map(( c: any) => (
<option key={c.id} value={String(c.id)}>
{c.type === 'ssh_key' ? '🔑' : '🔒'} {c.name}
</option>
))}
</select>
</div>
</div>
)}
{importSettingsPanel}
</>
)}
</div>
@@ -25,7 +25,8 @@ import {
} from 'lucide-react';
import { regionApi, serverApi, credentialApi } from '@/api';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
import { ImportCredentialSettings } from './ImportCredentialSettings';
import type { Credential } from '@/components/deploy/credential-manager/types';
interface RegionOption {
id: string;
@@ -39,7 +40,7 @@ interface ExcelServerImportProps {
}
type PreviewRow = {
row: number; hostname: string; ip: string; port: number;
row: number; hostname: string; ip: string; osType: string; protocol: string; port: number; rdpPort: number;
username: string; os: string; cpu: number; memory: number; disk: number; tags: string;
status: string; message: string;
};
@@ -91,13 +92,13 @@ function isValidIP(ip: string): boolean {
return /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) && ip.split('.').every((o) => Number(o) >= 0 && Number(o) <= 255);
}
// 下载 CSV 导入模板 — 主机名+IP+端口(默认22),账号root,Agent 安装后自动补全 // Logan.Li
// 下载 CSV 导入模板,Windows 行可填写 os_type=windows 自动匹配 RDP 凭证 // Logan.Li
function downloadTemplate() {
const csv = [
'hostname,ip,port',
'web-01,192.168.1.10,22',
'db-01,192.168.1.20,22',
'cache-01,192.168.1.30,2222',
'hostname,ip,os_type,protocol,port,rdp_port,username',
'web-01,192.168.1.10,linux,ssh,22,,root',
'db-01,192.168.1.20,linux,ssh,22,,root',
'win-01,192.168.1.30,windows,rdp,,3389,Administrator',
].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
@@ -118,8 +119,10 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const [regions, setRegions] = useState<RegionOption[]>([]);
const [isImporting, setIsImporting] = useState(false);
// 凭据选择 // Logan.Li
const [credentials, setCredentials] = useState<JsonData[]>([]);
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('');
const [sshCredentials, setSshCredentials] = useState<Credential[]>([]);
const [rdpCredentials, setRdpCredentials] = useState<Credential[]>([]);
const [selectedSshCredentialId, setSelectedSshCredentialId] = useState<string>('');
const [selectedRdpCredentialId, setSelectedRdpCredentialId] = useState<string>('');
const [defaultSshPort, setDefaultSshPort] = useState('22');
// 对话框打开时拉取区域列表 + 凭据列表 // Logan.Li
@@ -128,9 +131,10 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
let cancelled = false;
(async () => {
try {
const [regionResp, credResp] = await Promise.all([
const [regionResp, sshCredResp, rdpCredResp] = await Promise.all([
regionApi.list(),
credentialApi.list(),
credentialApi.list({ secret_type: 'ssh', page_size: 100 }),
credentialApi.list({ secret_type: 'rdp', page_size: 100 }),
]);
if (cancelled) return;
const list = regionResp?.list ?? [];
@@ -139,11 +143,13 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
name: r.name ?? '',
code: r.code ?? '',
})));
setCredentials(credResp?.list ?? []);
setSshCredentials((sshCredResp?.list ?? []) as Credential[]);
setRdpCredentials((rdpCredResp?.list ?? []) as Credential[]);
} catch {
if (!cancelled) {
setRegions([]);
setCredentials([]);
setSshCredentials([]);
setRdpCredentials([]);
}
}
})();
@@ -157,7 +163,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const text = await selectedFile.text();
const rows = parseCSV(text);
if (rows.length === 0) {
toast.error(i18n.t('server.excelserverimpo.sdee9fcbf'));
toast.error('文件无有效数据,请使用模板格式(首行为表头)');
setIsUploading(false);
return;
}
@@ -165,26 +171,31 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const preview: PreviewRow[] = rows.map((r, idx) => {
const hostname = (r.hostname ?? '').trim();
const ip = (r.ip ?? '').trim();
const osType = (r.os_type ?? '').trim().toLowerCase() || ((r.os ?? '').toLowerCase().includes('windows') ? 'windows' : 'linux');
const protocol = (r.protocol ?? '').trim().toLowerCase() || (osType === 'windows' ? 'rdp' : 'ssh');
const row: PreviewRow = {
row: idx + 2,
hostname,
ip,
osType,
protocol,
port: Number(r.port) || 22,
username: (r.username ?? '').trim() || 'root',
rdpPort: Number(r.rdp_port) || 3389,
username: (r.username ?? '').trim() || (protocol === 'rdp' ? 'Administrator' : 'root'),
os: r.os ?? '',
cpu: Number(r.cpu) || 0,
memory: Number(r.memory) || 0,
disk: Number(r.disk) || 0,
tags: r.tags ?? '',
status: 'valid',
message: i18n.t('common.valid'),
message: '有效',
};
const errs: string[] = [];
if (!hostname) errs.push(i18n.t('server.excelserverimpo.sd274584b'));
if (!ip) errs.push(i18n.t('server.excelserverimpo.s7641b315'));
else if (!isValidIP(ip)) errs.push(i18n.t('server.excelserverimpo.s968c09ff'));
if (!hostname) errs.push('主机名缺失');
if (!ip) errs.push('IP缺失');
else if (!isValidIP(ip)) errs.push('IP格式错误');
const key = `${hostname}|${ip}`;
if (hostname && ip && seen.has(key)) errs.push(i18n.t('server.excelserverimpo.sa884370d'));
if (hostname && ip && seen.has(key)) errs.push('重复行');
else seen.add(key);
if (errs.length > 0) {
row.status = 'error';
@@ -195,7 +206,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
setPreviewData(preview);
setStep(step + 1);
} catch {
toast.error(i18n.t('server.excelserverimpo.s691054e3'));
toast.error('文件解析失败,请确认是 CSV 格式');
} finally {
setIsUploading(false);
}
@@ -220,7 +231,8 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
setStep(1);
setSelectedRegion('');
setDefaultSshPort('22');
setSelectedCredentialId('');
setSelectedSshCredentialId('');
setSelectedRdpCredentialId('');
setSelectedFile(null);
setPreviewData([]);
};
@@ -233,7 +245,10 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const servers = valid.map((d) => ({
hostname: d.hostname,
ip: d.ip,
os_type: d.osType,
protocol: d.protocol,
port: d.port,
rdp_port: d.rdpPort,
username: d.username,
os: d.os,
cpu: d.cpu,
@@ -244,7 +259,8 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const resp: Record<string, unknown> = await serverApi.batchCreate({
servers,
region_id: selectedRegion || undefined,
credential_id: selectedCredentialId ? Number(selectedCredentialId) : undefined,
ssh_credential_id: selectedSshCredentialId ? Number(selectedSshCredentialId) : undefined,
rdp_credential_id: selectedRdpCredentialId ? Number(selectedRdpCredentialId) : undefined,
default_port: Number(defaultSshPort) || 22,
});
const r = resp ?? {};
@@ -255,7 +271,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
resetState();
onClose();
} catch (e) {
toast.error(e?.message || i18n.t('server.excelserverimpo.se951a3c7'));
toast.error(e?.message || '导入失败,请重试');
} finally {
setIsImporting(false);
}
@@ -271,9 +287,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const errorCount = previewData.filter(d => d.status === 'error').length;
const steps = [
{ id: 1, title: i18n.t('deploy.detailDialog.config') },
{ id: 2, title: i18n.t('common.upload2') },
{ id: 3, title: i18n.t('monitor.alertEvents.acknowledge') },
{ id: 1, title: '配置' },
{ id: 2, title: '上传' },
{ id: 3, title: '确认' },
];
return (
@@ -281,7 +297,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader>
<DialogTitle className="text-xl">CSV 批量导入服务器</DialogTitle>
<DialogDescription className="sr-only"> {i18n.t('server.excelserverimpo.s58c25c4b')} </DialogDescription>
<DialogDescription className="sr-only">通过 CSV 文件批量导入服务器资产</DialogDescription>
</DialogHeader>
{/* Step Indicator */}
@@ -319,8 +335,8 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<FileSpreadsheet className="h-5 w-5 dg-text-green" />
</div>
<div>
<p className="font-medium text-[var(--dg-text-primary)]"> {i18n.t('server.excelserverimpo.s95e484bd')} </p>
<p className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('server.excelserverimpo.s21609324')} </p>
<p className="font-medium text-[var(--dg-text-primary)]">主机导入模板</p>
<p className="text-xs text-[var(--dg-text-muted)]">下载 CSV 模板,Linux 默认 SSH,Windows 默认 RDP</p>
</div>
</div>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={downloadTemplate}>
@@ -333,13 +349,13 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
{/* Region Selection */}
<div>
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2"> {i18n.t('server.cloudserverimpo.s97d03d46')} </label>
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">选择区域</label>
<select
value={selectedRegion}
onChange={(e) => setSelectedRegion(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
>
<option value=""> {i18n.t('server.excelserverimpo.sf2648916')} </option>
<option value="">请选择区域</option>
{regions.map((region) => (
<option key={region.id} value={region.id}>
{region.name} ({region.code})
@@ -348,46 +364,16 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
</select>
</div>
{/* 凭据选择(SSH密钥/密码) — 选中后自动关联到导入的每台服务器 // Logan.Li */}
<div>
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">
选择凭据
<span className="text-xs text-[var(--dg-text-muted)] ml-1">(可选,用于SSH连接)</span>
</label>
{credentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">
暂无凭据,先去「凭证配置」添加 SSH 密钥或密码
</p>
) : (
<select
value={selectedCredentialId}
onChange={(e) => setSelectedCredentialId(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
>
<option value=""> {i18n.t('server.excelserverimpo.s9d5df376')} </option>
{credentials.map((c: JsonData) => (
<option key={c.id} value={String(c.id)}>
{c.type === 'ssh_key' ? '🔑' : c.type === 'api_token' ? '🌐' : '🔒'} {c.name} ({c.type === 'ssh_key' ? i18n.t('server.excelserverimpo.s87941dca') : c.type === 'api_token' ? 'API Token' : i18n.t('system.passwordLabel')})
</option>
))}
</select>
)}
</div>
{/* SSH 端口配置 */}
<div>
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">
SSH 端口
<span className="text-xs text-[var(--dg-text-muted)] ml-1">(导入时统一设置,可在服务器管理页单独修改)</span>
</label>
<Input
type="number"
placeholder="22"
value={defaultSshPort}
onChange={(e) => setDefaultSshPort(e.target.value)}
className="w-32 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<ImportCredentialSettings
defaultSshPort={defaultSshPort}
onDefaultSshPortChange={setDefaultSshPort}
sshCredentials={sshCredentials}
rdpCredentials={rdpCredentials}
selectedSshCredentialId={selectedSshCredentialId}
selectedRdpCredentialId={selectedRdpCredentialId}
onSelectedSshCredentialIdChange={setSelectedSshCredentialId}
onSelectedRdpCredentialIdChange={setSelectedRdpCredentialId}
/>
</div>
)}
@@ -398,8 +384,8 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
className="border-2 border-dashed border-[var(--dg-border)] rounded-lg p-8 text-center hover:border-blue-500/50 transition-colors"
>
<Upload className="h-12 w-12 mx-auto mb-4 text-[var(--dg-text-muted)]" />
<p className="text-[var(--dg-text-secondary)] mb-2"> {i18n.t('server.excelserverimpo.sbab467bd')} </p>
<p className="text-xs text-[var(--dg-text-muted)] mb-4"> {i18n.t('server.excelserverimpo.s7bb06035')} </p>
<p className="text-[var(--dg-text-secondary)] mb-2">点击或拖拽文件到此处上传</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-4">支持 CSV 格式文件(可点击左侧"下载模板"获取)</p>
<Input
type="file"
accept=".csv,text/csv"
@@ -409,7 +395,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
/>
<label htmlFor="excel-upload">
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" asChild>
<span> {i18n.t('server.excelserverimpo.sfd7e0c99')} </span>
<span>选择文件</span>
</Button>
</label>
</div>
@@ -439,7 +425,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
{isUploading && (
<div className="text-center py-4">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<p className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('server.excelserverimpo.s8c0c7b6b')} </p>
<p className="text-[var(--dg-text-secondary)] text-sm">正在解析文件...</p>
</div>
)}
</div>
@@ -449,7 +435,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
{step === 3 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('server.excelserverimpo.s3bc470c6')} </p>
<p className="text-[var(--dg-text-secondary)] text-sm">数据预览</p>
<div className="flex items-center gap-2">
<Badge className="bg-emerald-500/20 dg-text-green border-0">
<Check className="h-3 w-3 mr-1" />
@@ -474,10 +460,11 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<table className="w-full">
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]"> {i18n.t('monitor.logPanel.logResults.lineNo')} </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.workload.hostname')} </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">行号</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]"> {i18n.t('system.terminalStatus')} </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">类型</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">状态</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
@@ -486,6 +473,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<td className="px-3 py-2 text-sm text-[var(--dg-text-muted)]">{row.row}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{row.hostname || '-'}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.ip}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.osType === 'windows' ? 'Windows / RDP' : 'Linux / SSH'}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
{row.status === 'valid' && (
@@ -559,7 +547,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
) : (
<Check className="h-4 w-4 mr-1" />
)}
{isImporting ? i18n.t('system.importingDot') : `确认导入 (${validCount})`}
{isImporting ? '导入中...' : `确认导入 (${validCount})`}
</Button>
)}
</div>
@@ -0,0 +1,100 @@
import { Input } from '@/components/ui/input';
import type { Credential } from '@/components/deploy/credential-manager/types';
interface ImportCredentialSettingsProps {
defaultSshPort: string;
onDefaultSshPortChange: (value: string) => void;
sshCredentials: Credential[];
rdpCredentials: Credential[];
selectedSshCredentialId: string;
selectedRdpCredentialId: string;
onSelectedSshCredentialIdChange: (value: string) => void;
onSelectedRdpCredentialIdChange: (value: string) => void;
compact?: boolean;
}
function credentialLabel(cred: Credential) {
return `${cred.name}${cred.summary ? ` · ${cred.summary}` : ''}`;
}
export function ImportCredentialSettings({
defaultSshPort,
onDefaultSshPortChange,
sshCredentials,
rdpCredentials,
selectedSshCredentialId,
selectedRdpCredentialId,
onSelectedSshCredentialIdChange,
onSelectedRdpCredentialIdChange,
compact = false,
}: ImportCredentialSettingsProps) {
const labelClass = compact
? 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1'
: 'block text-sm font-medium text-[var(--dg-text-secondary)] mb-2';
const panelClass = compact
? 'grid grid-cols-1 sm:grid-cols-3 gap-4 p-4 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]'
: 'grid grid-cols-1 md:grid-cols-3 gap-4';
const inputBg = compact ? 'bg-[var(--dg-bg-tertiary)]' : 'bg-[var(--dg-bg-secondary)]';
return (
<div className={panelClass}>
<div>
<label className={labelClass}>
SSH 端口
<span className="text-[var(--dg-text-muted)] ml-1">(Linux 默认)</span>
</label>
<Input
type="number"
placeholder="22"
value={defaultSshPort}
onChange={(e) => onDefaultSshPortChange(e.target.value)}
className={`w-32 ${inputBg} border-[var(--dg-border)] text-[var(--dg-text-primary)]`}
/>
</div>
<div>
<label className={labelClass}>
Linux SSH 凭证
<span className="text-[var(--dg-text-muted)] ml-1">(可选)</span>
</label>
{sshCredentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">暂无 SSH 凭证</p>
) : (
<select
value={selectedSshCredentialId}
onChange={(e) => onSelectedSshCredentialIdChange(e.target.value)}
className={`w-full px-3 py-2 ${inputBg} border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm`}
>
<option value="">不使用凭证</option>
{sshCredentials.map((cred) => (
<option key={cred.id} value={String(cred.id)}>
{credentialLabel(cred)}
</option>
))}
</select>
)}
</div>
<div>
<label className={labelClass}>
Windows RDP 密码
<span className="text-[var(--dg-text-muted)] ml-1">(可选)</span>
</label>
{rdpCredentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">暂无 RDP 凭证</p>
) : (
<select
value={selectedRdpCredentialId}
onChange={(e) => onSelectedRdpCredentialIdChange(e.target.value)}
className={`w-full px-3 py-2 ${inputBg} border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm`}
>
<option value="">不使用凭证</option>
{rdpCredentials.map((cred) => (
<option key={cred.id} value={String(cred.id)}>
{credentialLabel(cred)}
</option>
))}
</select>
)}
</div>
</div>
);
}
@@ -0,0 +1,390 @@
import { useEffect, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { getWsBase } from '@/utils/request';
import { useUserStore } from '@/store';
import { copyToClipboard } from '@/utils/clipboard';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { RdpToolsPanel } from './RdpToolsPanel';
import { Loader2, Maximize2, Minimize2, Wrench, X } from 'lucide-react';
interface RdpDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
serverId: number | null;
serverName: string;
serverIp: string;
}
interface RdpFrame {
type: string;
x?: number;
y?: number;
width?: number;
height?: number;
image?: string;
message?: string;
text?: string;
}
interface RdpStats {
received: number;
drawn: number;
failed: number;
}
const RDP_WIDTH = 1024;
const RDP_HEIGHT = 800;
const RDP_BINARY_HEADER = 20;
interface RdpRawFrame {
x: number;
y: number;
width: number;
height: number;
rgba: Uint8ClampedArray;
}
export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }: RdpDialogProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const paintQueueRef = useRef<Promise<void>>(Promise.resolve());
const statsRef = useRef<RdpStats>({ received: 0, drawn: 0, failed: 0 });
const pendingMouseMoveRef = useRef<Record<string, unknown> | null>(null);
const mouseMoveTimerRef = useRef<number | null>(null);
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [status, setStatus] = useState('等待连接');
const [hasFrame, setHasFrame] = useState(false);
const [canvasSize, setCanvasSize] = useState({ width: RDP_WIDTH, height: RDP_HEIGHT });
const [displaySize, setDisplaySize] = useState({ width: RDP_WIDTH, height: RDP_HEIGHT });
const [toolsOpen, setToolsOpen] = useState(false);
const [remoteClipboard, setRemoteClipboard] = useState('');
const sendEvent = (event: Record<string, unknown>) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(event));
}
};
const flushMouseMove = () => {
if (mouseMoveTimerRef.current !== null) {
window.clearTimeout(mouseMoveTimerRef.current);
mouseMoveTimerRef.current = null;
}
const event = pendingMouseMoveRef.current;
pendingMouseMoveRef.current = null;
if (event) sendEvent(event);
};
const sendMouseMove = (event: Record<string, unknown>) => {
pendingMouseMoveRef.current = event;
if (mouseMoveTimerRef.current !== null) return;
// 拖拽时只保留最新坐标,按 16ms 发送,避免旧 mousemove 堆积造成左右摆延迟。 // Logan.Li
mouseMoveTimerRef.current = window.setTimeout(() => {
mouseMoveTimerRef.current = null;
const latest = pendingMouseMoveRef.current;
pendingMouseMoveRef.current = null;
if (latest) sendEvent(latest);
}, 16);
};
const patchStats = (patch: Partial<RdpStats>) => {
statsRef.current = { ...statsRef.current, ...patch };
};
const paintBitmap = async (ctx: CanvasRenderingContext2D, frame: RdpFrame) => {
const dx = frame.x || 0;
const dy = frame.y || 0;
const dw = frame.width || 0;
const dh = frame.height || 0;
try {
if ('createImageBitmap' in window && frame.image) {
const blob = await fetch(frame.image).then((resp) => resp.blob());
const bitmap = await createImageBitmap(blob);
ctx.drawImage(bitmap, dx, dy, dw || bitmap.width, dh || bitmap.height);
bitmap.close();
return;
}
} catch {
// dataURL 在个别浏览器上可能解码失败,回退到 Image.onload 路径 // Logan.Li
}
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, dx, dy, dw || img.width, dh || img.height);
resolve();
};
img.onerror = () => reject(new Error('RDP 位图解码失败'));
img.src = frame.image || '';
});
};
const parseRawFrame = async (data: Blob | ArrayBuffer): Promise<RdpRawFrame | null> => {
const buffer = data instanceof Blob ? await data.arrayBuffer() : data;
if (buffer.byteLength < RDP_BINARY_HEADER) return null;
const view = new DataView(buffer);
const magic =
String.fromCharCode(view.getUint8(0)) +
String.fromCharCode(view.getUint8(1)) +
String.fromCharCode(view.getUint8(2)) +
String.fromCharCode(view.getUint8(3));
if (magic !== 'RDPR') return null;
const x = view.getUint32(4, true);
const y = view.getUint32(8, true);
const width = view.getUint32(12, true);
const height = view.getUint32(16, true);
const bytes = width * height * 4;
if (width <= 0 || height <= 0 || buffer.byteLength < RDP_BINARY_HEADER + bytes) return null;
return {
x,
y,
width,
height,
rgba: new Uint8ClampedArray(buffer, RDP_BINARY_HEADER, bytes),
};
};
const drawRawFrame = async (data: Blob | ArrayBuffer) => {
const frame = await parseRawFrame(data);
if (!frame) return;
setStatus('');
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
ctx.imageSmoothingEnabled = false;
patchStats({ received: statsRef.current.received + 1 });
try {
const pixels = new Uint8ClampedArray(frame.rgba.length);
pixels.set(frame.rgba);
ctx.putImageData(new ImageData(pixels, frame.width, frame.height), frame.x, frame.y);
setHasFrame(true);
patchStats({ drawn: statsRef.current.drawn + 1 });
} catch {
patchStats({ failed: statsRef.current.failed + 1 });
setStatus('RDP 原始帧绘制失败');
}
};
const drawFrame = async (frame: RdpFrame) => {
if (frame.type === 'status' || frame.type === 'error') {
setStatus(frame.message || '');
return;
}
if (frame.type === 'session') {
setConnected(true);
if (frame.width && frame.height) setCanvasSize({ width: frame.width, height: frame.height });
return;
}
if (frame.type === 'clipboard') {
if (frame.text) {
setRemoteClipboard(frame.text);
await copyToClipboard(frame.text);
}
return;
}
if (frame.type !== 'bitmap' || !frame.image) return;
setStatus('');
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
ctx.imageSmoothingEnabled = false;
const received = statsRef.current.received + 1;
patchStats({ received });
try {
await paintBitmap(ctx, frame);
setHasFrame(true);
patchStats({ drawn: statsRef.current.drawn + 1 });
} catch {
patchStats({ failed: statsRef.current.failed + 1 });
setStatus(`RDP 位图绘制失败,已收 ${received} 帧`);
}
};
useEffect(() => {
if (!open || !serverId) return;
const token = useUserStore.getState().token || localStorage.getItem('token') || '';
const maxDisplayWidth = fullscreen ? window.innerWidth : Math.min(window.innerWidth * 0.94, RDP_WIDTH);
const maxDisplayHeight = fullscreen ? window.innerHeight - 41 : window.innerHeight - 96;
const scale = Math.min(maxDisplayWidth / RDP_WIDTH, maxDisplayHeight / RDP_HEIGHT, 1);
const displayWidth = Math.round(RDP_WIDTH * scale);
const displayHeight = Math.round(RDP_HEIGHT * scale);
// 固定 1024x800 会话尺寸,先排除分辨率变化对画质诊断的干扰。 // Logan.Li
const width = RDP_WIDTH;
const height = RDP_HEIGHT;
setDisplaySize({ width: displayWidth, height: displayHeight });
setCanvasSize({ width, height });
setStatus('正在创建 RDP 会话...');
setHasFrame(false);
setRemoteClipboard('');
statsRef.current = { received: 0, drawn: 0, failed: 0 };
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (canvas && ctx) {
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
const ws = new WebSocket(`${getWsBase()}/ws/rdp?id=${serverId}&width=${width}&height=${height}&token=${encodeURIComponent(token)}`);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onmessage = (event) => {
if (typeof event.data !== 'string') {
paintQueueRef.current = paintQueueRef.current
.catch(() => undefined)
.then(() => drawRawFrame(event.data as Blob | ArrayBuffer));
return;
}
try {
const frame = JSON.parse(event.data) as RdpFrame;
paintQueueRef.current = paintQueueRef.current
.catch(() => undefined)
.then(() => drawFrame(frame));
} catch {
setStatus(String(event.data || '收到无法解析的 RDP 帧'));
}
};
ws.onerror = () => setStatus('RDP 连接发生错误');
ws.onclose = () => {
setConnected(false);
setStatus((prev) => prev || 'RDP 连接已关闭');
};
return () => {
flushMouseMove();
ws.close();
wsRef.current = null;
paintQueueRef.current = Promise.resolve();
setConnected(false);
setToolsOpen(false);
};
}, [open, serverId, fullscreen]);
const canvasPoint = (event: React.MouseEvent<HTMLCanvasElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = Math.round((event.clientX - rect.left) * (canvasSize.width / rect.width));
const y = Math.round((event.clientY - rect.top) * (canvasSize.height / rect.height));
return { x, y };
};
const showConnectionIssue = Boolean(status && /失败|错误|关闭|无法解析/.test(status));
const showCenterLoading = open && !hasFrame && !showConnectionIssue;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0 overflow-hidden',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
: '',
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1120px', width: '94vw' }}
>
<DialogTitle className="sr-only">RDP - {serverName}</DialogTitle>
<DialogDescription className="sr-only">Windows RDP 图形会话</DialogDescription>
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-sm">
<div className={cn('w-2 h-2 rounded-full', connected ? 'bg-emerald-400' : 'bg-red-400')} />
<span className="text-[var(--dg-text-primary)] font-medium">{serverName}</span>
<span className="text-[var(--dg-text-muted)]">({serverIp})</span>
</div>
<div className="flex items-center gap-1">
{serverId && (
<Popover open={toolsOpen} onOpenChange={setToolsOpen}>
<PopoverAnchor asChild>
<button
type="button"
onClick={() => setToolsOpen((v) => !v)}
className={cn(
'p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]',
toolsOpen && 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]',
)}
title="工具"
>
<Wrench className="h-4 w-4" />
</button>
</PopoverAnchor>
<PopoverContent align="end" className="p-0 w-auto border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<RdpToolsPanel
serverId={serverId}
remoteClipboard={remoteClipboard}
onSyncToRemote={(text) => {
sendEvent({ type: 'clipboard', text });
setStatus('已同步到远端');
}}
onAutoExec={(command) => {
sendEvent({ type: 'auto_exec', text: command });
setStatus('正在自动执行上传命令...');
}}
/>
</PopoverContent>
</Popover>
)}
<button
type="button"
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
type="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>
<div
className="relative bg-black flex items-center justify-center"
style={{ height: fullscreen ? 'calc(100vh - 41px)' : displaySize.height }}
>
<canvas
ref={canvasRef}
width={canvasSize.width}
height={canvasSize.height}
tabIndex={0}
className="max-w-full max-h-full outline-none"
style={{ width: displaySize.width, height: displaySize.height }}
onMouseMove={(e) => sendMouseMove({ type: 'mouse_move', ...canvasPoint(e) })}
onMouseDown={(e) => { e.preventDefault(); e.currentTarget.focus(); flushMouseMove(); sendEvent({ type: 'mouse_down', button: e.button, ...canvasPoint(e) }); }}
onMouseUp={(e) => { e.preventDefault(); flushMouseMove(); sendEvent({ type: 'mouse_up', button: e.button, ...canvasPoint(e) }); }}
onWheel={(e) => { flushMouseMove(); sendEvent({ type: 'wheel', delta_y: Math.trunc(e.deltaY), ...canvasPoint(e) }); }}
onContextMenu={(e) => e.preventDefault()}
onPaste={(e) => {
const text = e.clipboardData.getData('text/plain');
if (text) sendEvent({ type: 'clipboard', text });
}}
onKeyDown={(e) => { e.preventDefault(); sendEvent({ type: 'key', code: e.code, key: e.key, down: true }); }}
onKeyUp={(e) => { e.preventDefault(); sendEvent({ type: 'key', code: e.code, key: e.key, down: false }); }}
/>
{showCenterLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/25">
<Loader2 className="h-8 w-8 animate-spin text-white/80" />
</div>
)}
{showConnectionIssue && (
<div className="absolute inset-0 flex items-center justify-center bg-black/35 px-6 text-center text-sm text-white/85">
{status}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,225 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { readFromClipboard } from '@/utils/clipboard';
import { rdpFilesApi, type RdpFileItem } from '@/api/server/rdp-files';
import { Button } from '@/components/ui/button';
import { Download, FolderOpen, RefreshCw, Settings2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { RDP_VIRTUAL_DOWNLOAD_ROOT } from './rdpConstants';
type ToolsTab = 'general' | 'files';
interface RdpToolsPanelProps {
serverId: number;
remoteClipboard: string;
onSyncToRemote: (text: string) => void;
onAutoExec: (command: string) => void;
}
function fmtSize(size: number): string {
if (size >= 1 << 20) return `${(size / (1 << 20)).toFixed(1)} MB`;
if (size >= 1 << 10) return `${(size / (1 << 10)).toFixed(1)} KB`;
return `${size} B`;
}
// RDP 工具面板:通用(剪贴板)+ 文件(SMB 直传,失败时自动回退)// Logan.Li
export function RdpToolsPanel({
serverId,
remoteClipboard,
onSyncToRemote,
onAutoExec,
}: RdpToolsPanelProps) {
const [tab, setTab] = useState<ToolsTab>('general');
const [localClipDraft, setLocalClipDraft] = useState('');
const [files, setFiles] = useState<RdpFileItem[]>([]);
const [loadingFiles, setLoadingFiles] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const refreshFiles = useCallback(async () => {
setLoadingFiles(true);
try {
const data = await rdpFilesApi.list(serverId);
setFiles(data?.list ?? []);
} catch (err) {
setFiles([]);
toast.error(err instanceof Error ? err.message : '无法列出远端文件(SMB 445 可能未开放)');
} finally {
setLoadingFiles(false);
}
}, [serverId]);
useEffect(() => {
if (tab === 'files') void refreshFiles();
}, [tab, refreshFiles]);
const fillLocalClipOnFocus = async () => {
const text = await readFromClipboard();
if (text) {
setLocalClipDraft(text);
return;
}
toast.info('无法自动读取剪贴板,请 Ctrl+V 粘贴(HTTPS 下可自动读取)');
};
const syncLocalClip = () => {
const trimmed = localClipDraft.trim();
if (!trimmed) {
toast.info('内容为空');
return;
}
onSyncToRemote(trimmed);
toast.success('已同步到远端');
};
const uploadWithFallback = async (file: File) => {
setUploading(true);
try {
try {
await rdpFilesApi.upload(serverId, file);
toast.success(`${file.name} 已上传到 Download`);
void refreshFiles();
return;
} catch {
// SMB 不可达时走 AIOps 暂存 + RDP 自动执行 // Logan.Li
}
const staged = await rdpFilesApi.stage(serverId, file);
onAutoExec(staged.deploy_command);
toast.success(`${file.name} 已自动下发(SMB 不可用时的回退模式)`);
} catch (err) {
toast.error(err instanceof Error ? err.message : '上传失败');
} finally {
setUploading(false);
}
};
const handleUploadPick = async (picked: File | undefined) => {
if (!picked) return;
await uploadWithFallback(picked);
setTab('files');
};
const handleDownload = async (name: string) => {
try {
await rdpFilesApi.download(serverId, name);
toast.success(`已开始下载 ${name}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : '下载失败');
}
};
return (
<div className="w-[380px]">
<div className="flex border-b border-[var(--dg-border)]">
<button
type="button"
onClick={() => setTab('general')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs transition-colors',
tab === 'general'
? 'text-[var(--dg-text-primary)] border-b-2 border-[var(--dg-blue)]'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]',
)}
>
<Settings2 className="h-3.5 w-3.5" />
通用
</button>
<button
type="button"
onClick={() => setTab('files')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs transition-colors',
tab === 'files'
? 'text-[var(--dg-text-primary)] border-b-2 border-[var(--dg-blue)]'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]',
)}
>
<FolderOpen className="h-3.5 w-3.5" />
文件
</button>
</div>
{tab === 'general' && (
<div className="p-3 space-y-3">
<div className="space-y-1.5">
<label className="text-xs text-[var(--dg-text-muted)]">远端剪贴板</label>
<textarea
readOnly
value={remoteClipboard}
rows={3}
placeholder="远端复制内容将显示在此"
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-xs text-[var(--dg-text-secondary)] outline-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs text-[var(--dg-text-muted)]">同步到远端</label>
<textarea
value={localClipDraft}
onChange={(e) => setLocalClipDraft(e.target.value)}
onFocus={() => { void fillLocalClipOnFocus(); }}
onPaste={(e) => {
const pasted = e.clipboardData.getData('text/plain');
if (pasted) setLocalClipDraft(pasted);
}}
rows={3}
placeholder="点击输入框自动粘贴本地剪贴板"
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-2 py-1.5 text-xs text-[var(--dg-text-primary)] outline-none"
/>
<Button type="button" size="sm" className="w-full" onClick={syncLocalClip}>
同步到远端
</Button>
</div>
</div>
)}
{tab === 'files' && (
<div className="p-3 space-y-3">
<div className="flex items-center justify-between text-xs text-[var(--dg-text-muted)]">
<span>共享盘 &gt; <span className="text-[var(--dg-text-secondary)]">Download</span></span>
<button type="button" onClick={() => { void refreshFiles(); }} className="p-1 rounded hover:bg-[var(--dg-bg-hover)]" title="刷新">
<RefreshCw className={cn('h-3.5 w-3.5', loadingFiles && 'animate-spin')} />
</button>
</div>
<code className="block text-[10px] leading-relaxed text-[var(--dg-cyan)] bg-[var(--dg-bg-primary)] rounded px-2 py-1.5 break-all">
{RDP_VIRTUAL_DOWNLOAD_ROOT}
</code>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
void handleUploadPick(e.target.files?.[0]);
e.currentTarget.value = '';
}}
/>
<Button type="button" size="sm" className="w-full" disabled={uploading} onClick={() => fileInputRef.current?.click()}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
{uploading ? '上传中...' : '上传文件'}
</Button>
<div className="max-h-40 overflow-y-auto rounded border border-[var(--dg-border)]">
{files.length === 0 ? (
<p className="px-2 py-3 text-[10px] text-[var(--dg-text-muted)] text-center">
{loadingFiles ? '加载中...' : '暂无文件(需 SMB 445 可达才能列出)'}
</p>
) : (
files.map((f) => (
<div key={f.name} className="flex items-center justify-between gap-2 px-2 py-1.5 text-xs border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-hover)]">
<div className="min-w-0 flex-1">
<p className="truncate text-[var(--dg-text-primary)]">{f.name}</p>
<p className="text-[10px] text-[var(--dg-text-muted)]">{fmtSize(f.size)}</p>
</div>
<button type="button" onClick={() => { void handleDownload(f.name); }} className="p-1 rounded hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]" title="下载">
<Download className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</div>
<p className="text-[10px] text-[var(--dg-text-muted)] leading-relaxed">
优先 SMB 直传(一键);若 445 不通则自动暂存到 AIOps 并由 RDP 执行下载命令。
</p>
</div>
)}
</div>
);
}
+108 -42
View File
@@ -27,7 +27,6 @@ import {
import { Server, ChevronDown, Loader2, Save, Key, Shield } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { credentialApi } from '@/api';
import { useTranslation } from 'react-i18next';
import type { JsonData } from '@/types/common';
// 表单数据结构
@@ -38,6 +37,9 @@ export interface ServerFormData {
port: string;
username: string;
auth_type: string;
os_type: string;
protocol: string;
rdp_port: string;
password: string;
ssh_key: string;
credential_id: number | null;
@@ -57,6 +59,9 @@ export const EMPTY_FORM: ServerFormData = {
port: '22',
username: 'root',
auth_type: 'password',
os_type: 'linux',
protocol: 'ssh',
rdp_port: '3389',
password: '',
ssh_key: '',
credential_id: null,
@@ -86,6 +91,9 @@ interface Credential {
name: string;
username: string;
auth_type: string;
type?: string;
secret_type?: string;
summary?: string;
}
export function ServerForm({
@@ -99,15 +107,19 @@ export function ServerForm({
onSave,
formSaving,
}: ServerFormProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [credentials, setCredentials] = useState<Credential[]>([]);
// 只显示 SSH 相关的凭证(ssh key / ssh password)
const sshCredentials = credentials.filter((cred: any) => {
// 按连接协议过滤凭证:SSH 和 RDP 分开展示,避免 Windows 误选 SSH 凭据 // Logan.Li
const sshCredentials = credentials.filter((cred) => {
const at = (cred.auth_type || cred.type || cred.secret_type || '').toLowerCase();
return at === 'key' || at === 'password' || at === 'ssh_key' || at === 'ssh_password' || at.includes('ssh');
});
const rdpCredentials = credentials.filter((cred) => {
const at = (cred.auth_type || cred.type || cred.secret_type || '').toLowerCase();
return at === 'rdp' || at.includes('rdp');
});
const selectableCredentials = formData.protocol === 'rdp' ? rdpCredentials : sshCredentials;
// 加载凭证列表
useEffect(() => {
@@ -141,16 +153,28 @@ export function ServerForm({
}
};
const handleProtocolChange = (protocol: string) => {
setFormData({
...formData,
protocol,
os_type: protocol === 'rdp' ? 'windows' : 'linux',
port: protocol === 'rdp' ? '22' : (formData.port || '22'),
rdp_port: protocol === 'rdp' ? (formData.rdp_port || '3389') : formData.rdp_port,
username: protocol === 'rdp' ? 'Administrator' : 'root',
credential_id: null,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Server className="h-5 w-5 text-[var(--dg-blue)]" />
{editingId ? t('server.editTitle') : t('server.addServer')}
{editingId ? '编辑服务器' : '添加服务器'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
{editingId ? t('server.editSubtitle') : t('server.addSubtitle')}
{editingId ? '修改服务器资产信息' : '录入新的服务器资产到 CMDB'}
</DialogDescription>
</DialogHeader>
@@ -159,7 +183,7 @@ export function ServerForm({
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">
{t('server.hostname')} <span className="dg-text-red">*</span>
主机名 <span className="dg-text-red">*</span>
</Label>
<Input
placeholder="web-server-01"
@@ -170,7 +194,7 @@ export function ServerForm({
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">
{t('server.ipAddress')} <span className="dg-text-red">*</span>
IP地址 <span className="dg-text-red">*</span>
</Label>
<Input
placeholder="10.0.0.1"
@@ -181,33 +205,75 @@ export function ServerForm({
</div>
</div>
{/* SSH 连接配置 */}
{/* 连接协议 */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">系统类型</Label>
<Select
value={formData.os_type}
onValueChange={(v) => setFormData({ ...formData, os_type: v, protocol: v === 'windows' ? 'rdp' : 'ssh' })}
>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="linux">Linux</SelectItem>
<SelectItem value="windows">Windows</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">连接协议</Label>
<Select value={formData.protocol} onValueChange={handleProtocolChange}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="ssh">SSH</SelectItem>
<SelectItem value="rdp">RDP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{formData.protocol === 'rdp' ? 'RDP端口' : 'SSH端口'}</Label>
<Input
type="number"
placeholder={formData.protocol === 'rdp' ? '3389' : '22'}
value={formData.protocol === 'rdp' ? formData.rdp_port : formData.port}
onChange={(e) => setFormData(formData.protocol === 'rdp'
? { ...formData, rdp_port: e.target.value }
: { ...formData, port: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
{/* 连接配置 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.publicIp')}</Label>
<Label className="text-[var(--dg-text-secondary)]">公网IP</Label>
<Input
placeholder={t('server.publicIpPlaceholder')}
placeholder="如 203.0.113.10"
value={formData.public_ip}
onChange={(e) => setFormData({ ...formData, public_ip: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.sshPort')}</Label>
<Label className="text-[var(--dg-text-secondary)]">登录用户</Label>
<Input
type="number"
placeholder="22"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: e.target.value })}
placeholder={formData.protocol === 'rdp' ? 'Administrator' : 'root'}
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
{/* SSH 凭证关联 — 只允许 SSH 密钥 / SSH 密码 类型 */}
{/* 凭证关联 — SSH/RDP 按协议过滤 */}
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)] flex items-center gap-1.5">
<Key className="h-3.5 w-3.5" /> {t('server.sshCredential')}
<Key className="h-3.5 w-3.5" /> {formData.protocol === 'rdp' ? 'RDP 凭证' : 'SSH 凭证'}
{!editingId && !formData.credential_id && <span className="dg-text-red ml-1">*</span>}
</Label>
<Select
@@ -215,23 +281,23 @@ export function ServerForm({
onValueChange={handleCredentialSelect}
>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder={t('server.selectCredential')} />
<SelectValue placeholder={formData.protocol === 'rdp' ? '选择 Windows RDP 密码...' : '选择 SSH 凭证...'} />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
{sshCredentials.map((cred) => (
{selectableCredentials.map((cred) => (
<SelectItem key={cred.id} value={String(cred.id)}>
<span className="flex items-center gap-2">
<Shield className="h-3 w-3 dg-text-blue" />
{cred.name} ({cred.username}@{ (cred.auth_type || '').toLowerCase() === 'key' ? t('server.authKey') : t('server.authPassword')})
{cred.name}{cred.summary ? ` (${cred.summary})` : ''}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{sshCredentials.length === 0 && (
{selectableCredentials.length === 0 && (
<p className="text-xs text-[var(--dg-text-muted)]">
{t('server.noCredential')}
暂无可用的 {formData.protocol === 'rdp' ? 'Windows RDP 密码' : 'SSH 凭证'}。
<span
className="text-[var(--dg-blue)] hover:underline cursor-pointer ml-1"
onClick={() => {
@@ -239,13 +305,13 @@ export function ServerForm({
navigate('/credentials');
}}
>
{t('server.goCreateCredential')}
去系统管理 - 凭证配置创建
</span>
</p>
)}
{formData.credential_id && (
<p className="text-[10px] dg-text-green/70">{t('server.credentialLinked')}</p>
<p className="text-[10px] dg-text-green/70">已关联凭证,登录信息从凭证自动获取(登录用户和认证方式由凭证决定)</p>
)}
</div>
@@ -259,7 +325,7 @@ export function ServerForm({
>
<span className="flex items-center gap-2">
<Server className="h-4 w-4" />
{t('server.moreDetails')}
更多详情(OS版本 / 运行状态 / CPU / 内存 / 磁盘 / 标签 / 描述)
</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', detailsOpen && 'rotate-180')} />
</Button>
@@ -267,16 +333,16 @@ export function ServerForm({
<CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.os')}</Label>
<Label className="text-[var(--dg-text-secondary)]">操作系统</Label>
<Input
placeholder={t('server.osPlaceholder')}
placeholder="如 CentOS 7.9"
value={formData.os}
onChange={(e) => setFormData({ ...formData, os: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.runStatus')}</Label>
<Label className="text-[var(--dg-text-secondary)]">运行状态</Label>
<Select
value={formData.status}
onValueChange={(v) => setFormData({ ...formData, status: v })}
@@ -285,17 +351,17 @@ export function ServerForm({
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="running">{t('common.running')}</SelectItem>
<SelectItem value="stopped">{t('common.stopped')}</SelectItem>
<SelectItem value="maintenance">{t('common.maintenance')}</SelectItem>
<SelectItem value="offline">{t('common.offline')}</SelectItem>
<SelectItem value="running">运行中</SelectItem>
<SelectItem value="stopped">已停止</SelectItem>
<SelectItem value="maintenance">维护中</SelectItem>
<SelectItem value="offline">离线</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.cpuCores')}</Label>
<Label className="text-[var(--dg-text-secondary)]">CPU核数</Label>
<Input
type="number"
placeholder="4"
@@ -305,7 +371,7 @@ export function ServerForm({
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.memoryGb')}</Label>
<Label className="text-[var(--dg-text-secondary)]">内存 (GB)</Label>
<Input
type="number"
placeholder="8"
@@ -315,7 +381,7 @@ export function ServerForm({
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.diskGb')}</Label>
<Label className="text-[var(--dg-text-secondary)]">磁盘 (GB)</Label>
<Input
type="number"
placeholder="100"
@@ -326,18 +392,18 @@ export function ServerForm({
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.tags')}</Label>
<Label className="text-[var(--dg-text-secondary)]">标签</Label>
<Input
placeholder={t('server.tagsPlaceholder')}
placeholder='JSON 格式,如 {"project":"web","team":"ops"}'
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('common.description')}</Label>
<Label className="text-[var(--dg-text-secondary)]">描述</Label>
<Textarea
placeholder={t('server.descPlaceholder')}
placeholder="补充说明..."
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[60px]"
@@ -353,7 +419,7 @@ export function ServerForm({
onClick={() => onOpenChange(false)}
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]"
>
{t('common.cancel')}
取消
</Button>
<Button
onClick={onSave}
@@ -365,7 +431,7 @@ export function ServerForm({
) : (
<Save className="h-4 w-4 mr-2" />
)}
{editingId ? t('server.saveChanges') : t('common.create')}
{editingId ? '保存修改' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
@@ -47,6 +47,7 @@ import { toast } from 'sonner';
import { useUserStore } from '@/store';
import { CloudServerImport } from './CloudServerImport';
import { TerminalDialog } from './TerminalDialog';
import { RdpDialog } from './RdpDialog';
import { ServerFileBrowser } from './ServerFileBrowser';
import type { JsonData } from '@/types/common';
import { ExcelServerImport } from './ExcelServerImport';
@@ -79,6 +80,8 @@ export function ServerManager() {
// 终端 Dialog 状态
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string; hasAuth?: boolean } | null>(null);
const [rdpOpen, setRdpOpen] = useState(false);
const [rdpServer, setRdpServer] = useState<{ id: number; name: string; ip: string } | null>(null);
// 文件浏览 Dialog 状态
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
@@ -105,6 +108,11 @@ export function ServerManager() {
setTerminalOpen(true);
};
const openRdp = (server: JsonData) => {
setRdpServer({ id: server.id, name: server.hostname, ip: server.ip });
setRdpOpen(true);
};
const openFileBrowser = (server: JsonData) => {
setFileBrowserServer({ id: server.id, name: server.hostname });
setFileBrowserOpen(true);
@@ -272,6 +280,9 @@ export function ServerManager() {
port: String(server.port || 22),
username: server.username || 'root',
auth_type: server.auth_type || 'password',
os_type: server.os_type || ((server.protocol === 'rdp') ? 'windows' : 'linux'),
protocol: server.protocol || ((server.os_type === 'windows') ? 'rdp' : 'ssh'),
rdp_port: String(server.rdp_port || 3389),
password: '',
ssh_key: '',
credential_id: null as number | null,
@@ -297,6 +308,9 @@ export function ServerManager() {
ip: formData.ip,
public_ip: formData.public_ip || undefined,
port: Number(formData.port) || 22,
os_type: formData.os_type || 'linux',
protocol: formData.protocol || 'ssh',
rdp_port: Number(formData.rdp_port) || 3389,
username: formData.username || 'root',
auth_type: formData.auth_type || 'password',
os: formData.os,
@@ -500,6 +514,7 @@ export function ServerManager() {
selected={selectedServers.includes(String(server.id))}
onToggleSelect={toggleSelect}
onOpenTerminal={openTerminal}
onOpenRdp={openRdp}
onOpenFiles={openFileBrowser}
onOpenEdit={openEditForm}
onDelete={handleDelete}
@@ -565,6 +580,13 @@ export function ServerManager() {
onSave={handleSave}
formSaving={formSaving}
/>
<RdpDialog
open={rdpOpen}
onOpenChange={setRdpOpen}
serverId={rdpServer?.id ?? null}
serverName={rdpServer?.name || ''}
serverIp={rdpServer?.ip || ''}
/>
{/* Import Dialogs */}
<CloudServerImport
@@ -12,17 +12,17 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TableCell, TableRow } from '@/components/ui/table';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen } from 'lucide-react';
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen, Monitor } from 'lucide-react';
import { getStatusBadge, getTags, getUsageColor, isRdpServer } from './serverHelpers';
import { usePermission } from '@/hooks/usePermission';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
interface ServerTableRowProps {
server?: JsonData;
selected: boolean;
onToggleSelect: (id: string) => void;
onOpenTerminal: (server: JsonData) => void;
onOpenRdp: (server: JsonData) => void;
onOpenEdit: (server: JsonData) => void;
onOpenFiles?: (server: JsonData) => void;
onDelete: (id: string) => void;
@@ -33,6 +33,7 @@ export function ServerTableRow({
selected,
onToggleSelect,
onOpenTerminal,
onOpenRdp,
onOpenEdit,
onOpenFiles,
onDelete,
@@ -48,6 +49,7 @@ export function ServerTableRow({
const _txBps = server.net_tx_bps || 0;
const readBps = server.disk_read_bps || 0;
const writeBps = server.disk_write_bps || 0;
const isRdp = isRdpServer(server);
// Format bytes/s to human readable
const fmtBps = (bps: number) => {
@@ -110,7 +112,7 @@ export function ServerTableRow({
<span className="text-xs text-[var(--dg-text-secondary)]">{cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--dg-text-muted)] w-8"> {i18n.t('kubernetes.pod.memory')} </span>
<span className="text-xs text-[var(--dg-text-muted)] w-8">内存</span>
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full', getUsageColor(memoryUsage))}
@@ -151,8 +153,8 @@ export function ServerTableRow({
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{hasPermission('server:webshell') && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)} title={i18n.t('server.servertablerowt.sa0b6010a')}>
<Terminal className="h-4 w-4" />
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => isRdp ? onOpenRdp(server) : onOpenTerminal(server)} title={isRdp ? 'RDP登录' : '终端登录'}>
{isRdp ? <Monitor className="h-4 w-4" /> : <Terminal className="h-4 w-4" />}
</Button>
)}
{(hasPermission('server:update') || hasPermission('server:delete') || hasPermission('server:webshell')) && (
@@ -163,7 +165,7 @@ export function ServerTableRow({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]"> {i18n.t('system.auditAction')} </DropdownMenuLabel>
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
{hasPermission('server:update') && (
<DropdownMenuItem
@@ -177,10 +179,10 @@ export function ServerTableRow({
{hasPermission('server:webshell') && (
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenTerminal(server)}
onClick={() => isRdp ? onOpenRdp(server) : onOpenTerminal(server)}
>
<Terminal className="h-4 w-4 mr-2" />
终端
{isRdp ? <Monitor className="h-4 w-4 mr-2" /> : <Terminal className="h-4 w-4 mr-2" />}
{isRdp ? 'RDP' : '终端'}
</DropdownMenuItem>
)}
{hasPermission('server:update') && (
@@ -12,11 +12,11 @@ import 'xterm/css/xterm.css';
import { Maximize2, Minimize2, X, Shield} from 'lucide-react';
import { cn } from '@/lib/utils';
import { attachTerminalResize, fitTerminal, mountXterm } from '@/lib/xterm-utils';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
import { useUserStore } from '@/store';
import { credentialApi, serverCredentialApi } from '@/api';
import { toast } from 'sonner';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
interface TerminalDialogProps {
open: boolean;
@@ -46,6 +46,7 @@ export function TerminalDialog({
const fitRef = useRef<FitAddon | null>(null);
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [hasOutput, setHasOutput] = useState(false);
// 凭据选择状态(hasAuth=false 时显示) // Logan.Li
const [credentials, setCredentials] = useState<JsonData[]>([]);
@@ -65,10 +66,10 @@ export function TerminalDialog({
try {
setAssigning(true);
await serverCredentialApi.assign(serverId, Number(selectedCredId));
toast.success(i18n.t('server.terminaldialogt.s7c9109af'));
toast.success('凭据已关联');
setAuthReady(true);
} catch (e) {
toast.error(e?.message || i18n.t('server.terminaldialogt.s6d973dbe'));
toast.error(e?.message || '关联失败');
} finally {
setAssigning(false);
}
@@ -85,6 +86,7 @@ export function TerminalDialog({
// 连接终端(authReady=true 时才执行)
useEffect(() => {
if (!open || !serverId || !authReady) return;
setHasOutput(false);
// 创建终端
const term = new XTerm({
@@ -121,9 +123,11 @@ export function TerminalDialog({
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
setHasOutput(true);
term.write(msg.data);
}
} catch {
setHasOutput(true);
term.write(typeof event.data === 'string' ? event.data : '');
}
};
@@ -136,11 +140,13 @@ export function TerminalDialog({
ws.onclose = () => {
setConnected(false);
term.write(i18n.t('server.terminaldialogt.safab2019'));
setHasOutput(true);
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
};
ws.onerror = () => {
term.write(i18n.t('kubernetes.podexecterminal.sc8107726'));
setHasOutput(true);
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
return () => {
@@ -227,11 +233,11 @@ export function TerminalDialog({
<div className="p-6 flex flex-col items-center justify-center gap-4" style={{ height: '480px' }}>
<Shield className="h-12 w-12 dg-text-yellow" />
<div className="text-center">
<p className="text-[var(--dg-text-primary)] font-medium mb-1"> {i18n.t('server.terminaldialogt.sa8156f58')} </p>
<p className="text-sm text-[var(--dg-text-muted)]"> {i18n.t('server.terminaldialogt.s909c9a65')} </p>
<p className="text-[var(--dg-text-primary)] font-medium mb-1">服务器未配置认证凭据</p>
<p className="text-sm text-[var(--dg-text-muted)]">请选择一个凭据用于 SSH 连接</p>
</div>
{credentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('server.terminaldialogt.s817bf0a2')} </p>
<p className="text-xs text-[var(--dg-text-muted)]">暂无可用凭据,请先去「凭证配置」添加</p>
) : (
<select
value={selectedCredId}
@@ -251,17 +257,16 @@ export function TerminalDialog({
disabled={!selectedCredId || assigning}
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
>
{assigning ? i18n.t('server.terminaldialogt.saf3d5dd8') : i18n.t('server.terminaldialogt.s9b448012')}
{assigning ? '关联中...' : '确认连接'}
</Button>
</div>
)}
{/* 终端容器 */}
{authReady && (
<div
ref={containerRef}
className="xterm-terminal-host"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '520px' }}
/>
<div className="relative bg-[#0a0e14]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '520px' }}>
<div ref={containerRef} className="xterm-terminal-host h-full" />
<TerminalLoadingOverlay show={!hasOutput} label="正在登录..." />
</div>
)}
</DialogContent>
</Dialog>
+38 -38
View File
@@ -29,10 +29,11 @@ import {
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { attachTerminalResize, fitTerminal, mountXterm } from '@/lib/xterm-utils';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
import { serverApi } from '@/api';
import { useUserStore } from '@/store';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
interface TerminalSession {
id: string;
@@ -40,6 +41,7 @@ interface TerminalSession {
serverName: string;
serverIp: string;
connected: boolean;
ready: boolean;
}
interface TermEntry {
@@ -47,6 +49,7 @@ interface TermEntry {
ws: WebSocket;
fit: FitAddon;
opened: boolean;
resizeCleanup?: () => void;
}
export function WebShell() {
@@ -67,7 +70,7 @@ export function WebShell() {
const res: JsonData = await serverApi.getServers();
setServers(res?.list || []);
} catch (e) {
console.error(i18n.t('server.webshelltsx.s8603ff5b'), e);
console.error('加载服务器列表失败:', e);
setServers([]);
}
};
@@ -79,8 +82,9 @@ export function WebShell() {
// 卸载时关闭所有连接
useEffect(() => {
return () => {
termsRef.current.forEach(({ ws, term }) => {
termsRef.current.forEach(({ ws, term, resizeCleanup }) => {
try {
resizeCleanup?.();
ws.close();
term.dispose();
} catch {
@@ -135,10 +139,12 @@ export function WebShell() {
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
setSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, ready: true } : s)));
term.write(msg.data);
}
} catch {
// 非 JSON,直接写入原始数据
setSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, ready: true } : s)));
term.write(typeof event.data === 'string' ? event.data : '');
}
};
@@ -148,14 +154,15 @@ export function WebShell() {
};
ws.onclose = () => {
term.write(i18n.t('server.terminaldialogt.safab2019'));
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, connected: false } : s))
prev.map((s) => (s.id === sessionId ? { ...s, connected: false, ready: true } : s))
);
};
ws.onerror = () => {
term.write(i18n.t('kubernetes.podexecterminal.sc8107726'));
setSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, ready: true } : s)));
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
termsRef.current.set(sessionId, { term, ws, fit, opened: false });
@@ -166,6 +173,7 @@ export function WebShell() {
serverName: server.hostname,
serverIp: server.ip,
connected: true,
ready: false,
};
setSessions((prev) => [...prev, newSession]);
setActiveSession(sessionId);
@@ -177,6 +185,7 @@ export function WebShell() {
const entry = termsRef.current.get(sessionId);
if (entry) {
try {
entry.resizeCleanup?.();
if (entry.ws.readyState === WebSocket.OPEN || entry.ws.readyState === WebSocket.CONNECTING) {
entry.ws.close();
}
@@ -201,12 +210,7 @@ export function WebShell() {
const entry = termsRef.current.get(activeSession);
if (!entry || !entry.opened) return;
try {
entry.fit.fit();
const { cols, rows } = entry.term;
entry.term.resize(cols, rows);
if (entry.ws.readyState === WebSocket.OPEN) {
entry.ws.send(JSON.stringify({ status: 1, cols, rows }));
}
fitTerminal(entry.fit, entry.term, entry.ws);
} catch {
/* noop */
}
@@ -222,7 +226,7 @@ export function WebShell() {
if (entry && entry.opened) {
requestAnimationFrame(() => {
try {
entry.fit.fit();
fitTerminal(entry.fit, entry.term, entry.ws);
entry.term.focus();
} catch {
/* noop */
@@ -248,27 +252,16 @@ export function WebShell() {
const entry = termsRef.current.get(sessionId);
if (entry && !entry.opened) {
try {
entry.term.open(el);
mountXterm(entry.term, entry.fit, () => el, entry.ws);
entry.opened = true;
// 延迟适配:等 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 JsonData)._observer = observer;
entry.resizeCleanup = attachTerminalResize(el, entry.fit, entry.term, entry.ws);
} catch (e) {
console.error(i18n.t('server.webshelltsx.s7d4b03bb'), e);
console.error('打开终端失败:', e);
}
}
} else {
const entry = termsRef.current.get(sessionId);
if (entry && (entry as JsonData)._observer) {
(entry as JsonData)._observer.disconnect();
}
entry?.resizeCleanup?.();
containerRef.current.delete(sessionId);
}
};
@@ -283,7 +276,7 @@ export function WebShell() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">WebShell</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1"> {i18n.t('server.webshelltsx.s1df74087')} </p>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">远程服务器终端管理</p>
</div>
<div className="flex items-center gap-2">
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
@@ -295,21 +288,21 @@ export function WebShell() {
</DialogTrigger>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]"> {i18n.t('server.webshelltsx.s7026b318')} </DialogTitle>
<DialogTitle className="text-[var(--dg-text-primary)]">新建终端连接</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
选择要连接的服务器
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--dg-text-secondary)]"> {i18n.t('server.webshelltsx.s030235d1')} </label>
<label className="text-sm font-medium text-[var(--dg-text-secondary)]">选择服务器</label>
<Select value={selectedServer} onValueChange={setSelectedServer}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder={i18n.t('server.webshelltsx.s030235d1')} />
<SelectValue placeholder="选择服务器" />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
{runningServers.length === 0 ? (
<SelectItem value="__none" disabled> {i18n.t('server.webshelltsx.se5460be8')} </SelectItem>
<SelectItem value="__none" disabled>暂无运行中的服务器</SelectItem>
) : (
runningServers.map((server) => (
<SelectItem key={server.id} value={server.id}>
@@ -379,7 +372,7 @@ export function WebShell() {
))}
</div>
) : (
<span className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('server.webshelltsx.sd5d6cbbb')} </span>
<span className="text-sm text-[var(--dg-text-secondary)]">暂无连接</span>
)}
</div>
<div className="flex items-center gap-1">
@@ -407,12 +400,19 @@ export function WebShell() {
key={session.id}
ref={(el) => registerContainer(session.id, el)}
className={cn(
'absolute inset-0',
'absolute inset-0 xterm-terminal-host',
activeSession === session.id ? 'block' : 'hidden'
)}
style={{ padding: 0 }}
/>
))}
{sessions.map((session) => (
<TerminalLoadingOverlay
key={`${session.id}-loading`}
show={activeSession === session.id && !session.ready}
label="正在登录..."
/>
))}
</div>
) : (
<div
@@ -420,8 +420,8 @@ export function WebShell() {
style={{ height: terminalHeight }}
>
<Terminal className="h-16 w-16 mb-4 opacity-50" />
<p className="text-lg font-medium"> {i18n.t('server.webshelltsx.sefd92a06')} </p>
<p className="text-sm mt-1"> {i18n.t('server.webshelltsx.se2e43347')} </p>
<p className="text-lg font-medium">暂无活动连接</p>
<p className="text-sm mt-1">点击“新建连接”开始远程会话</p>
</div>
)}
</CardContent>
@@ -431,7 +431,7 @@ export function WebShell() {
{activeSessionData && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('server.webshelltsx.sde027789')} </CardTitle>
<CardTitle className="text-sm text-[var(--dg-text-secondary)]">快捷命令</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
@@ -0,0 +1,7 @@
// RDP 虚拟共享盘根路径,映射为 Windows 本机 Download 目录 // Logan.Li
export const RDP_VIRTUAL_DOWNLOAD_ROOT = 'C:\\Users\\Public\\AIOpsShare\\Download';
export function rdpVirtualDownloadPath(fileName: string): string {
const safe = fileName.replace(/[\\/:*?"<>|]/g, '_').trim() || 'upload.bin';
return `${RDP_VIRTUAL_DOWNLOAD_ROOT}\\${safe}`;
}
@@ -3,6 +3,14 @@ import { Badge } from '@/components/ui/badge';
import type { Server as ServerType } from '@/types';
import type { JsonData } from '@/types/common';
// isRdpServer 判断资产是否应展示 RDP 入口(兼容云导入仅填 os 字段的历史数据) // Logan.Li
export function isRdpServer(server: JsonData): boolean {
const protocol = String(server.protocol || '').toLowerCase();
if (protocol === 'rdp') return true;
const osType = String(server.os_type || server.os || '').toLowerCase();
return osType.includes('windows') || osType.includes('win');
}
// 解析标签:后端 tags 可能是 JSON 字符串(JSON数组/对象)、逗号分隔字符串或数组 // Logan.Li
export function getTags(server: JsonData): string[] {
const tags = server.tags;
@@ -14,7 +14,7 @@ import { Loader2, Maximize2, Minimize2, Monitor, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils';
import { useUserStore } from '@/store';
import { useTranslation } from 'react-i18next';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
interface SessionInfo {
session_id: number;
@@ -33,7 +33,6 @@ interface LiveMonitorProps {
}
export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo }: LiveMonitorProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const fitRef = useRef<FitAddon | null>(null);
@@ -126,11 +125,11 @@ export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo }
}
};
ws.onerror = () => setConnectError(t('aiops.wsConnectFailed'));
ws.onerror = () => setConnectError('WebSocket 连接失败');
ws.onclose = (e) => {
if (!didOpen) {
setConnectError(`连接失败 (code=${e.code}): ${e.reason || t('aiops.authOrNetwork')}`);
setConnectError(`连接失败 (code=${e.code}): ${e.reason || '认证失败或网络问题'}`);
}
setConnected(false);
term.writeln('\r\n\x1b[33m[审计监控] 连接已断开\x1b[0m');
@@ -138,7 +137,7 @@ export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo }
const timeout = setTimeout(() => {
if (!didOpen) {
setConnectError(t('aiops.connectTimeoutActive'));
setConnectError('连接超时 — 请确认已登录且有活跃会话');
ws.close();
}
}, 5000);
@@ -196,14 +195,14 @@ export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo }
: { maxWidth: '960px', width: '92vw' }
}
>
<DialogTitle className="sr-only">{t('system.terminalLiveMonitor')}</DialogTitle>
<DialogDescription className="sr-only">{t('system.auditWebssh')}</DialogDescription>
<DialogTitle className="sr-only">终端实时监控</DialogTitle>
<DialogDescription className="sr-only">审计在线 WebSSH 会话</DialogDescription>
{/* 工具栏 — 与 TerminalDialog 一致 // Logan.Li */}
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-sm min-w-0 flex-wrap">
<Monitor className="h-4 w-4 dg-text-green shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium">{t('system.terminalLiveMonitor')}</span>
<span className="text-[var(--dg-text-primary)] font-medium">终端实时监控</span>
{meta && (
<span className="text-[var(--dg-text-muted)] truncate">
{meta.username} · {meta.server_name} ({meta.server_ip}) · {formatTime(meta.start_time)}
@@ -217,12 +216,12 @@ export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo }
{!connected && !connectError && (
<Badge variant="outline" className="border-yellow-500/30 dg-text-yellow bg-yellow-500/10 text-xs">
<Loader2 className="h-3 w-3 animate-spin mr-1" />
{t('common.connecting')}
连接中
</Badge>
)}
{connectError && (
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10 text-xs">
{t('system.ldapTestFailed')}
连接失败
</Badge>
)}
</div>
@@ -231,7 +230,7 @@ export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo }
type="button"
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title={fullscreen ? t('common.exitFullscreen') : t('common.fullscreen')}
title={fullscreen ? '退出全屏' : '全屏'}
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
@@ -249,12 +248,11 @@ export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo }
<p className="text-sm dg-text-red px-4 py-2 border-b border-[var(--dg-border)]">{connectError}</p>
)}
<div
ref={containerRef}
className="xterm-terminal-host"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '520px' }}
/>
<div className="relative bg-[#0a0e14]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '520px' }}>
<div ref={containerRef} className="xterm-terminal-host h-full" />
<TerminalLoadingOverlay show={!connected && !connectError} label="正在连接监控..." />
</div>
</DialogContent>
</Dialog>
);
}
}
@@ -0,0 +1,262 @@
// RDP 审计查看器:在线实时查看 + 历史录屏回放共用 Canvas 播放器 // Logan.Li
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { cn } from '@/lib/utils';
import { getAuthToken, getWsBase } from '@/utils/request';
import { rdpSessionApi } from '@/api';
import { Loader2, Maximize2, Minimize2, Pause, Play, RotateCcw, X } from 'lucide-react';
interface RdpAuditViewerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: number | null;
mode: 'live' | 'replay';
}
interface RdpFrame {
type: string;
x?: number;
y?: number;
width?: number;
height?: number;
image?: string;
message?: string;
}
interface RdpRecord {
version?: number;
frames?: RdpFrame[];
}
interface RawFrame {
x: number;
y: number;
width: number;
height: number;
rgba: Uint8ClampedArray;
}
const BINARY_HEADER = 20;
const FRAME_MS = 500;
async function parseRawFrame(data: Blob | ArrayBuffer): Promise<RawFrame | null> {
const buffer = data instanceof Blob ? await data.arrayBuffer() : data;
if (buffer.byteLength < BINARY_HEADER) return null;
const view = new DataView(buffer);
const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
if (magic !== 'RDPR') return null;
const x = view.getUint32(4, true);
const y = view.getUint32(8, true);
const width = view.getUint32(12, true);
const height = view.getUint32(16, true);
const bytes = width * height * 4;
if (width <= 0 || height <= 0 || buffer.byteLength < BINARY_HEADER + bytes) return null;
return { x, y, width, height, rgba: new Uint8ClampedArray(buffer, BINARY_HEADER, bytes) };
}
async function drawImageFrame(ctx: CanvasRenderingContext2D, frame: RdpFrame) {
if (!frame.image) return;
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, frame.x || 0, frame.y || 0, frame.width || img.width, frame.height || img.height);
resolve();
};
img.onerror = () => reject(new Error('RDP 录屏帧解码失败'));
img.src = frame.image || '';
});
}
export function RdpAuditViewer({ open, onOpenChange, sessionId, mode }: RdpAuditViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const timerRef = useRef<number | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const [status, setStatus] = useState('');
const [loading, setLoading] = useState(false);
const [canvasSize, setCanvasSize] = useState({ width: 1024, height: 800 });
const [frames, setFrames] = useState<RdpFrame[]>([]);
const [index, setIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const playableFrames = useMemo(() => frames.filter((f) => f.type === 'bitmap' && f.image), [frames]);
const drawRaw = async (data: Blob | ArrayBuffer) => {
const frame = await parseRawFrame(data);
if (!frame) return;
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
ctx.imageSmoothingEnabled = false;
const pixels = new Uint8ClampedArray(frame.rgba.length);
pixels.set(frame.rgba);
ctx.putImageData(new ImageData(pixels, frame.width, frame.height), frame.x, frame.y);
};
const drawReplay = async (nextIndex: number) => {
const frame = playableFrames[nextIndex];
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!frame || !canvas || !ctx) return;
ctx.imageSmoothingEnabled = false;
await drawImageFrame(ctx, frame);
};
useEffect(() => {
if (!open || !sessionId) return;
setStatus(mode === 'live' ? '正在连接 RDP 实时审计...' : '正在加载 RDP 录屏...');
setLoading(true);
setFrames([]);
setIndex(0);
setPlaying(false);
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (canvas && ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
if (mode === 'live') {
const token = getAuthToken();
const ws = new WebSocket(`${getWsBase()}/ws/rdp-audit/${sessionId}?token=${encodeURIComponent(token)}`);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => { setLoading(false); setStatus(''); };
ws.onmessage = (event) => {
if (typeof event.data !== 'string') {
void drawRaw(event.data as Blob | ArrayBuffer);
return;
}
try {
const frame = JSON.parse(event.data) as RdpFrame;
if (frame.type === 'session' && frame.width && frame.height) {
setCanvasSize({ width: frame.width, height: frame.height });
} else if (frame.type === 'status' || frame.type === 'error') {
setStatus(frame.message || '');
}
} catch {
/* ignore */
}
};
ws.onerror = () => { setLoading(false); setStatus('RDP 实时审计连接失败'); };
ws.onclose = () => setStatus((prev) => prev || 'RDP 实时审计已断开');
return () => {
ws.close();
wsRef.current = null;
};
}
let cancelled = false;
rdpSessionApi.getRecord(sessionId)
.then((raw) => {
if (cancelled) return;
const text = typeof raw === 'string' ? raw : String(raw || '');
const record = JSON.parse(text || '{}') as RdpRecord;
const list = (record.frames || []).filter((f) => f.type === 'bitmap' && f.image);
setFrames(list);
setStatus(list.length ? '' : '该 RDP 会话没有可播放画面帧');
setPlaying(list.length > 0);
setLoading(false);
})
.catch(() => {
if (!cancelled) {
setStatus('RDP 录屏加载失败');
setLoading(false);
}
});
rdpSessionApi.logReplayView(sessionId).catch(() => {});
return () => { cancelled = true; };
}, [open, sessionId, mode]);
useEffect(() => {
if (!playing || mode !== 'replay') return;
timerRef.current = window.setInterval(() => {
setIndex((current) => {
const next = current + 1;
if (next >= playableFrames.length) {
setPlaying(false);
return current;
}
void drawReplay(next);
return next;
});
}, FRAME_MS);
return () => {
if (timerRef.current !== null) window.clearInterval(timerRef.current);
timerRef.current = null;
};
}, [playing, mode, playableFrames.length]);
useEffect(() => {
if (mode === 'replay' && playableFrames.length > 0) void drawReplay(index);
}, [index, playableFrames.length, mode]);
const displayHeight = fullscreen ? 'calc(100vh - 88px)' : '560px';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0 overflow-hidden',
fullscreen ? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0' : '',
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1120px', width: '94vw' }}
>
<DialogTitle className="sr-only">{mode === 'live' ? 'RDP 实时审计' : 'RDP 历史回放'}</DialogTitle>
<DialogDescription className="sr-only">Windows RDP 图形会话审计查看</DialogDescription>
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<div className="text-sm font-medium text-[var(--dg-text-primary)]">
{mode === 'live' ? 'RDP 实时审计' : `RDP 历史回放 #${sessionId || ''}`}
</div>
<div className="flex items-center gap-1">
<button type="button" onClick={() => setFullscreen(!fullscreen)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]">
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button type="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>
<div className="relative bg-black flex items-center justify-center" style={{ height: displayHeight }}>
<canvas
ref={canvasRef}
width={canvasSize.width}
height={canvasSize.height}
className="max-w-full max-h-full outline-none"
style={{ aspectRatio: `${canvasSize.width} / ${canvasSize.height}` }}
/>
{loading && <Loader2 className="absolute h-7 w-7 animate-spin text-white/80" />}
{status && <div className="absolute left-4 bottom-4 max-w-[70%] rounded bg-black/70 px-3 py-2 text-xs text-white">{status}</div>}
</div>
{mode === 'replay' && (
<div className="flex items-center gap-3 px-4 py-3 border-t border-[var(--dg-border)]">
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={playableFrames.length === 0} onClick={() => setPlaying((v) => !v)}>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={playableFrames.length === 0} onClick={() => { setIndex(0); setPlaying(false); }}>
<RotateCcw className="h-4 w-4" />
</Button>
<Slider
value={[index]}
min={0}
max={Math.max(playableFrames.length - 1, 0)}
step={1}
onValueChange={(v) => { setPlaying(false); setIndex(v[0] || 0); }}
className="flex-1"
/>
<span className="text-xs dg-text-muted tabular-nums">{playableFrames.length ? index + 1 : 0} / {playableFrames.length}</span>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -16,6 +16,7 @@ import {
import { exportAuditReportCsv, exportAuditReportJson } from './replay/export-audit';
import { terminalSessionApi, sessionCommandApi } from '@/api';
import { unwrapList } from '@/hooks/api/unwrap';
import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils';
import { formatSize } from '@/pages/audit/terminal-audit/helpers';
import { ReplayEngine, type SpeedOption } from './replay/replay-engine';
import {
@@ -39,6 +40,7 @@ export function ReplayDialog({ open, onOpenChange, sessionId, initialSeekSec }:
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const engineRef = useRef<ReplayEngine | null>(null);
const rowsRef = useRef<AsciicastRow[]>([]);
@@ -97,6 +99,7 @@ export function ReplayDialog({ open, onOpenChange, sessionId, initialSeekSec }:
const fit = new FitAddon();
term.loadAddon(fit);
termRef.current = term;
fitRef.current = fit;
const engine = new ReplayEngine({
onTimeUpdate: (time) => {
@@ -147,10 +150,7 @@ export function ReplayDialog({ open, onOpenChange, sessionId, initialSeekSec }:
const rows = parseAsciicast(data);
rowsRef.current = rows;
if (containerRef.current) {
term.open(containerRef.current);
try { fit.fit(); } catch { /* noop */ }
}
mountXterm(term, fit, () => containerRef.current);
if (rows.length === 0) {
term.write(data);
@@ -168,6 +168,8 @@ export function ReplayDialog({ open, onOpenChange, sessionId, initialSeekSec }:
const seek = initialSeekSec != null && initialSeekSec > 0 ? initialSeekSec : 0;
engine.seekTo(seek, false);
setCurrentTime(seek);
engine.play();
setPlaying(true);
// 二次审计:记录回放查看 // Logan.Li
terminalSessionApi.logReplayView(sessionId).catch(() => {});
@@ -187,9 +189,29 @@ export function ReplayDialog({ open, onOpenChange, sessionId, initialSeekSec }:
engineRef.current = null;
try { term.dispose(); } catch { /* noop */ }
termRef.current = null;
fitRef.current = null;
};
}, [open, sessionId, initialSeekSec, t]);
// 回放终端也跟随 Dialog 尺寸变化重新 fit,避免缩放后内容区留边。 // Logan.Li
useEffect(() => {
if (!open) return;
let cleanup: (() => void) | undefined;
let retry = 0;
const attach = () => {
const container = containerRef.current;
const fit = fitRef.current;
const term = termRef.current;
if (!container || !fit || !term) {
if (retry++ < 30) requestAnimationFrame(attach);
return;
}
cleanup = attachTerminalResize(container, fit, term);
};
attach();
return () => cleanup?.();
}, [open, sessionId]);
// 倍速变更 // Logan.Li
useEffect(() => {
engineRef.current?.setSpeed(speed);
@@ -264,7 +286,7 @@ export function ReplayDialog({ open, onOpenChange, sessionId, initialSeekSec }:
<p className="dg-text-red text-sm">{error}</p>
</div>
)}
<div ref={containerRef} className="flex-1 min-h-0 p-1" />
<div ref={containerRef} className="xterm-terminal-host flex-1 min-h-0" />
{!legacyMode && totalTime > 0 && (
<div className="shrink-0 border-t border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]/95 px-3 py-2.5 space-y-2">
@@ -361,4 +383,4 @@ export function ReplayDialog({ open, onOpenChange, sessionId, initialSeekSec }:
</DialogContent>
</Dialog>
);
}
}
@@ -9,7 +9,7 @@ import {
import { Key, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { userApi } from '@/api';
import { useTranslation } from 'react-i18next';
import { getErrorMessage } from '@/lib/utils';
interface ResetPasswordDialogProps {
user: { id: number | string; username?: string; nickname?: string; authSource?: string } | null;
@@ -17,7 +17,6 @@ interface ResetPasswordDialogProps {
}
export function ResetPasswordDialog({ user, onClose }: ResetPasswordDialogProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
@@ -31,12 +30,12 @@ export function ResetPasswordDialog({ user, onClose }: ResetPasswordDialogProps)
setSaving(true);
try {
await userApi.resetUserPassword(user.id, password);
toast({ title: t('system.passwordReset'), description: `用户 ${user.nickname || user.username} 的新密码已生效` });
toast({ title: '密码已重置', description: `用户 ${user.nickname || user.username} 的新密码已生效` });
setPassword('');
setConfirm('');
onClose();
} catch (e: any) {
toast({ title: t('system.resetFailed'), description: e?.message || String(e), variant: 'destructive' });
} catch (e: unknown) {
toast({ title: '重置失败', description: getErrorMessage(e), variant: 'destructive' });
} finally {
setSaving(false);
}
@@ -48,7 +47,7 @@ export function ResetPasswordDialog({ user, onClose }: ResetPasswordDialogProps)
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Key className="h-5 w-5 text-[var(--dg-blue)]" />
{t('system.resetPassword')}
重置密码
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
{user ? `为「${user.nickname || user.username}」设置新密码` : ''}
@@ -56,26 +55,26 @@ export function ResetPasswordDialog({ user, onClose }: ResetPasswordDialogProps)
</DialogHeader>
{isLdap ? (
<p className="text-sm dg-text-yellow/90 py-2">{t('system.ldapResetHint')}</p>
<p className="text-sm dg-text-yellow/90 py-2">LDAP 用户请在 LDAP 目录中管理密码,不支持本地重置。</p>
) : (
<div className="space-y-3 py-1">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">{t('system.newPassword')}</Label>
<Label className="text-xs text-[var(--dg-text-muted)]">新密码</Label>
<Input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder={t('system.passwordMinLen')}
placeholder="至少 6 位"
className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">{t('system.userConfirmPassword')}</Label>
<Label className="text-xs text-[var(--dg-text-muted)]">确认密码</Label>
<Input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
placeholder={t('system.confirmNewPassword')}
placeholder="再次输入新密码"
className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div>
@@ -83,7 +82,7 @@ export function ResetPasswordDialog({ user, onClose }: ResetPasswordDialogProps)
)}
<DialogFooter>
<Button variant="outline" className="border-[var(--dg-border)]" onClick={onClose}>{t('common.cancel')}</Button>
<Button variant="outline" className="border-[var(--dg-border)]" onClick={onClose}>取消</Button>
{!isLdap && (
<Button onClick={handleReset} disabled={!canSubmit || saving} className="bg-[var(--dg-blue)] hover:opacity-90">
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
@@ -94,4 +93,4 @@ export function ResetPasswordDialog({ user, onClose }: ResetPasswordDialogProps)
</DialogContent>
</Dialog>
);
}
}
@@ -75,7 +75,11 @@ export function RoleFormDialog({
const togglePermission = (id: number, checked: boolean) => {
setForm((prev) => {
const set = new Set(prev.permission_ids);
checked ? set.add(id) : set.delete(id);
if (checked) {
set.add(id);
} else {
set.delete(id);
}
return { ...prev, permission_ids: Array.from(set) };
});
};
@@ -84,7 +88,13 @@ export function RoleFormDialog({
const toggleGroup = (items: PermissionOption[], checked: boolean) => {
setForm((prev) => {
const set = new Set(prev.permission_ids);
items.forEach(p => checked ? set.add(p.id) : set.delete(p.id));
items.forEach((p) => {
if (checked) {
set.add(p.id);
} else {
set.delete(p.id);
}
});
return { ...prev, permission_ids: Array.from(set) };
});
};
@@ -6,6 +6,11 @@ export interface AsciicastRow {
data: string;
}
export interface AsciicastParseResult {
rows: AsciicastRow[];
inputRecorded: boolean;
}
export interface CommandBookmark {
id: number;
command: string;
@@ -39,13 +44,52 @@ const HIGH_RISK_PATTERNS = [
/\bkill\s+-9\b/i,
];
// parseAsciicast 解析 asciicast v2 录制数据 // Logan.Li
export function parseAsciicast(raw: string): AsciicastRow[] {
function isPrintableInput(data: string): boolean {
if (!data || data.length > 128) return false;
if (data.includes('\x1b')) return false;
return /^[\x08\x09\x0a\x0d\x20-\x7e]+$/.test(data);
}
function compactPrintable(data: string): string {
return data.replace(/[\r\n]/g, '');
}
// dedupeLegacyInputRows 兼容旧录像:去掉“客户端输入 + 远端 PTY 回显”造成的重复字符。 // Logan.Li
export function dedupeLegacyInputRows(rows: AsciicastRow[]): AsciicastRow[] {
const result: AsciicastRow[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const current = compactPrintable(row.data);
if (!current || !isPrintableInput(row.data)) {
result.push(row);
continue;
}
let echo = '';
for (let j = i + 1; j < rows.length && rows[j].time - row.time <= 1; j++) {
if (!isPrintableInput(rows[j].data)) break;
echo += compactPrintable(rows[j].data);
if (echo.length >= current.length) break;
}
if (echo.startsWith(current)) continue;
result.push(row);
}
return result;
}
// parseAsciicastDetailed 解析 asciicast v2,并识别 AIOps 新旧录制格式。 // Logan.Li
export function parseAsciicastDetailed(raw: string): AsciicastParseResult {
const lines = raw.split('\n').filter((l) => l.trim());
const rows: AsciicastRow[] = [];
for (const line of lines) {
let inputRecorded = true;
for (let idx = 0; idx < lines.length; idx++) {
const line = lines[idx];
try {
const parsed = JSON.parse(line) as unknown;
if (idx === 0 && parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const header = parsed as { aiops_input_recorded?: boolean };
inputRecorded = header.aiops_input_recorded !== false;
}
if (Array.isArray(parsed) && parsed.length >= 3) {
rows.push({
time: Number(parsed[0]) || 0,
@@ -57,7 +101,13 @@ export function parseAsciicast(raw: string): AsciicastRow[] {
// 跳过无法解析的行
}
}
return rows;
return { rows: inputRecorded ? dedupeLegacyInputRows(rows) : rows, inputRecorded };
}
// parseAsciicast 解析 asciicast v2 录制数据 // Logan.Li
export function parseAsciicast(raw: string): AsciicastRow[] {
const parsed = parseAsciicastDetailed(raw);
return parsed.rows;
}
// formatReplayTime 格式化回放时间 mm:ss 或 hh:mm:ss // Logan.Li
@@ -113,4 +163,4 @@ export function findRowIndexAt(rows: AsciicastRow[], time: number): number {
if (rows[idx].time > time) break;
}
return idx;
}
}
@@ -51,7 +51,11 @@ export function RemoteExecPage() {
const selCount = selIds.length;
const toggle = (id: number) => {
const next = new Set(selected);
next.has(id) ? next.delete(id) : next.add(id);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
setSelected(next);
};
const toggleAll = () => {
@@ -254,4 +258,4 @@ export function RemoteExecPage() {
</div>
</PageShell>
);
}
}
@@ -17,6 +17,7 @@ import {
const categoryLabel: Record<string, string> = {
monitor: '监控',
cleanup: '清理',
finops: '成本',
};
function statusBadge(status?: string) {
@@ -113,7 +114,7 @@ export function SystemTasksPanel({ refreshing }: Props) {
<ContentPanel>
<div className="flex items-center justify-between gap-3 mb-3 px-0.5">
<p className="text-xs dg-text-muted">
进程内置调度,不可编辑;清理类任务支持手动触发
进程内置调度,不可编辑;清理与成本类任务支持手动触发
</p>
<div className="flex items-center gap-2 text-[11px] shrink-0">
<span className="dg-text-muted">{tasks.length} 个任务</span>
@@ -16,15 +16,16 @@ interface Props {
}
function ansiToHtml(text: string): string {
const esc = String.fromCharCode(27);
const escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return escaped
.replace(/\x1b\[31m/g, '<span class="text-[var(--dg-red)]">')
.replace(/\x1b\[32m/g, '<span class="text-[var(--dg-green)]">')
.replace(/\x1b\[33m/g, '<span class="text-[var(--dg-yellow)]">')
.replace(/\x1b\[0m/g, '</span>');
.replaceAll(`${esc}[31m`, '<span class="text-[var(--dg-red)]">')
.replaceAll(`${esc}[32m`, '<span class="text-[var(--dg-green)]">')
.replaceAll(`${esc}[33m`, '<span class="text-[var(--dg-yellow)]">')
.replaceAll(`${esc}[0m`, '</span>');
}
export function TerminalOutput({
@@ -136,4 +137,4 @@ export function TerminalOutput({
</div>
</div>
);
}
}
@@ -13,12 +13,12 @@ import {
KeyRound, Plus, Settings2, RotateCw, Ban, Loader2, Rocket, Eye, EyeOff, Clock, Activity,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/utils';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { authApi } from '@/api';
import { getPublicApiBase } from '@/utils/request';
import { getSessionApiAccessKey, setSessionApiAccessKey } from '@/utils/storage';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
export type ApiKeyMeta = {
has_token: boolean;
@@ -71,7 +71,6 @@ function shortKeyId(id?: string) {
}
export function ApiAccessKeySection() {
const { t } = useTranslation();
const { toast } = useToast();
const confirm = useConfirm();
const apiBase = getPublicApiBase();
@@ -138,9 +137,9 @@ export function ApiAccessKeySection() {
});
setKeyMeta(res);
setFormOpen(false);
toast({ title: t('system.credentialUpdated2') });
} catch (e: any) {
toast({ title: t('alert.updateFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '凭证信息已更新' });
} catch (e: unknown) {
toast({ title: '更新失败', description: getErrorMessage(e), variant: 'destructive' });
} finally {
setActionLoading(false);
}
@@ -149,9 +148,9 @@ export function ApiAccessKeySection() {
const hasKey = !!keyMeta?.has_token;
const ok = await confirm({
title: hasKey ? t('system.rotateCredential') : t('system.cicdCredentialIssue'),
description: hasKey ? t('system.rotateHint') : t('system.plainOnceHint'),
confirmText: hasKey ? t('system.credentialRotate') : t('system.issue'),
title: hasKey ? '轮换凭证' : '签发凭证',
description: hasKey ? '旧凭证将立即作废,是否继续?' : '凭证明文仅显示一次,请妥善保存。',
confirmText: hasKey ? '轮换' : '签发',
variant: hasKey ? 'destructive' : 'default',
});
if (!ok) return;
@@ -179,9 +178,9 @@ export function ApiAccessKeySection() {
expired: false,
days_left: Math.ceil((new Date(res.expires_at).getTime() - Date.now()) / 86400000),
});
toast({ title: t('system.cicdCredentialIssued'), description: t('system.savedToBrowser') });
} catch (e: any) {
toast({ title: t('system.issueFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '凭证已签发', description: '已保存至本浏览器,请立即复制' });
} catch (e: unknown) {
toast({ title: '签发失败', description: getErrorMessage(e), variant: 'destructive' });
} finally {
setActionLoading(false);
}
@@ -189,9 +188,9 @@ export function ApiAccessKeySection() {
const handleRevoke = async () => {
const ok = await confirm({
title: t('system.cicdCredentialRevoke'),
description: t('system.revokeConfirmHint'),
confirmText: t('system.credentialRevoke'),
title: '吊销凭证',
description: '吊销后 Pipeline / curl 调用将全部失败,是否继续?',
confirmText: '吊销',
variant: 'destructive',
});
if (!ok) return;
@@ -201,15 +200,15 @@ export function ApiAccessKeySection() {
setPlainToken('');
setSessionApiAccessKey('');
setKeyMeta({ has_token: false });
toast({ title: t('system.credentialRevoked') });
} catch (e: any) {
toast({ title: t('system.revokeFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '凭证已吊销' });
} catch (e: unknown) {
toast({ title: '吊销失败', description: getErrorMessage(e), variant: 'destructive' });
} finally {
setActionLoading(false);
}
};
const authHeader = copyableToken ? `X-AIOps-Access-Key: ${copyableToken}` : t('system.authHeaderValue');
const authHeader = copyableToken ? `X-AIOps-Access-Key: ${copyableToken}` : 'X-AIOps-Access-Key: <你的访问凭证>';
const sampleCluster = 'test-k8s';
const sampleNs = 'default';
const sampleDep = 'demo-jar';
@@ -233,8 +232,8 @@ export function ApiAccessKeySection() {
<div>
<CardTitle className="text-base flex items-center gap-2 text-[var(--dg-text-primary)]">
<KeyRound className="h-4 w-4 text-[var(--dg-blue)]" />
{t('system.cicdCredential')}
<Badge className="text-[10px] bg-[var(--dg-blue)]/15 text-[var(--dg-blue)] border-[var(--dg-blue)]/30">{t('system.longTermValid')}</Badge>
CI/CD 访问凭证
<Badge className="text-[10px] bg-[var(--dg-blue)]/15 text-[var(--dg-blue)] border-[var(--dg-blue)]/30">长期有效</Badge>
</CardTitle>
<CardDescription className="text-xs mt-1">
每人唯一凭证,Deployment 发布脚本自动引用,不随登录会话过期。
@@ -242,7 +241,7 @@ export function ApiAccessKeySection() {
</div>
{!loading && !keyMeta?.has_token && (
<Button size="sm" onClick={openCreate} disabled={actionLoading}>
<Plus className="h-4 w-4 mr-1" />{t('system.cicdCredentialIssue')}
<Plus className="h-4 w-4 mr-1" />签发凭证
</Button>
)}
</CardHeader>
@@ -262,7 +261,7 @@ export function ApiAccessKeySection() {
<div className="flex-1 p-4 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1 min-w-0 flex-1">
<p className="text-[10px] uppercase tracking-wider text-[var(--dg-text-muted)]">{t('system.credentialString')}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--dg-text-muted)]">凭证字符串</p>
<div className="flex items-center gap-2">
<code className="flex-1 min-w-0 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md px-3 py-2 truncate block">
{showToken && copyableToken ? copyableToken : mask}
@@ -273,7 +272,7 @@ export function ApiAccessKeySection() {
size="icon"
className="h-9 w-9 shrink-0 border-[var(--dg-border)]"
onClick={() => setShowToken(!showToken)}
title={showToken ? t('common.hidden') : '显示'}
title={showToken ? '隐藏' : '显示'}
>
{showToken ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
@@ -285,25 +284,25 @@ export function ApiAccessKeySection() {
<Settings2 className="h-3 w-3 mr-1" />调整
</Button>
<Button variant="outline" size="sm" className="h-8 text-xs border-[var(--dg-border)]" onClick={openCreate} disabled={actionLoading}>
<RotateCw className="h-3 w-3 mr-1" />{t('system.credentialRotate')}
<RotateCw className="h-3 w-3 mr-1" />轮换
</Button>
<Button variant="outline" size="sm" className="h-8 text-xs border-red-500/30 dg-text-red" onClick={handleRevoke} disabled={actionLoading}>
<Ban className="h-3 w-3 mr-1" />{t('system.credentialRevoke')}
<Ban className="h-3 w-3 mr-1" />吊销
</Button>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-3 text-xs">
<div>
<p className="text-[var(--dg-text-muted)] mb-0.5">{t('system.usageDesc')}</p>
<p className="text-[var(--dg-text-secondary)]">{keyMeta.description || t('system.notFilled')}</p>
<p className="text-[var(--dg-text-muted)] mb-0.5">用途说明</p>
<p className="text-[var(--dg-text-secondary)]">{keyMeta.description || '未填写'}</p>
</div>
<div>
<p className="text-[var(--dg-text-muted)] mb-0.5">{t('system.credentialId')}</p>
<p className="text-[var(--dg-text-muted)] mb-0.5">凭证标识</p>
<p className="font-mono text-[var(--dg-text-secondary)]" title={keyMeta.key_id}>{shortKeyId(keyMeta.key_id)}</p>
</div>
<div>
<p className="text-[var(--dg-text-muted)] mb-0.5">{t('system.issueTime')}</p>
<p className="text-[var(--dg-text-muted)] mb-0.5">签发时间</p>
<p className="text-[var(--dg-text-secondary)]">{formatDateTime(keyMeta.created_at)}</p>
</div>
</div>
@@ -313,7 +312,7 @@ export function ApiAccessKeySection() {
<div className="flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1 text-[var(--dg-text-muted)]">
<Clock className="h-3 w-3" />
{isExpired ? t('system.userExpired') : daysLeft != null ? `剩余 ${daysLeft} 天` : t('system.apiKeyExpiry')}
{isExpired ? '已过期' : daysLeft != null ? `剩余 ${daysLeft} 天` : '有效期'}
</span>
<span className="text-[var(--dg-text-muted)]">
有效至 {formatDateTime(keyMeta.expires_at)}
@@ -333,10 +332,10 @@ export function ApiAccessKeySection() {
最近调用 {formatDateTime(keyMeta.last_used_at)}
</span>
{keyMeta.prefix && (
<span>{t('system.prefix')} <code className="text-[var(--dg-text-secondary)]">{keyMeta.prefix}</code></span>
<span>前缀 <code className="text-[var(--dg-text-secondary)]">{keyMeta.prefix}</code></span>
)}
{isExpired && (
<Badge variant="outline" className="text-[10px] border-red-500/40 dg-text-red">{t('system.needRenew')}</Badge>
<Badge variant="outline" className="text-[10px] border-red-500/40 dg-text-red">需续期或轮换</Badge>
)}
</div>
</div>
@@ -347,8 +346,8 @@ export function ApiAccessKeySection() {
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-[var(--dg-blue)]/10 flex items-center justify-center">
<KeyRound className="h-6 w-6 text-[var(--dg-blue)]" />
</div>
<p className="text-sm text-[var(--dg-text-secondary)]">{t('system.notIssuedCicd')}</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1 mb-4">{t('system.issueHint2')}</p>
<p className="text-sm text-[var(--dg-text-secondary)]">尚未签发 CI/CD 访问凭证</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1 mb-4">签发后可用于 Pipeline 触发镜像更新、滚动重启等操作</p>
<Button size="sm" onClick={openCreate} disabled={actionLoading}>
<Plus className="h-4 w-4 mr-1" />立即签发
</Button>
@@ -356,7 +355,7 @@ export function ApiAccessKeySection() {
)}
{keyMeta?.has_token && !copyableToken && (
<p className="text-[11px] dg-text-yellow/90">{t('system.notCachedHint')}</p>
<p className="text-[11px] dg-text-yellow/90">本机未缓存凭证明文,请点击「轮换」重新签发一次;签发后将自动填入 Deployment 发布 curl。</p>
)}
<div className="rounded-lg bg-[var(--dg-bg-tertiary)]/60 p-4 space-y-3">
@@ -364,18 +363,18 @@ export function ApiAccessKeySection() {
<Rocket className="h-3.5 w-3.5 dg-text-purple" />调用示例
</p>
<CodeSnippetBlock
label={t('kubernetes.deployment.updateImage')}
label="更新镜像"
code={updateCurl}
copyLabel={t('system.curlCopied')}
copyLabel="curl 已复制"
copyDisabled={!copyableToken}
copyDisabledTitle={t('system.issueAccessFirst')}
copyDisabledTitle="请先签发访问凭证"
/>
<CodeSnippetBlock
label={t('kubernetes.deployment.restart')}
label="滚动重启"
code={restartCurl}
copyLabel={t('system.curlCopied')}
copyLabel="curl 已复制"
copyDisabled={!copyableToken}
copyDisabledTitle={t('system.issueAccessFirst')}
copyDisabledTitle="请先签发访问凭证"
/>
</div>
</CardContent>
@@ -393,7 +392,7 @@ export function ApiAccessKeySection() {
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">{t('system.usageDesc')}</Label>
<Label className="text-xs text-[var(--dg-text-muted)]">用途说明</Label>
<Input
value={formDescription}
onChange={e => setFormDescription(e.target.value)}
@@ -402,7 +401,7 @@ export function ApiAccessKeySection() {
/>
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">{t('system.validUntil')}</Label>
<Label className="text-xs text-[var(--dg-text-muted)]">有效至</Label>
<Input
type="date"
value={formExpiresAt}
@@ -412,7 +411,7 @@ export function ApiAccessKeySection() {
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFormOpen(false)} className="border-[var(--dg-border)]">{t('common.cancel')}</Button>
<Button variant="outline" onClick={() => setFormOpen(false)} className="border-[var(--dg-border)]">取消</Button>
<Button onClick={handleFormSubmit} disabled={actionLoading || !formExpiresAt}>
{actionLoading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
{formMode === 'edit' ? '保存' : keyMeta?.has_token ? '轮换' : '签发'}
@@ -424,22 +423,22 @@ export function ApiAccessKeySection() {
<Dialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen}>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">{t('system.savedCredential')}</DialogTitle>
<DialogTitle className="text-[var(--dg-text-primary)]">请保存凭证明文</DialogTitle>
<DialogDescription className="text-xs text-[var(--dg-text-muted)]">
仅此一次机会查看完整字符串,关闭后无法再次获取。
</DialogDescription>
</DialogHeader>
<CodeSnippetBlock
label={t('cloud.accountCred')}
label="访问凭证"
code={revealedToken}
copyLabel={t('system.credentialCopied')}
copyLabel="凭证已复制"
className="mt-2"
/>
<DialogFooter>
<Button onClick={() => setTokenDialogOpen(false)}>{t('system.iSaved')}</Button>
<Button onClick={() => setTokenDialogOpen(false)}>我已保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
}
+34 -19
View File
@@ -7,17 +7,32 @@ import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { User, Shield, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/utils';
import { authApi } from '@/api';
import { useUserStore } from '@/store';
import { ApiAccessKeySection } from './ApiAccessKeySection';
import { PageShell } from '@/components/layout/PageShell';
import i18n from '@/i18n';
type ProfileRole = {
id?: string | number;
name?: string;
}
type Profile = {
username?: string;
nickname?: string;
real_name?: string;
email?: string;
phone?: string;
auth_source?: string;
roles?: Array<ProfileRole | string>;
}
export default function ProfilePage() {
const { toast } = useToast();
const storeUser = useUserStore(s => s.user);
const [profile, setProfile] = useState<any>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const [oldPwd, setOldPwd] = useState('');
@@ -36,7 +51,7 @@ export default function ProfilePage() {
useEffect(() => { load(); }, [load]);
const displayName = profile?.real_name || profile?.nickname || storeUser?.nickname || storeUser?.username || i18n.t('system.terminalUser');
const displayName = profile?.real_name || profile?.nickname || storeUser?.nickname || storeUser?.username || '用户';
const username = profile?.username || storeUser?.username || '';
const handleChangePassword = async () => {
@@ -44,11 +59,11 @@ export default function ProfilePage() {
setPwdSaving(true);
try {
await authApi.changePassword({ old_password: oldPwd, new_password: newPwd });
toast({ title: i18n.t('common.profilepagetsx.s8ba2f8b4') });
toast({ title: '密码已修改' });
setOldPwd('');
setNewPwd('');
} catch (e: any) {
toast({ title: i18n.t('common.profilepagetsx.s5badb3ba'), description: e?.message || String(e), variant: 'destructive' });
} catch (e: unknown) {
toast({ title: '修改失败', description: getErrorMessage(e), variant: 'destructive' });
} finally {
setPwdSaving(false);
}
@@ -62,12 +77,12 @@ export default function ProfilePage() {
subtitle={`@${username} · ${profile?.email || '—'}`}
icon={<User className="h-5 w-5" />}
loading={loading}
loadingLabel={i18n.t('monitor.logPanel.logResults.loading')}
loadingLabel="加载中..."
actions={(
<div className="flex gap-2 flex-wrap">
{(profile?.roles || []).map((r: any) => (
<Badge key={r.id || r.name} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs">
{r.name || r}
{(profile?.roles || []).map((r) => (
<Badge key={typeof r === 'string' ? r : r.id || r.name} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs">
{typeof r === 'string' ? r : r.name}
</Badge>
))}
</div>
@@ -81,11 +96,11 @@ export default function ProfilePage() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]"> {i18n.t('system.userName')} </span><span className="font-mono">{username}</span></div>
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]"> {i18n.t('system.userEmail')} </span><span>{profile?.email || '—'}</span></div>
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]"> {i18n.t('system.userMobile')} </span><span>{profile?.phone || '—'}</span></div>
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]"> {i18n.t('common.profilepagetsx.sfcef0de8')} </span>
<Badge variant="outline" className="text-xs">{profile?.auth_source === 'ldap' ? 'LDAP' : i18n.t('common.profilepagetsx.s8e7eca1f')}</Badge>
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]">用户名</span><span className="font-mono">{username}</span></div>
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]">邮箱</span><span>{profile?.email || '—'}</span></div>
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]">手机</span><span>{profile?.phone || '—'}</span></div>
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]">账号来源</span>
<Badge variant="outline" className="text-xs">{profile?.auth_source === 'ldap' ? 'LDAP' : '本地'}</Badge>
</div>
</CardContent>
</Card>
@@ -95,15 +110,15 @@ export default function ProfilePage() {
<CardTitle className="text-base flex items-center gap-2 text-[var(--dg-text-primary)]">
<Shield className="h-4 w-4 dg-text-green" />安全设置
</CardTitle>
<CardDescription className="text-xs"> {i18n.t('common.profilepagetsx.s6bfc4433')} </CardDescription>
<CardDescription className="text-xs">修改登录密码</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('common.profilepagetsx.sa806ce95')} </Label>
<Label className="text-xs text-[var(--dg-text-muted)]">当前密码</Label>
<Input type="password" value={oldPwd} onChange={e => setOldPwd(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('system.newPassword')} </Label>
<Label className="text-xs text-[var(--dg-text-muted)]">新密码</Label>
<Input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
<Button size="sm" onClick={handleChangePassword} disabled={pwdSaving || !oldPwd || newPwd.length < 6}>
@@ -117,4 +132,4 @@ export default function ProfilePage() {
<ApiAccessKeySection />
</PageShell>
);
}
}