1.30 k8s管理
This commit is contained in:
parent
7471571e8c
commit
7e1d78a898
114
frontend/src/components/LogViewer/README.md
Normal file
114
frontend/src/components/LogViewer/README.md
Normal file
@ -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: '部署失败:连接超时',
|
||||
},
|
||||
];
|
||||
|
||||
<LogViewer
|
||||
logs={logs}
|
||||
loading={false}
|
||||
onRefresh={handleRefresh}
|
||||
onDownload={handleDownload}
|
||||
autoScroll={true}
|
||||
showToolbar={true}
|
||||
/>
|
||||
```
|
||||
|
||||
### 纯文本模式(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
|
||||
`;
|
||||
|
||||
<LogViewer
|
||||
content={logContent}
|
||||
loading={false}
|
||||
onRefresh={handleRefresh}
|
||||
onDownload={handleDownload}
|
||||
theme="vs-dark"
|
||||
/>
|
||||
```
|
||||
|
||||
## 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` 提供日志格式化函数
|
||||
65
frontend/src/components/LogViewer/formatters.ts
Normal file
65
frontend/src/components/LogViewer/formatters.ts
Normal file
@ -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 '';
|
||||
};
|
||||
207
frontend/src/components/LogViewer/index.tsx
Normal file
207
frontend/src/components/LogViewer/index.tsx
Normal file
@ -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<MonacoLayoutConfig> = {
|
||||
lineNumbersMinChars: 3,
|
||||
lineDecorationsWidth: 20,
|
||||
glyphMargin: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* LogViewer - 专业的日志查看器组件
|
||||
*
|
||||
* 基于Monaco Editor实现,支持:
|
||||
* - 虚拟滚动(高性能,支持百万行)
|
||||
* - 语法高亮(INFO/WARN/ERROR)
|
||||
* - 结构化日志和纯文本两种模式
|
||||
* - 自动滚动到底部
|
||||
* - 刷新和下载功能
|
||||
*/
|
||||
const LogViewer: React.FC<LogViewerProps> = ({
|
||||
logs,
|
||||
content,
|
||||
loading = false,
|
||||
onRefresh,
|
||||
onDownload,
|
||||
height = '100%',
|
||||
theme = 'light',
|
||||
showToolbar = true,
|
||||
customToolbar,
|
||||
showLineNumbers = true,
|
||||
autoScroll = true,
|
||||
className,
|
||||
monacoLayout,
|
||||
}) => {
|
||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(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 (
|
||||
<div className={cn('flex flex-col h-full', className)}>
|
||||
{/* 工具栏 */}
|
||||
{showToolbar && (
|
||||
customToolbar ? (
|
||||
<div className="mb-3 flex-shrink-0">{customToolbar}</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between mb-3 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{logs && logs.length > 0 && (
|
||||
<span>共 {logs.length} 条日志</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={!formattedContent || loading}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 日志内容区域 */}
|
||||
<div className="flex-1 border rounded-md overflow-hidden" style={{ height }}>
|
||||
{loading && !formattedContent ? (
|
||||
<div className="flex items-center justify-center h-full bg-muted/10">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<span className="text-sm text-muted-foreground">加载日志中...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : formattedContent ? (
|
||||
<Editor
|
||||
height="100%"
|
||||
language={editorLanguage}
|
||||
theme={editorTheme}
|
||||
value={formattedContent}
|
||||
options={editorOptions}
|
||||
onMount={handleEditorDidMount}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full bg-muted/10">
|
||||
<span className="text-sm text-muted-foreground">暂无日志</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogViewer;
|
||||
72
frontend/src/components/LogViewer/logLanguage.ts
Normal file
72
frontend/src/components/LogViewer/logLanguage.ts
Normal file
@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
70
frontend/src/components/LogViewer/types.ts
Normal file
70
frontend/src/components/LogViewer/types.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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<LogLevel, string> = {
|
||||
INFO: 'text-blue-600',
|
||||
WARN: 'text-yellow-600',
|
||||
ERROR: 'text-red-600',
|
||||
};
|
||||
return levelMap[level] || 'text-gray-600';
|
||||
};
|
||||
|
||||
|
||||
const DeployNodeLogDialog: React.FC<DeployNodeLogDialogProps> = ({
|
||||
open,
|
||||
@ -40,7 +33,6 @@ const DeployNodeLogDialog: React.FC<DeployNodeLogDialogProps> = ({
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [logData, setLogData] = useState<DeployNodeLogDTO | null>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
@ -51,15 +43,6 @@ const DeployNodeLogDialog: React.FC<DeployNodeLogDialogProps> = ({
|
||||
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<DeployNodeLogDialogProps> = ({
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="flex-1 flex flex-col min-h-0">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{logData && !logData.expired && (
|
||||
<span>共 {logData.logs.length} 条日志</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchLogs}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-2", loading && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 日志内容区域 */}
|
||||
{loading && !logData ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto mb-2" />
|
||||
<span className="text-sm text-muted-foreground">加载日志中...</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -143,68 +108,22 @@ const DeployNodeLogDialog: React.FC<DeployNodeLogDialogProps> = ({
|
||||
<p className="text-lg font-medium">日志已过期或不存在</p>
|
||||
<p className="text-sm mt-2">日志通常保留 7 天,请检查部署时间</p>
|
||||
</div>
|
||||
) : logData?.logs && logData.logs.length > 0 ? (
|
||||
<LogViewer
|
||||
logs={logData.logs as StructuredLog[]}
|
||||
loading={loading}
|
||||
onRefresh={fetchLogs}
|
||||
theme="light"
|
||||
autoScroll={true}
|
||||
showToolbar={true}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex-1 border rounded-md bg-gray-50 overflow-auto"
|
||||
ref={scrollAreaRef}
|
||||
>
|
||||
<div className="p-2 font-mono text-xs w-max min-w-full">
|
||||
{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 (
|
||||
<div
|
||||
key={log.sequenceId}
|
||||
className="flex items-start px-2 py-0.5 whitespace-nowrap"
|
||||
>
|
||||
{/* 行号 - 动态宽度,右对齐 */}
|
||||
<span
|
||||
className="text-gray-400 flex-shrink-0 text-right select-none"
|
||||
style={{ width: `${lineNumWidth}ch`, marginRight: '1ch' }}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
{/* 时间戳 - 可读格式,23个字符 (2025-11-07 16:27:41.494) */}
|
||||
<span
|
||||
className="text-gray-600 flex-shrink-0"
|
||||
style={{ width: '23ch', marginRight: '2ch' }}
|
||||
>
|
||||
{timestamp}
|
||||
</span>
|
||||
|
||||
{/* 日志级别 - 5个字符,右对齐 */}
|
||||
<span
|
||||
className={cn('flex-shrink-0 font-semibold text-right', getLevelClass(log.level))}
|
||||
style={{ width: '5ch', marginRight: '2ch' }}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
|
||||
{/* 日志消息 - 不换行显示 */}
|
||||
<span className="text-gray-800">
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Clock className="h-12 w-12 mb-4 text-muted-foreground/30" />
|
||||
<p className="text-base font-medium">暂无日志</p>
|
||||
<p className="text-sm mt-2">任务可能尚未开始或未产生日志</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@ -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<DeploymentRowProps> = ({
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const [pods, setPods] = useState<K8sPodResponse[]>([]);
|
||||
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,16 +111,96 @@ export const DeploymentRow: React.FC<DeploymentRowProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<tr className={`border-b hover:bg-gray-50 cursor-pointer ${health === 'critical' ? 'border-l-4 border-l-red-500' : health === 'warning' ? 'border-l-4 border-l-yellow-500' : ''}`}>
|
||||
<tr
|
||||
className={`border-b hover:bg-gray-50 ${hasReplicas ? 'cursor-pointer' : ''} ${health === 'critical' ? 'border-l-4 border-l-red-500' : health === 'warning' ? 'border-l-4 border-l-yellow-500' : ''}`}
|
||||
onClick={hasReplicas ? onToggleExpand : undefined}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasReplicas ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleExpand}
|
||||
className="h-6 w-6 p-0"
|
||||
className="h-6 w-6 p-0 pointer-events-none"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
@ -87,6 +208,9 @@ export const DeploymentRow: React.FC<DeploymentRowProps> = ({
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="h-6 w-6" />
|
||||
)}
|
||||
<Layers className="h-4 w-4 text-blue-600" />
|
||||
<span className="font-medium">{deployment.deploymentName}</span>
|
||||
</div>
|
||||
@ -95,14 +219,7 @@ export const DeploymentRow: React.FC<DeploymentRowProps> = ({
|
||||
<HealthBar deployment={deployment} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleExpand}
|
||||
className="h-7 font-mono"
|
||||
>
|
||||
{ready}/{desired}
|
||||
</Button>
|
||||
<span className="text-sm font-mono text-gray-700">{ready}/{desired}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-sm text-gray-600 font-mono truncate block max-w-xs" title={deployment.image}>
|
||||
@ -117,18 +234,91 @@ export const DeploymentRow: React.FC<DeploymentRowProps> = ({
|
||||
{deployment.k8sUpdateTime ? dayjs(deployment.k8sUpdateTime).fromNow() : '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<OperationMenu deployment={deployment} onSuccess={onRefresh} />
|
||||
<td className="px-6 py-4 sticky right-0 bg-background shadow-[-2px_0_4px_rgba(0,0,0,0.05)]" onClick={(e) => e.stopPropagation()}>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>重启Deployment</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setScaleDialogOpen(true)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>扩缩容</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setYamlDialogOpen(true)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<FileCode className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>查看YAML</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLabelDialogOpen(true)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Tag className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>查看标签</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>删除</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-primary" />
|
||||
</td>
|
||||
</tr>
|
||||
{initialLoading ? (
|
||||
<>
|
||||
{Array.from({ length: deployment.replicas || 1 }).map((_, index) => (
|
||||
<PodRowSkeleton key={index} />
|
||||
))}
|
||||
</>
|
||||
) : pods.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 text-center text-gray-500">
|
||||
@ -140,12 +330,143 @@ export const DeploymentRow: React.FC<DeploymentRowProps> = ({
|
||||
<PodRow
|
||||
key={pod.name}
|
||||
pod={pod}
|
||||
deploymentId={deployment.id}
|
||||
onViewLogs={onViewLogs}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 扩缩容对话框 */}
|
||||
<Dialog open={scaleDialogOpen} onOpenChange={setScaleDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>扩缩容</DialogTitle>
|
||||
<DialogDescription>
|
||||
调整 {deployment.deploymentName} 的副本数量
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="replicas">副本数</Label>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const current = parseInt(replicas) || 0;
|
||||
if (current > 0) {
|
||||
setReplicas(String(current - 1));
|
||||
}
|
||||
}}
|
||||
disabled={parseInt(replicas) <= 0}
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
<Input
|
||||
id="replicas"
|
||||
type="number"
|
||||
min="0"
|
||||
value={replicas}
|
||||
onChange={(e) => setReplicas(e.target.value)}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const current = parseInt(replicas) || 0;
|
||||
setReplicas(String(current + 1));
|
||||
}}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
当前副本数: {deployment.replicas || 0}
|
||||
</p>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setScaleDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleScale} disabled={loading}>
|
||||
{loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除 Deployment <strong>{deployment.deploymentName}</strong> 吗?
|
||||
此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
|
||||
{loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* YAML查看器 */}
|
||||
<YamlViewerDialog
|
||||
open={yamlDialogOpen}
|
||||
onOpenChange={setYamlDialogOpen}
|
||||
title={`Deployment: ${deployment.deploymentName} - YAML配置`}
|
||||
yamlContent={deployment.yamlConfig}
|
||||
/>
|
||||
|
||||
{/* 标签查看对话框 */}
|
||||
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Tag className="h-5 w-5" />
|
||||
标签列表
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{deployment.labels && Object.keys(deployment.labels).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(deployment.labels).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border hover:border-primary/50 transition-colors bg-muted/30"
|
||||
>
|
||||
<div className="flex-1 min-w-0 font-mono text-sm">
|
||||
<span className="text-blue-600 dark:text-blue-400 font-semibold">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<span className="text-foreground">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">暂无标签</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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<number>;
|
||||
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<DeploymentTableProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<div className="border rounded-lg overflow-auto">
|
||||
<table className="w-full caption-bottom text-sm" style={{ minWidth: '1200px' }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-64">名称</TableHead>
|
||||
@ -37,7 +37,7 @@ export const DeploymentTable: React.FC<DeploymentTableProps> = ({
|
||||
<TableHead className="w-80">镜像</TableHead>
|
||||
<TableHead className="w-24">重启</TableHead>
|
||||
<TableHead className="w-32">更新时间</TableHead>
|
||||
<TableHead className="w-20">操作</TableHead>
|
||||
<TableHead className="w-20 sticky right-0 bg-background shadow-[-2px_0_4px_rgba(0,0,0,0.05)]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -52,7 +52,7 @@ export const DeploymentTable: React.FC<DeploymentTableProps> = ({
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
206
frontend/src/pages/Resource/K8s/List/components/PodLogDialog.tsx
Normal file
206
frontend/src/pages/Resource/K8s/List/components/PodLogDialog.tsx
Normal file
@ -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<PodLogDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
deploymentId,
|
||||
podName,
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingPod, setLoadingPod] = useState(false);
|
||||
const [logContent, setLogContent] = useState<string>('');
|
||||
const [selectedContainer, setSelectedContainer] = useState<string>('');
|
||||
const [containers, setContainers] = useState<string[]>([]);
|
||||
|
||||
// 打开对话框时获取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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-6xl h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
Pod日志 - {podName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="flex-1 flex flex-col min-h-0">
|
||||
{loadingPod ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto mb-2" />
|
||||
<span className="text-sm text-muted-foreground">加载Pod信息中...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<LogViewer
|
||||
content={logContent}
|
||||
loading={loading && !logContent}
|
||||
onRefresh={fetchLogs}
|
||||
onDownload={handleDownload}
|
||||
theme="vs-dark"
|
||||
autoScroll={true}
|
||||
showToolbar={true}
|
||||
showLineNumbers={true}
|
||||
customToolbar={
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 左侧:容器选择器 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{containers.length > 1 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">容器:</span>
|
||||
<Select
|
||||
value={selectedContainer}
|
||||
onValueChange={setSelectedContainer}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((container) => (
|
||||
<SelectItem key={container} value={container}>
|
||||
{container}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : containers.length === 1 ? (
|
||||
<div className="flex items-center h-9">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
容器: <span className="font-medium text-foreground">{containers[0]}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={!logContent || logContent === '暂无日志' || logContent === '获取日志失败'}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchLogs}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PodLogDialog;
|
||||
@ -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<PodRowProps> = ({ pod, onViewLogs }) => {
|
||||
export const PodRow: React.FC<PodRowProps> = ({ pod, deploymentId, onViewLogs }) => {
|
||||
const { toast } = useToast();
|
||||
const [copiedField, setCopiedField] = React.useState<string | null>(null);
|
||||
const phaseLabel = PodPhaseLabels[pod.phase];
|
||||
const [yamlDialogOpen, setYamlDialogOpen] = useState(false);
|
||||
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
|
||||
const [copiedImage, setCopiedImage] = useState(false);
|
||||
|
||||
const handleCopy = (text: string, field: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedField(field);
|
||||
const phaseLabel = PodPhaseLabels[pod.phase] || PodPhaseLabels['UNKNOWN'] || {
|
||||
label: '未知',
|
||||
variant: 'secondary' as const,
|
||||
color: 'bg-gray-600 hover:bg-gray-700'
|
||||
};
|
||||
|
||||
// 获取主容器信息
|
||||
const mainContainer = pod.containers[0];
|
||||
|
||||
// 复制镜像名称
|
||||
const handleCopyImage = () => {
|
||||
if (mainContainer?.image) {
|
||||
navigator.clipboard.writeText(mainContainer.image).then(() => {
|
||||
setCopiedImage(true);
|
||||
toast({
|
||||
title: '已复制',
|
||||
description: `已复制${field}到剪贴板`,
|
||||
description: '镜像名称已复制到剪贴板',
|
||||
});
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
setTimeout(() => setCopiedImage(false), 2000);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-t border-gray-100 bg-gray-50/50 hover:bg-gray-100/50">
|
||||
<td className="px-6 py-3" colSpan={7}>
|
||||
<div className="flex items-center gap-4 pl-8">
|
||||
{/* Pod名称 */}
|
||||
<div className="flex-shrink-0 w-48 flex items-center gap-2">
|
||||
<Box className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-sm font-mono text-gray-700">{pod.name}</span>
|
||||
{/* Pod信息列(占6列) */}
|
||||
<td className="px-6 py-4 bg-gray-50/50" colSpan={6}>
|
||||
<div className="pl-8 space-y-3">
|
||||
{/* 第一行:Pod名称 + 状态 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Pod名称 - 固定宽度 */}
|
||||
<div className="flex items-center gap-2.5 w-[480px]">
|
||||
<Box className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
<span className="text-sm font-mono text-gray-800 font-medium truncate">
|
||||
{pod.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 状态 */}
|
||||
<div className="flex-shrink-0">
|
||||
<Badge variant={phaseLabel.variant} className={`${phaseLabel.color} text-xs`}>
|
||||
{/* 状态徽章 */}
|
||||
<Badge variant={phaseLabel.variant} className={`${phaseLabel.color} text-xs flex-shrink-0`}>
|
||||
{phaseLabel.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 就绪状态 */}
|
||||
<div className="flex-shrink-0">
|
||||
<Badge variant={pod.ready ? 'default' : 'destructive'} className="text-xs">
|
||||
{pod.ready ? '就绪' : '未就绪'}
|
||||
{/* 未就绪警告 */}
|
||||
{!pod.ready && (
|
||||
<Badge variant="destructive" className="text-xs flex-shrink-0">
|
||||
未就绪
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 重启次数 */}
|
||||
<div className="flex-shrink-0 w-20">
|
||||
{pod.restartCount > 0 ? (
|
||||
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 text-xs">
|
||||
{pod.restartCount}次
|
||||
{/* 重启次数警告 */}
|
||||
{pod.restartCount > 0 && (
|
||||
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 text-xs flex-shrink-0">
|
||||
重启 {pod.restartCount}次
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pod IP */}
|
||||
<div className="flex items-center gap-1 group flex-shrink-0 w-36">
|
||||
<span className="text-sm text-gray-600 font-mono">{pod.podIP || '-'}</span>
|
||||
{pod.podIP && (
|
||||
{/* 第二行:详细信息 */}
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600">
|
||||
{/* 节点 - 固定宽度 */}
|
||||
<div className="flex items-center gap-2 w-80">
|
||||
<span className="text-gray-500 font-medium flex-shrink-0">节点:</span>
|
||||
<span className="font-mono text-gray-700 truncate">{pod.nodeName || '-'}</span>
|
||||
</div>
|
||||
|
||||
<span className="text-gray-300">|</span>
|
||||
|
||||
{/* IP - 固定宽度 */}
|
||||
<div className="flex items-center gap-2 w-32">
|
||||
<span className="text-gray-500 font-medium flex-shrink-0">IP:</span>
|
||||
<span className="font-mono text-gray-700">{pod.podIP || '-'}</span>
|
||||
</div>
|
||||
|
||||
{mainContainer && (
|
||||
<>
|
||||
<span className="text-gray-300">|</span>
|
||||
|
||||
{/* 镜像 - 固定宽度 */}
|
||||
<div className="flex items-center gap-2 w-96 group">
|
||||
<span className="text-gray-500 font-medium flex-shrink-0">镜像:</span>
|
||||
<span className="font-mono text-gray-700 truncate" title={mainContainer.image}>
|
||||
{mainContainer.image}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(pod.podIP!, 'Pod IP')}
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={handleCopyImage}
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
>
|
||||
{copiedField === 'Pod IP' ? (
|
||||
{copiedImage ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 资源 - 固定宽度 */}
|
||||
<div className="flex items-center gap-2 w-64">
|
||||
<span className="text-gray-500 font-medium flex-shrink-0">资源:</span>
|
||||
<span className="text-gray-700">
|
||||
CPU {mainContainer.cpuLimit || '-'} / 内存 {mainContainer.memoryLimit || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 节点 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-gray-600 truncate block">{pod.nodeName || '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* 日志按钮 */}
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onViewLogs(pod)}
|
||||
className="h-7"
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
日志
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 操作列(占1列,固定右侧) */}
|
||||
<td className="px-6 py-4 sticky right-0 bg-gray-50/50 shadow-[-2px_0_4px_rgba(0,0,0,0.05)]">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onViewLogs(pod, deploymentId)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>查看日志</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setYamlDialogOpen(true)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<FileCode className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>查看YAML</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLabelDialogOpen(true)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Tag className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>查看标签</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* YAML查看器 */}
|
||||
<YamlViewerDialog
|
||||
open={yamlDialogOpen}
|
||||
onOpenChange={setYamlDialogOpen}
|
||||
title={`Pod: ${pod.name} - YAML配置`}
|
||||
yamlContent={pod.yamlConfig}
|
||||
/>
|
||||
|
||||
{/* 标签和注解对话框 - 使用Tab切换 */}
|
||||
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Tag className="h-5 w-5" />
|
||||
Pod 标签和注解
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Tabs defaultValue="labels" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="labels">标签 (Labels)</TabsTrigger>
|
||||
<TabsTrigger value="annotations">注解 (Annotations)</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="labels" className="mt-4">
|
||||
{pod.labels && Object.keys(pod.labels).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(pod.labels).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border hover:border-primary/50 transition-colors bg-muted/30"
|
||||
>
|
||||
<div className="flex-1 min-w-0 font-mono text-sm">
|
||||
<span className="text-blue-600 dark:text-blue-400 font-semibold">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-muted-foreground">: </span>
|
||||
<span className="text-foreground">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">暂无标签</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="annotations" className="mt-4">
|
||||
{pod.annotations && Object.keys(pod.annotations).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(pod.annotations).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border hover:border-primary/50 transition-colors bg-muted/30"
|
||||
>
|
||||
<div className="flex-1 min-w-0 font-mono text-xs break-all">
|
||||
<span className="text-purple-600 dark:text-purple-400 font-semibold">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-muted-foreground">: </span>
|
||||
<span className="text-foreground">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">暂无注解</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export const PodRowSkeleton: React.FC = () => {
|
||||
return (
|
||||
<tr className="border-t border-gray-100 bg-gray-50/50">
|
||||
{/* Pod信息列(占6列) */}
|
||||
<td className="px-6 py-4 bg-gray-50/50" colSpan={6}>
|
||||
<div className="pl-8 space-y-3">
|
||||
{/* 第一行:Pod名称 + 状态 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Pod名称 */}
|
||||
<div className="flex items-center gap-2.5 w-[480px]">
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
</div>
|
||||
|
||||
{/* 状态徽章 */}
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* 第二行:详细信息 */}
|
||||
<div className="flex items-center gap-6">
|
||||
{/* 节点 */}
|
||||
<div className="flex items-center gap-2 w-80">
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-4 w-px" />
|
||||
|
||||
{/* IP */}
|
||||
<div className="flex items-center gap-2 w-32">
|
||||
<Skeleton className="h-4 w-6" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-4 w-px" />
|
||||
|
||||
{/* 镜像 */}
|
||||
<div className="flex items-center gap-2 w-96">
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-4 w-px" />
|
||||
|
||||
{/* 资源 */}
|
||||
<div className="flex items-center gap-2 w-64">
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 操作列(占1列,固定右侧) */}
|
||||
<td className="px-6 py-4 sticky right-0 bg-gray-50/50 shadow-[-2px_0_4px_rgba(0,0,0,0.05)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@ -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<ExternalSystemResponse[]>([]);
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<number | null>(null);
|
||||
const [namespaces, setNamespaces] = useState<K8sNamespaceResponse[]>([]);
|
||||
const [selectedNamespaceId, setSelectedNamespaceId] = useState<number | null>(null);
|
||||
const [deployments, setDeployments] = useState<K8sDeploymentResponse[]>([]);
|
||||
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<HealthStatus | 'all'>('all');
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date>(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 = () => {
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Select
|
||||
value={selectedClusterId ? String(selectedClusterId) : undefined}
|
||||
onValueChange={(value) => setSelectedClusterId(Number(value))}
|
||||
onValueChange={(value) => {
|
||||
const clusterId = Number(value);
|
||||
dispatch(setSelectedClusterId(clusterId));
|
||||
// 切换集群时清除命名空间选择
|
||||
dispatch(setSelectedNamespaceId(null));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="选择K8S集群" />
|
||||
@ -308,7 +329,7 @@ const K8sManagement: React.FC = () => {
|
||||
|
||||
<Select
|
||||
value={selectedNamespaceId ? String(selectedNamespaceId) : undefined}
|
||||
onValueChange={(value) => setSelectedNamespaceId(Number(value))}
|
||||
onValueChange={(value) => dispatch(setSelectedNamespaceId(Number(value)))}
|
||||
disabled={!selectedClusterId || namespaces.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
@ -398,6 +419,16 @@ const K8sManagement: React.FC = () => {
|
||||
onViewLogs={handleViewLogs}
|
||||
onRefresh={loadDeployments}
|
||||
/>
|
||||
|
||||
{/* Pod日志对话框 */}
|
||||
{selectedPod && (
|
||||
<PodLogDialog
|
||||
open={logDialogOpen}
|
||||
onOpenChange={setLogDialogOpen}
|
||||
deploymentId={selectedPod.deploymentId}
|
||||
podName={selectedPod.name}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
|
||||
@ -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状态标签映射
|
||||
|
||||
5
frontend/src/store/hooks.ts
Normal file
5
frontend/src/store/hooks.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from './index';
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
47
frontend/src/store/k8sSlice.ts
Normal file
47
frontend/src/store/k8sSlice.ts
Normal file
@ -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<number | null>) => {
|
||||
state.selectedClusterId = action.payload;
|
||||
if (action.payload !== null) {
|
||||
localStorage.setItem('k8s_selectedClusterId', String(action.payload));
|
||||
} else {
|
||||
localStorage.removeItem('k8s_selectedClusterId');
|
||||
}
|
||||
},
|
||||
setSelectedNamespaceId: (state, action: PayloadAction<number | null>) => {
|
||||
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;
|
||||
Loading…
Reference in New Issue
Block a user