diff --git a/frontend/src/components/LogViewer/README.md b/frontend/src/components/LogViewer/README.md new file mode 100644 index 00000000..5c41c74f --- /dev/null +++ b/frontend/src/components/LogViewer/README.md @@ -0,0 +1,114 @@ +# LogViewer 组件 + +专业的日志查看器组件,基于 Monaco Editor 实现,支持高性能虚拟滚动和语法高亮。 + +## 特性 + +- ✅ **高性能虚拟滚动**:基于 Monaco Editor,支持百万行日志 +- ✅ **语法高亮**:自动识别 INFO/WARN/ERROR 日志级别并高亮显示 +- ✅ **双模式支持**:结构化日志(带时间戳/级别)和纯文本日志 +- ✅ **自动滚动**:可选的自动滚动到底部功能 +- ✅ **工具栏**:内置刷新和下载按钮 +- ✅ **主题支持**:支持亮色和暗色主题 + +## 使用示例 + +### 结构化日志模式(Dashboard 部署日志) + +```tsx +import LogViewer from '@/components/LogViewer'; +import type { StructuredLog } from '@/components/LogViewer/types'; + +const logs: StructuredLog[] = [ + { + sequenceId: 1, + timestamp: '2025-12-13T10:30:00.123Z', + level: 'INFO', + message: '开始部署应用...', + }, + { + sequenceId: 2, + timestamp: '2025-12-13T10:30:05.456Z', + level: 'WARN', + message: '检测到配置文件缺失,使用默认配置', + }, + { + sequenceId: 3, + timestamp: '2025-12-13T10:30:10.789Z', + level: 'ERROR', + message: '部署失败:连接超时', + }, +]; + + +``` + +### 纯文本模式(K8s Pod 日志) + +```tsx +import LogViewer from '@/components/LogViewer'; + +const logContent = ` +2025-12-13 10:30:00 Starting application... +2025-12-13 10:30:01 Loading configuration... +2025-12-13 10:30:02 Server started on port 8080 +`; + + +``` + +## Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `logs` | `StructuredLog[]` | - | 结构化日志数据(与 content 二选一) | +| `content` | `string` | - | 纯文本日志内容(与 logs 二选一) | +| `loading` | `boolean` | `false` | 加载状态 | +| `onRefresh` | `() => void` | - | 刷新回调函数 | +| `onDownload` | `() => void` | - | 下载回调函数 | +| `height` | `string \| number` | `'100%'` | 组件高度 | +| `theme` | `'light' \| 'vs-dark'` | `'light'` | 主题 | +| `showToolbar` | `boolean` | `true` | 是否显示工具栏 | +| `autoScroll` | `boolean` | `true` | 是否自动滚动到底部 | +| `className` | `string` | - | 自定义类名 | + +## 类型定义 + +```typescript +export type LogLevel = 'INFO' | 'WARN' | 'ERROR'; + +export interface StructuredLog { + sequenceId: number; + timestamp: string; + level: LogLevel; + message: string; +} +``` + +## 注意事项 + +1. **性能优化**:Monaco Editor 内置虚拟滚动,可高效处理大量日志 +2. **模式选择**:`logs` 和 `content` 二选一,优先使用 `logs` +3. **自动滚动**:首次加载和内容更新时会自动滚动到底部(如果 `autoScroll=true`) +4. **下载功能**:下载的文件名格式为 `log-{timestamp}.log` +5. **语法高亮**:仅在结构化日志模式下生效 + +## 技术实现 + +- **Monaco Editor**:使用 `@monaco-editor/react` 封装 +- **自定义语言**:定义 `deploylog` 语言,支持日志级别语法高亮 +- **自定义主题**:`deploylog-theme` (亮色) 和 `deploylog-theme-dark` (暗色) +- **格式化工具**:`formatters.ts` 提供日志格式化函数 diff --git a/frontend/src/components/LogViewer/formatters.ts b/frontend/src/components/LogViewer/formatters.ts new file mode 100644 index 00000000..38b40773 --- /dev/null +++ b/frontend/src/components/LogViewer/formatters.ts @@ -0,0 +1,65 @@ +import dayjs from 'dayjs'; +import type { StructuredLog } from './types'; + +/** + * 计算行号宽度(字符数) + */ +export const calculateLineNumberWidth = (totalLines: number): number => { + return Math.max(4, String(totalLines).length + 1); +}; + +/** + * 格式化结构化日志为Monaco显示文本 + * 格式:行号 | 时间戳 | 级别 | 消息 + */ +export const formatStructuredLogs = (logs: StructuredLog[]): string => { + if (!logs || logs.length === 0) { + return ''; + } + + const lineNumWidth = calculateLineNumberWidth(logs.length); + + return logs.map((log, index) => { + const lineNum = String(index + 1).padStart(lineNumWidth, ' '); + const timestamp = dayjs(log.timestamp).format('YYYY-MM-DD HH:mm:ss.SSS'); + const level = log.level.padEnd(5, ' '); // INFO/WARN/ERROR 统一5个字符 + + return `${lineNum} | ${timestamp} | ${level} | ${log.message}`; + }).join('\n'); +}; + +/** + * 格式化纯文本日志 + * 保持原样,不做额外处理 + */ +export const formatPlainText = (content: string): string => { + return content || ''; +}; + +/** + * 格式化纯文本日志并添加行号 + */ +export const formatPlainTextWithLineNumbers = (content: string): string => { + if (!content) return ''; + + const lines = content.split('\n'); + const lineNumWidth = calculateLineNumberWidth(lines.length); + + return lines.map((line, index) => { + const lineNum = String(index + 1).padStart(lineNumWidth, ' '); + return `${lineNum} | ${line}`; + }).join('\n'); +}; + +/** + * 从格式化的日志文本生成下载内容 + */ +export const generateDownloadContent = (logs?: StructuredLog[], content?: string): string => { + if (logs && logs.length > 0) { + return formatStructuredLogs(logs); + } + if (content) { + return content; + } + return ''; +}; diff --git a/frontend/src/components/LogViewer/index.tsx b/frontend/src/components/LogViewer/index.tsx new file mode 100644 index 00000000..f1c8e9c5 --- /dev/null +++ b/frontend/src/components/LogViewer/index.tsx @@ -0,0 +1,207 @@ +import React, { useEffect, useRef, useMemo } from 'react'; +import Editor, { OnMount } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor'; +import { Button } from '@/components/ui/button'; +import { RefreshCw, Download, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatStructuredLogs, formatPlainText, generateDownloadContent } from './formatters'; +import { registerLogLanguage } from './logLanguage'; +import type { LogViewerProps, MonacoLayoutConfig } from './types'; + +/** + * Monaco Editor默认布局配置 + */ +const DEFAULT_MONACO_LAYOUT: Required = { + lineNumbersMinChars: 3, + lineDecorationsWidth: 20, + glyphMargin: false, +}; + +/** + * LogViewer - 专业的日志查看器组件 + * + * 基于Monaco Editor实现,支持: + * - 虚拟滚动(高性能,支持百万行) + * - 语法高亮(INFO/WARN/ERROR) + * - 结构化日志和纯文本两种模式 + * - 自动滚动到底部 + * - 刷新和下载功能 + */ +const LogViewer: React.FC = ({ + logs, + content, + loading = false, + onRefresh, + onDownload, + height = '100%', + theme = 'light', + showToolbar = true, + customToolbar, + showLineNumbers = true, + autoScroll = true, + className, + monacoLayout, +}) => { + const editorRef = useRef(null); + const isStructuredMode = !!logs; + + // 合并默认布局配置和自定义配置 + const layoutConfig = { ...DEFAULT_MONACO_LAYOUT, ...monacoLayout }; + + // 格式化日志内容 + const formattedContent = useMemo(() => { + if (logs && logs.length > 0) { + return formatStructuredLogs(logs); + } + if (content) { + // 纯文本直接显示,Monaco会提供行号 + return formatPlainText(content); + } + return ''; + }, [logs, content]); + + // 注册自定义语言(只执行一次) + useEffect(() => { + registerLogLanguage(); + }, []); + + // Editor挂载回调 + const handleEditorDidMount: OnMount = (editor) => { + editorRef.current = editor; + }; + + // 自动滚动到底部 + useEffect(() => { + if (autoScroll && editorRef.current && formattedContent) { + // 延迟执行,确保内容已渲染 + setTimeout(() => { + const editor = editorRef.current; + if (editor) { + const lineCount = editor.getModel()?.getLineCount() || 0; + editor.revealLine(lineCount); + } + }, 100); + } + }, [formattedContent, autoScroll]); + + // 处理下载 + const handleDownload = () => { + const downloadContent = generateDownloadContent(logs, content); + if (!downloadContent) return; + + const blob = new Blob([downloadContent], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `log-${new Date().getTime()}.log`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + onDownload?.(); + }; + + // Monaco Editor配置 + const editorOptions: monaco.editor.IStandaloneEditorConstructionOptions = { + readOnly: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: 'on', + automaticLayout: true, + fontSize: 12, + fontFamily: "'Menlo', 'Monaco', 'Courier New', monospace", + lineNumbers: showLineNumbers ? 'on' : 'off', + glyphMargin: layoutConfig.glyphMargin, + folding: false, + lineDecorationsWidth: showLineNumbers ? layoutConfig.lineDecorationsWidth : 0, + lineNumbersMinChars: showLineNumbers ? layoutConfig.lineNumbersMinChars : 0, + renderLineHighlight: 'none', + scrollbar: { + vertical: 'visible', + horizontal: 'visible', + useShadows: false, + verticalScrollbarSize: 10, + horizontalScrollbarSize: 10, + }, + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + overviewRulerBorder: false, + }; + + // 确定使用的语言和主题 + const editorLanguage = isStructuredMode ? 'deploylog' : 'plaintext'; + const editorTheme = isStructuredMode + ? (theme === 'vs-dark' ? 'deploylog-theme-dark' : 'deploylog-theme') + : theme; + + return ( +
+ {/* 工具栏 */} + {showToolbar && ( + customToolbar ? ( +
{customToolbar}
+ ) : ( +
+
+ {logs && logs.length > 0 && ( + 共 {logs.length} 条日志 + )} +
+
+ {onDownload && ( + + )} + {onRefresh && ( + + )} +
+
+ ) + )} + + {/* 日志内容区域 */} +
+ {loading && !formattedContent ? ( +
+
+ + 加载日志中... +
+
+ ) : formattedContent ? ( + + ) : ( +
+ 暂无日志 +
+ )} +
+
+ ); +}; + +export default LogViewer; diff --git a/frontend/src/components/LogViewer/logLanguage.ts b/frontend/src/components/LogViewer/logLanguage.ts new file mode 100644 index 00000000..7df872b3 --- /dev/null +++ b/frontend/src/components/LogViewer/logLanguage.ts @@ -0,0 +1,72 @@ +import * as monaco from 'monaco-editor'; + +/** + * 注册自定义日志语言和主题 + */ +export const registerLogLanguage = () => { + // 检查是否已注册 + const languages = monaco.languages.getLanguages(); + if (languages.some(lang => lang.id === 'deploylog')) { + return; + } + + // 注册语言 + monaco.languages.register({ id: 'deploylog' }); + + // 定义语法高亮规则 + monaco.languages.setMonarchTokensProvider('deploylog', { + tokenizer: { + root: [ + // 行号(数字 + |) + [/^\s*\d+\s*\|/, 'log.linenumber'], + + // 时间戳(YYYY-MM-DD HH:mm:ss.SSS) + [/\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}/, 'log.timestamp'], + + // 日志级别 + [/\bINFO\b/, 'log.info'], + [/\bWARN\b/, 'log.warn'], + [/\bERROR\b/, 'log.error'], + + // 分隔符 + [/\|/, 'log.separator'], + ], + }, + }); + + // 定义自定义主题 + monaco.editor.defineTheme('deploylog-theme', { + base: 'vs', + inherit: true, + rules: [ + { token: 'log.linenumber', foreground: '999999' }, + { token: 'log.timestamp', foreground: '666666' }, + { token: 'log.separator', foreground: 'cccccc' }, + { token: 'log.info', foreground: '2563eb', fontStyle: 'bold' }, + { token: 'log.warn', foreground: 'ca8a04', fontStyle: 'bold' }, + { token: 'log.error', foreground: 'dc2626', fontStyle: 'bold' }, + ], + colors: { + 'editor.background': '#fafafa', + 'editor.lineHighlightBackground': '#f5f5f5', + }, + }); + + // 定义暗色主题 + monaco.editor.defineTheme('deploylog-theme-dark', { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'log.linenumber', foreground: '666666' }, + { token: 'log.timestamp', foreground: '999999' }, + { token: 'log.separator', foreground: '555555' }, + { token: 'log.info', foreground: '60a5fa', fontStyle: 'bold' }, + { token: 'log.warn', foreground: 'fbbf24', fontStyle: 'bold' }, + { token: 'log.error', foreground: 'f87171', fontStyle: 'bold' }, + ], + colors: { + 'editor.background': '#1e1e1e', + 'editor.lineHighlightBackground': '#2a2a2a', + }, + }); +}; diff --git a/frontend/src/components/LogViewer/types.ts b/frontend/src/components/LogViewer/types.ts new file mode 100644 index 00000000..288ab1a0 --- /dev/null +++ b/frontend/src/components/LogViewer/types.ts @@ -0,0 +1,70 @@ +/** + * 日志级别 + */ +export type LogLevel = 'INFO' | 'WARN' | 'ERROR'; + +/** + * Monaco Editor布局配置 + */ +export interface MonacoLayoutConfig { + /** 行号区域最小字符数 */ + lineNumbersMinChars?: number; + /** 装饰区域宽度(行号和内容之间的间距) */ + lineDecorationsWidth?: number; + /** 是否显示字形边距 */ + glyphMargin?: boolean; +} + +/** + * 结构化日志条目 + */ +export interface StructuredLog { + sequenceId: number; + timestamp: string; + level: LogLevel; + message: string; +} + +/** + * LogViewer组件Props + */ +export interface LogViewerProps { + /** 结构化日志数据(与content二选一) */ + logs?: StructuredLog[]; + + /** 纯文本日志内容(与logs二选一) */ + content?: string; + + /** 加载状态 */ + loading?: boolean; + + /** 刷新回调 */ + onRefresh?: () => void; + + /** 下载回调 */ + onDownload?: () => void; + + /** 高度 */ + height?: string | number; + + /** 主题 */ + theme?: 'light' | 'vs-dark'; + + /** 是否显示工具栏 */ + showToolbar?: boolean; + + /** 自定义工具栏(如果提供,将替换默认工具栏) */ + customToolbar?: React.ReactNode; + + /** 是否显示行号(仅对纯文本有效,结构化日志始终显示) */ + showLineNumbers?: boolean; + + /** 是否自动滚动到底部 */ + autoScroll?: boolean; + + /** 自定义类名 */ + className?: string; + + /** Monaco Editor布局配置 */ + monacoLayout?: MonacoLayoutConfig; +} diff --git a/frontend/src/pages/Dashboard/components/DeployNodeLogDialog.tsx b/frontend/src/pages/Dashboard/components/DeployNodeLogDialog.tsx index 8da0ad7d..31c9a14d 100644 --- a/frontend/src/pages/Dashboard/components/DeployNodeLogDialog.tsx +++ b/frontend/src/pages/Dashboard/components/DeployNodeLogDialog.tsx @@ -8,11 +8,11 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Loader2, AlertCircle, Clock, FileText, RefreshCw } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import { AlertCircle, Clock, FileText } from 'lucide-react'; import { getDeployNodeLogs } from '../service'; -import type { DeployNodeLogDTO, LogLevel } from '../types'; -import dayjs from 'dayjs'; +import type { DeployNodeLogDTO } from '../types'; +import LogViewer from '@/components/LogViewer'; +import type { StructuredLog } from '@/components/LogViewer/types'; interface DeployNodeLogDialogProps { open: boolean; @@ -22,14 +22,7 @@ interface DeployNodeLogDialogProps { nodeName?: string; } -const getLevelClass = (level: LogLevel): string => { - const levelMap: Record = { - INFO: 'text-blue-600', - WARN: 'text-yellow-600', - ERROR: 'text-red-600', - }; - return levelMap[level] || 'text-gray-600'; -}; + const DeployNodeLogDialog: React.FC = ({ open, @@ -40,7 +33,6 @@ const DeployNodeLogDialog: React.FC = ({ }) => { const [loading, setLoading] = useState(false); const [logData, setLogData] = useState(null); - const scrollAreaRef = useRef(null); const intervalRef = useRef(null); const fetchLogs = async () => { @@ -51,15 +43,6 @@ const DeployNodeLogDialog: React.FC = ({ const response = await getDeployNodeLogs(processInstanceId, nodeId); setLogData(response); - // 如果日志未过期且有新日志,滚动到底部 - if (response && !response.expired && response.logs.length > 0) { - setTimeout(() => { - if (scrollAreaRef.current) { - scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight; - } - }, 100); - } - // 如果日志已过期,停止轮询 if (response?.expired) { if (intervalRef.current) { @@ -111,29 +94,11 @@ const DeployNodeLogDialog: React.FC = ({ - {/* 工具栏 */} -
-
- {logData && !logData.expired && ( - 共 {logData.logs.length} 条日志 - )} -
- -
- {/* 日志内容区域 */} {loading && !logData ? (
- +
加载日志中...
@@ -143,66 +108,20 @@ const DeployNodeLogDialog: React.FC = ({

日志已过期或不存在

日志通常保留 7 天,请检查部署时间

+ ) : logData?.logs && logData.logs.length > 0 ? ( + ) : ( -
-
- {logData?.logs && logData.logs.length > 0 ? ( - (() => { - // 计算行号宽度(只计算一次) - const lineNumWidth = Math.max(4, String(logData.logs.length).length + 1); - - return logData.logs.map((log, index) => { - // 格式化时间戳为可读格式:2025-11-07 16:27:41.494 - const timestamp = dayjs(log.timestamp).format('YYYY-MM-DD HH:mm:ss.SSS'); - - return ( -
- {/* 行号 - 动态宽度,右对齐 */} - - {index + 1} - - - {/* 时间戳 - 可读格式,23个字符 (2025-11-07 16:27:41.494) */} - - {timestamp} - - - {/* 日志级别 - 5个字符,右对齐 */} - - {log.level} - - - {/* 日志消息 - 不换行显示 */} - - {log.message} - -
- ); - }); - })() - ) : ( -
- -

暂无日志

-

任务可能尚未开始或未产生日志

-
- )} -
+
+ +

暂无日志

+

任务可能尚未开始或未产生日志

)} diff --git a/frontend/src/pages/Resource/K8s/List/components/DeploymentRow.tsx b/frontend/src/pages/Resource/K8s/List/components/DeploymentRow.tsx index 7228d637..ac5479f7 100644 --- a/frontend/src/pages/Resource/K8s/List/components/DeploymentRow.tsx +++ b/frontend/src/pages/Resource/K8s/List/components/DeploymentRow.tsx @@ -1,11 +1,39 @@ import React, { useState, useEffect } from 'react'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { ChevronDown, ChevronRight, Loader2, Layers } from 'lucide-react'; +import { + ChevronDown, + ChevronRight, + Loader2, + Layers, + RotateCw, + Maximize2, + FileCode, + Tag, + Trash2, +} from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogBody, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { useToast } from '@/components/ui/use-toast'; import { HealthBar } from './HealthBar'; import { PodRow } from './PodRow'; -import { OperationMenu } from './OperationMenu'; +import { PodRowSkeleton } from './PodRowSkeleton'; +import { YamlViewerDialog } from './YamlViewerDialog'; +import { restartK8sDeployment, scaleK8sDeployment, deleteK8sDeployment } from '../service'; import type { K8sDeploymentResponse, K8sPodResponse } from '../types'; import { getDeploymentHealth, HealthStatusLabels } from '../types'; import { getK8sPodsByDeployment } from '../service'; @@ -21,7 +49,7 @@ interface DeploymentRowProps { deployment: K8sDeploymentResponse; isExpanded: boolean; onToggleExpand: () => void; - onViewLogs: (pod: K8sPodResponse) => void; + onViewLogs: (pod: K8sPodResponse, deploymentId: number) => void; onRefresh?: () => void; } @@ -34,33 +62,46 @@ export const DeploymentRow: React.FC = ({ }) => { const { toast } = useToast(); const [pods, setPods] = useState([]); + const [initialLoading, setInitialLoading] = useState(true); + const [scaleDialogOpen, setScaleDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [yamlDialogOpen, setYamlDialogOpen] = useState(false); + const [labelDialogOpen, setLabelDialogOpen] = useState(false); + const [replicas, setReplicas] = useState(deployment.replicas?.toString() || '1'); const [loading, setLoading] = useState(false); const health = getDeploymentHealth(deployment); - const healthLabel = HealthStatusLabels[health]; - const loadPods = async () => { + const loadPods = async (isInitial = false) => { if (!isExpanded) return; try { - setLoading(true); const data = await getK8sPodsByDeployment(deployment.id); setPods(data || []); + if (isInitial) { + setInitialLoading(false); + } } catch (error) { console.error('加载Pod失败:', error); + if (isInitial) { + setInitialLoading(false); + } toast({ title: '加载失败', description: '加载Pod列表失败', variant: 'destructive', }); - } finally { - setLoading(false); } }; // 展开时加载Pod useEffect(() => { if (isExpanded) { - loadPods(); + setInitialLoading(true); + loadPods(true); + } else { + // 收起时重置状态 + setInitialLoading(true); + setPods([]); } }, [isExpanded]); @@ -70,23 +111,106 @@ export const DeploymentRow: React.FC = ({ const ready = deployment.readyReplicas ?? 0; const desired = deployment.replicas ?? 0; + // 重启Deployment + const handleRestart = async () => { + try { + setLoading(true); + await restartK8sDeployment(deployment.id); + toast({ + title: '重启成功', + description: `Deployment ${deployment.deploymentName} 已重启`, + }); + onRefresh?.(); + } catch (error: any) { + toast({ + title: '重启失败', + description: error.message || '重启Deployment失败', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + // 扩缩容 + const handleScale = async () => { + const newReplicas = parseInt(replicas); + if (isNaN(newReplicas) || newReplicas < 0) { + toast({ + title: '参数错误', + description: '副本数必须是非负整数', + variant: 'destructive', + }); + return; + } + + try { + setLoading(true); + await scaleK8sDeployment(deployment.id, newReplicas); + toast({ + title: '扩缩容成功', + description: `Deployment ${deployment.deploymentName} 副本数已调整为 ${newReplicas}`, + }); + onRefresh?.(); + } catch (error: any) { + toast({ + title: '扩缩容失败', + description: error.message || '扩缩容失败', + variant: 'destructive', + }); + } finally { + setLoading(false); + setScaleDialogOpen(false); + } + }; + + // 删除Deployment + const handleDelete = async () => { + try { + setLoading(true); + await deleteK8sDeployment(deployment.id); + toast({ + title: '删除成功', + description: `Deployment ${deployment.deploymentName} 已删除`, + }); + onRefresh?.(); + } catch (error: any) { + toast({ + title: '删除失败', + description: error.message || '删除Deployment失败', + variant: 'destructive', + }); + } finally { + setLoading(false); + setDeleteDialogOpen(false); + } + }; + + const hasReplicas = (deployment.replicas ?? 0) > 0; + return ( <> - +
- + {hasReplicas ? ( + + ) : ( +
+ )} {deployment.deploymentName}
@@ -95,14 +219,7 @@ export const DeploymentRow: React.FC = ({ - + {ready}/{desired} @@ -117,18 +234,91 @@ export const DeploymentRow: React.FC = ({ {deployment.k8sUpdateTime ? dayjs(deployment.k8sUpdateTime).fromNow() : '-'} - - + e.stopPropagation()}> + +
+ + + + + 重启Deployment + + + + + + + 扩缩容 + + + + + + + 查看YAML + + + + + + + 查看标签 + + + + + + + 删除 + +
+
{isExpanded && ( <> - {loading ? ( - - - - - + {initialLoading ? ( + <> + {Array.from({ length: deployment.replicas || 1 }).map((_, index) => ( + + ))} + ) : pods.length === 0 ? ( @@ -140,12 +330,143 @@ export const DeploymentRow: React.FC = ({ )) )} )} + + {/* 扩缩容对话框 */} + + + + 扩缩容 + + 调整 {deployment.deploymentName} 的副本数量 + + + +
+
+ +
+ + setReplicas(e.target.value)} + className="text-center" + /> + +
+
+

+ 当前副本数: {deployment.replicas || 0} +

+
+
+ + + + +
+
+ + {/* 删除确认对话框 */} + + + + 确认删除 + + 确定要删除 Deployment {deployment.deploymentName} 吗? + 此操作不可撤销。 + + + + + + + + + + {/* YAML查看器 */} + + + {/* 标签查看对话框 */} + + + + + + 标签列表 + + +
+ {deployment.labels && Object.keys(deployment.labels).length > 0 ? ( +
+ {Object.entries(deployment.labels).map(([key, value]) => ( +
+
+ + {key} + + : + {value} +
+
+ ))} +
+ ) : ( +
暂无标签
+ )} +
+
+
); }; diff --git a/frontend/src/pages/Resource/K8s/List/components/DeploymentTable.tsx b/frontend/src/pages/Resource/K8s/List/components/DeploymentTable.tsx index ee7caa52..64ffef3b 100644 --- a/frontend/src/pages/Resource/K8s/List/components/DeploymentTable.tsx +++ b/frontend/src/pages/Resource/K8s/List/components/DeploymentTable.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { DeploymentRow } from './DeploymentRow'; import type { K8sDeploymentResponse, K8sPodResponse } from '../types'; @@ -7,7 +7,7 @@ interface DeploymentTableProps { deployments: K8sDeploymentResponse[]; expandedIds: Set; onToggleExpand: (id: number) => void; - onViewLogs: (pod: K8sPodResponse) => void; + onViewLogs: (pod: K8sPodResponse, deploymentId: number) => void; onRefresh?: () => void; } @@ -27,8 +27,8 @@ export const DeploymentTable: React.FC = ({ } return ( -
- +
+
名称 @@ -37,7 +37,7 @@ export const DeploymentTable: React.FC = ({ 镜像 重启 更新时间 - 操作 + 操作 @@ -52,7 +52,7 @@ export const DeploymentTable: React.FC = ({ /> ))} -
+
); }; diff --git a/frontend/src/pages/Resource/K8s/List/components/PodLogDialog.tsx b/frontend/src/pages/Resource/K8s/List/components/PodLogDialog.tsx new file mode 100644 index 00000000..149f6300 --- /dev/null +++ b/frontend/src/pages/Resource/K8s/List/components/PodLogDialog.tsx @@ -0,0 +1,206 @@ +import React, { useEffect, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogBody, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { FileText, RefreshCw, Download } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { getK8sPodLogs, getK8sPodDetail } from '../service'; +import { useToast } from '@/components/ui/use-toast'; +import LogViewer from '@/components/LogViewer'; + +interface PodLogDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + deploymentId: number; + podName: string; +} + +const PodLogDialog: React.FC = ({ + open, + onOpenChange, + deploymentId, + podName, +}) => { + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + const [loadingPod, setLoadingPod] = useState(false); + const [logContent, setLogContent] = useState(''); + const [selectedContainer, setSelectedContainer] = useState(''); + const [containers, setContainers] = useState([]); + + // 打开对话框时获取Pod详情,获取最新的容器列表 + useEffect(() => { + if (open && deploymentId && podName) { + const fetchPodDetail = async () => { + setLoadingPod(true); + try { + const pod = await getK8sPodDetail(deploymentId, podName); + const containerNames = pod.containers.map(c => c.name); + setContainers(containerNames); + if (containerNames.length > 0) { + setSelectedContainer(containerNames[0]); + } + } catch (error: any) { + console.error('获取Pod详情失败:', error); + toast({ + title: '获取Pod详情失败', + description: error.message || '无法获取Pod容器信息', + variant: 'destructive', + }); + } finally { + setLoadingPod(false); + } + }; + fetchPodDetail(); + } + }, [open, deploymentId, podName]); + + const fetchLogs = async () => { + if (!deploymentId || !podName) return; + + setLoading(true); + try { + // 如果有选中的容器,传递容器名;否则不传,让后端使用默认容器 + const params: any = { + tail: 1000, // 获取最后1000行 + }; + if (selectedContainer) { + params.container = selectedContainer; + } + + const logs = await getK8sPodLogs(deploymentId, podName, params); + setLogContent(logs || '暂无日志'); + } catch (error: any) { + console.error('获取Pod日志失败:', error); + toast({ + title: '获取日志失败', + description: error.message || '无法获取Pod日志', + variant: 'destructive', + }); + setLogContent('获取日志失败'); + } finally { + setLoading(false); + } + }; + + // 打开对话框或切换容器时加载日志 + useEffect(() => { + if (open && selectedContainer) { + setLogContent(''); + fetchLogs(); + } + }, [open, selectedContainer, deploymentId, podName]); + + // 下载日志回调 + const handleDownload = () => { + toast({ + title: '下载成功', + description: '日志文件已保存', + }); + }; + + return ( + + + + + + Pod日志 - {podName} + + + + + {loadingPod ? ( +
+
+
+ 加载Pod信息中... +
+
+ ) : ( + + {/* 左侧:容器选择器 */} +
+ {containers.length > 1 ? ( +
+ 容器: + +
+ ) : containers.length === 1 ? ( +
+ + 容器: {containers[0]} + +
+ ) : null} +
+ + {/* 右侧:操作按钮 */} +
+ + +
+
+ } + /> + )} +
+ + + + +
+
+ ); +}; + +export default PodLogDialog; diff --git a/frontend/src/pages/Resource/K8s/List/components/PodRow.tsx b/frontend/src/pages/Resource/K8s/List/components/PodRow.tsx index 1f13bb5f..f6f06ba5 100644 --- a/frontend/src/pages/Resource/K8s/List/components/PodRow.tsx +++ b/frontend/src/pages/Resource/K8s/List/components/PodRow.tsx @@ -1,105 +1,273 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { FileText, Copy, Check, Box } from 'lucide-react'; +import { FileText, Box, FileCode, Tag, Copy, Check } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useToast } from '@/components/ui/use-toast'; +import { YamlViewerDialog } from './YamlViewerDialog'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogBody, +} from '@/components/ui/dialog'; import type { K8sPodResponse } from '../types'; import { PodPhaseLabels } from '../types'; interface PodRowProps { pod: K8sPodResponse; - onViewLogs: (pod: K8sPodResponse) => void; + deploymentId: number; + onViewLogs: (pod: K8sPodResponse, deploymentId: number) => void; } -export const PodRow: React.FC = ({ pod, onViewLogs }) => { +export const PodRow: React.FC = ({ pod, deploymentId, onViewLogs }) => { const { toast } = useToast(); - const [copiedField, setCopiedField] = React.useState(null); - const phaseLabel = PodPhaseLabels[pod.phase]; + const [yamlDialogOpen, setYamlDialogOpen] = useState(false); + const [labelDialogOpen, setLabelDialogOpen] = useState(false); + const [copiedImage, setCopiedImage] = useState(false); + + const phaseLabel = PodPhaseLabels[pod.phase] || PodPhaseLabels['UNKNOWN'] || { + label: '未知', + variant: 'secondary' as const, + color: 'bg-gray-600 hover:bg-gray-700' + }; - const handleCopy = (text: string, field: string) => { - navigator.clipboard.writeText(text).then(() => { - setCopiedField(field); - toast({ - title: '已复制', - description: `已复制${field}到剪贴板`, + // 获取主容器信息 + const mainContainer = pod.containers[0]; + + // 复制镜像名称 + const handleCopyImage = () => { + if (mainContainer?.image) { + navigator.clipboard.writeText(mainContainer.image).then(() => { + setCopiedImage(true); + toast({ + title: '已复制', + description: '镜像名称已复制到剪贴板', + }); + setTimeout(() => setCopiedImage(false), 2000); }); - setTimeout(() => setCopiedField(null), 2000); - }); + } }; return ( - - -
- {/* Pod名称 */} -
- - {pod.name} -
+ <> + + {/* Pod信息列(占6列) */} + +
+ {/* 第一行:Pod名称 + 状态 */} +
+ {/* Pod名称 - 固定宽度 */} +
+ + + {pod.name} + +
- {/* 状态 */} -
- - {phaseLabel.label} - -
- - {/* 就绪状态 */} -
- - {pod.ready ? '就绪' : '未就绪'} - -
- - {/* 重启次数 */} -
- {pod.restartCount > 0 ? ( - - {pod.restartCount}次 + {/* 状态徽章 */} + + {phaseLabel.label} - ) : ( - - - )} -
- {/* Pod IP */} -
- {pod.podIP || '-'} - {pod.podIP && ( -
+ + {/* 第二行:详细信息 */} +
+ {/* 节点 - 固定宽度 */} +
+ 节点: + {pod.nodeName || '-'} +
+ + | + + {/* IP - 固定宽度 */} +
+ IP: + {pod.podIP || '-'} +
+ + {mainContainer && ( + <> + | + + {/* 镜像 - 固定宽度 */} +
+ 镜像: + + {mainContainer.image} + + +
+ + {/* 资源 - 固定宽度 */} +
+ 资源: + + CPU {mainContainer.cpuLimit || '-'} / 内存 {mainContainer.memoryLimit || '-'} + +
+ + )} +
+
+ + + {/* 操作列(占1列,固定右侧) */} + + +
+ + + + + 查看日志 + + + + + + + 查看YAML + + + + + + + 查看标签 + +
+
+ + + + {/* YAML查看器 */} + + + {/* 标签和注解对话框 - 使用Tab切换 */} + + + + + + Pod 标签和注解 + + + + + + 标签 (Labels) + 注解 (Annotations) + + + + {pod.labels && Object.keys(pod.labels).length > 0 ? ( +
+ {Object.entries(pod.labels).map(([key, value]) => ( +
+
+ + {key} + + : + {value} +
+
+ ))} +
) : ( - +
暂无标签
)} - - )} -
- - {/* 节点 */} -
- {pod.nodeName || '-'} -
- - {/* 日志按钮 */} -
- -
-
- - + + + + {pod.annotations && Object.keys(pod.annotations).length > 0 ? ( +
+ {Object.entries(pod.annotations).map(([key, value]) => ( +
+
+ + {key} + + : + {value} +
+
+ ))} +
+ ) : ( +
暂无注解
+ )} +
+ + + + + ); }; diff --git a/frontend/src/pages/Resource/K8s/List/components/PodRowSkeleton.tsx b/frontend/src/pages/Resource/K8s/List/components/PodRowSkeleton.tsx new file mode 100644 index 00000000..096d8bdd --- /dev/null +++ b/frontend/src/pages/Resource/K8s/List/components/PodRowSkeleton.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Skeleton } from '@/components/ui/skeleton'; + +export const PodRowSkeleton: React.FC = () => { + return ( + + {/* Pod信息列(占6列) */} + +
+ {/* 第一行:Pod名称 + 状态 */} +
+ {/* Pod名称 */} +
+ + +
+ + {/* 状态徽章 */} + +
+ + {/* 第二行:详细信息 */} +
+ {/* 节点 */} +
+ + +
+ + + + {/* IP */} +
+ + +
+ + + + {/* 镜像 */} +
+ + +
+ + + + {/* 资源 */} +
+ + +
+
+
+ + + {/* 操作列(占1列,固定右侧) */} + +
+ + + +
+ + + ); +}; diff --git a/frontend/src/pages/Resource/K8s/List/index.tsx b/frontend/src/pages/Resource/K8s/List/index.tsx index 76bae2e0..04c36d6a 100644 --- a/frontend/src/pages/Resource/K8s/List/index.tsx +++ b/frontend/src/pages/Resource/K8s/List/index.tsx @@ -6,11 +6,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Input } from '@/components/ui/input'; import { RefreshCw, Loader2, Cloud, History as HistoryIcon, Database, Search } from 'lucide-react'; import { useToast } from '@/components/ui/use-toast'; +import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { setSelectedClusterId, setSelectedNamespaceId } from '@/store/k8sSlice'; import { StatsCards } from './components/StatsCards'; import { FilterTabs } from './components/FilterTabs'; import { DeploymentTable } from './components/DeploymentTable'; import { DeploymentTableSkeleton } from './components/DeploymentTableSkeleton'; import { SyncHistoryTab } from './components/SyncHistoryTab'; +import PodLogDialog from './components/PodLogDialog'; import { getExternalSystemList } from '@/pages/Resource/External/List/service'; import { syncK8sNamespace, syncK8sDeployment, getK8sNamespaceList, getK8sDeploymentByNamespace } from './service'; import type { ExternalSystemResponse } from '@/pages/Resource/External/List/types'; @@ -23,10 +26,12 @@ import dayjs from 'dayjs'; const K8sManagement: React.FC = () => { const { toast } = useToast(); + const dispatch = useAppDispatch(); + const selectedClusterId = useAppSelector((state) => state.k8s.selectedClusterId); + const selectedNamespaceId = useAppSelector((state) => state.k8s.selectedNamespaceId); + const [k8sClusters, setK8sClusters] = useState([]); - const [selectedClusterId, setSelectedClusterId] = useState(null); const [namespaces, setNamespaces] = useState([]); - const [selectedNamespaceId, setSelectedNamespaceId] = useState(null); const [deployments, setDeployments] = useState([]); const [syncingNamespace, setSyncingNamespace] = useState(false); const [syncingDeployment, setSyncingDeployment] = useState(false); @@ -35,6 +40,8 @@ const K8sManagement: React.FC = () => { const [searchQuery, setSearchQuery] = useState(''); const [activeFilter, setActiveFilter] = useState('all'); const [lastUpdateTime, setLastUpdateTime] = useState(new Date()); + const [logDialogOpen, setLogDialogOpen] = useState(false); + const [selectedPod, setSelectedPod] = useState<(K8sPodResponse & { deploymentId: number }) | null>(null); const { expandedIds, toggleExpand } = useDeploymentExpand(); @@ -46,9 +53,14 @@ const K8sManagement: React.FC = () => { const enabledClusters = (clusters || []).filter(c => c.enabled); setK8sClusters(enabledClusters); - // 如果有集群,默认选择第一个 + // 如果有集群且store中没有选中的集群,默认选择第一个 if (enabledClusters.length > 0 && !selectedClusterId) { - setSelectedClusterId(enabledClusters[0].id); + dispatch(setSelectedClusterId(enabledClusters[0].id)); + } + // 如果store中有选中的集群,但该集群已被禁用或删除,清除选择 + if (selectedClusterId && !enabledClusters.find(c => c.id === selectedClusterId)) { + dispatch(setSelectedClusterId(null)); + dispatch(setSelectedNamespaceId(null)); } } catch (error) { console.error('加载K8S集群失败:', error); @@ -69,9 +81,13 @@ const K8sManagement: React.FC = () => { try { const data = await getK8sNamespaceList(selectedClusterId); setNamespaces(data || []); - // 默认选择第一个命名空间 + // 如果有命名空间且store中没有选中的命名空间,默认选择第一个 if (data && data.length > 0 && !selectedNamespaceId) { - setSelectedNamespaceId(data[0].id); + dispatch(setSelectedNamespaceId(data[0].id)); + } + // 如果store中有选中的命名空间,但该命名空间已被删除,清除选择 + if (selectedNamespaceId && data && !data.find(ns => ns.id === selectedNamespaceId)) { + dispatch(setSelectedNamespaceId(null)); } } catch (error) { console.error('加载命名空间失败:', error); @@ -232,9 +248,9 @@ const K8sManagement: React.FC = () => { }, [deployments]); // 处理日志查看 - const handleViewLogs = (pod: K8sPodResponse) => { - // TODO: 打开日志抽屉 - console.log('查看日志:', pod.name); + const handleViewLogs = (pod: K8sPodResponse, deploymentId: number) => { + setSelectedPod({ ...pod, deploymentId }); + setLogDialogOpen(true); }; if (initialLoading) { @@ -292,7 +308,12 @@ const K8sManagement: React.FC = () => {
setSelectedNamespaceId(Number(value))} + onValueChange={(value) => dispatch(setSelectedNamespaceId(Number(value)))} disabled={!selectedClusterId || namespaces.length === 0} > @@ -398,6 +419,16 @@ const K8sManagement: React.FC = () => { onViewLogs={handleViewLogs} onRefresh={loadDeployments} /> + + {/* Pod日志对话框 */} + {selectedPod && ( + + )} )} diff --git a/frontend/src/pages/Resource/K8s/List/types.ts b/frontend/src/pages/Resource/K8s/List/types.ts index b97c9d2c..dd1d9939 100644 --- a/frontend/src/pages/Resource/K8s/List/types.ts +++ b/frontend/src/pages/Resource/K8s/List/types.ts @@ -163,15 +163,15 @@ export interface K8sSyncHistoryQuery extends BaseQuery { */ export enum PodPhase { /** 运行中 */ - RUNNING = 'Running', + RUNNING = 'RUNNING', /** 等待中 */ - PENDING = 'Pending', + PENDING = 'PENDING', /** 成功 */ - SUCCEEDED = 'Succeeded', + SUCCEEDED = 'SUCCEEDED', /** 失败 */ - FAILED = 'Failed', + FAILED = 'FAILED', /** 未知 */ - UNKNOWN = 'Unknown', + UNKNOWN = 'UNKNOWN', } /** @@ -179,11 +179,11 @@ export enum PodPhase { */ export enum ContainerState { /** 运行中 */ - RUNNING = 'running', + RUNNING = 'RUNNING', /** 等待中 */ - WAITING = 'waiting', + WAITING = 'WAITING', /** 已终止 */ - TERMINATED = 'terminated', + TERMINATED = 'TERMINATED', } // Pod状态标签映射 diff --git a/frontend/src/store/hooks.ts b/frontend/src/store/hooks.ts new file mode 100644 index 00000000..81bfc176 --- /dev/null +++ b/frontend/src/store/hooks.ts @@ -0,0 +1,5 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import type { RootState, AppDispatch } from './index'; + +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index a40bbdb8..f9d442dc 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -1,9 +1,11 @@ import {configureStore} from '@reduxjs/toolkit'; import userReducer from './userSlice'; +import k8sReducer from './k8sSlice'; const store = configureStore({ reducer: { user: userReducer, + k8s: k8sReducer, }, }); diff --git a/frontend/src/store/k8sSlice.ts b/frontend/src/store/k8sSlice.ts new file mode 100644 index 00000000..02016f1a --- /dev/null +++ b/frontend/src/store/k8sSlice.ts @@ -0,0 +1,47 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface K8sState { + selectedClusterId: number | null; + selectedNamespaceId: number | null; +} + +const initialState: K8sState = { + selectedClusterId: localStorage.getItem('k8s_selectedClusterId') + ? Number(localStorage.getItem('k8s_selectedClusterId')) + : null, + selectedNamespaceId: localStorage.getItem('k8s_selectedNamespaceId') + ? Number(localStorage.getItem('k8s_selectedNamespaceId')) + : null, +}; + +const k8sSlice = createSlice({ + name: 'k8s', + initialState, + reducers: { + setSelectedClusterId: (state, action: PayloadAction) => { + state.selectedClusterId = action.payload; + if (action.payload !== null) { + localStorage.setItem('k8s_selectedClusterId', String(action.payload)); + } else { + localStorage.removeItem('k8s_selectedClusterId'); + } + }, + setSelectedNamespaceId: (state, action: PayloadAction) => { + state.selectedNamespaceId = action.payload; + if (action.payload !== null) { + localStorage.setItem('k8s_selectedNamespaceId', String(action.payload)); + } else { + localStorage.removeItem('k8s_selectedNamespaceId'); + } + }, + clearK8sSelection: (state) => { + state.selectedClusterId = null; + state.selectedNamespaceId = null; + localStorage.removeItem('k8s_selectedClusterId'); + localStorage.removeItem('k8s_selectedNamespaceId'); + }, + }, +}); + +export const { setSelectedClusterId, setSelectedNamespaceId, clearK8sSelection } = k8sSlice.actions; +export default k8sSlice.reducer;