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,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Loader2, AlertCircle, Clock, FileText, RefreshCw } from 'lucide-react';
|
import { AlertCircle, Clock, FileText } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { getDeployNodeLogs } from '../service';
|
import { getDeployNodeLogs } from '../service';
|
||||||
import type { DeployNodeLogDTO, LogLevel } from '../types';
|
import type { DeployNodeLogDTO } from '../types';
|
||||||
import dayjs from 'dayjs';
|
import LogViewer from '@/components/LogViewer';
|
||||||
|
import type { StructuredLog } from '@/components/LogViewer/types';
|
||||||
|
|
||||||
interface DeployNodeLogDialogProps {
|
interface DeployNodeLogDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -22,14 +22,7 @@ interface DeployNodeLogDialogProps {
|
|||||||
nodeName?: string;
|
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> = ({
|
const DeployNodeLogDialog: React.FC<DeployNodeLogDialogProps> = ({
|
||||||
open,
|
open,
|
||||||
@ -40,7 +33,6 @@ const DeployNodeLogDialog: React.FC<DeployNodeLogDialogProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [logData, setLogData] = useState<DeployNodeLogDTO | null>(null);
|
const [logData, setLogData] = useState<DeployNodeLogDTO | null>(null);
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const fetchLogs = async () => {
|
const fetchLogs = async () => {
|
||||||
@ -51,15 +43,6 @@ const DeployNodeLogDialog: React.FC<DeployNodeLogDialogProps> = ({
|
|||||||
const response = await getDeployNodeLogs(processInstanceId, nodeId);
|
const response = await getDeployNodeLogs(processInstanceId, nodeId);
|
||||||
setLogData(response);
|
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 (response?.expired) {
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
@ -111,29 +94,11 @@ const DeployNodeLogDialog: React.FC<DeployNodeLogDialogProps> = ({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogBody className="flex-1 flex flex-col min-h-0">
|
<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 ? (
|
{loading && !logData ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center">
|
<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>
|
<span className="text-sm text-muted-foreground">加载日志中...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -143,66 +108,20 @@ const DeployNodeLogDialog: React.FC<DeployNodeLogDialogProps> = ({
|
|||||||
<p className="text-lg font-medium">日志已过期或不存在</p>
|
<p className="text-lg font-medium">日志已过期或不存在</p>
|
||||||
<p className="text-sm mt-2">日志通常保留 7 天,请检查部署时间</p>
|
<p className="text-sm mt-2">日志通常保留 7 天,请检查部署时间</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : logData?.logs && logData.logs.length > 0 ? (
|
||||||
|
<LogViewer
|
||||||
|
logs={logData.logs as StructuredLog[]}
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={fetchLogs}
|
||||||
|
theme="light"
|
||||||
|
autoScroll={true}
|
||||||
|
showToolbar={true}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
className="flex-1 border rounded-md bg-gray-50 overflow-auto"
|
<Clock className="h-12 w-12 mb-4 text-muted-foreground/30" />
|
||||||
ref={scrollAreaRef}
|
<p className="text-base font-medium">暂无日志</p>
|
||||||
>
|
<p className="text-sm mt-2">任务可能尚未开始或未产生日志</p>
|
||||||
<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">
|
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
|
|||||||
@ -1,11 +1,39 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
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 { useToast } from '@/components/ui/use-toast';
|
||||||
import { HealthBar } from './HealthBar';
|
import { HealthBar } from './HealthBar';
|
||||||
import { PodRow } from './PodRow';
|
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 type { K8sDeploymentResponse, K8sPodResponse } from '../types';
|
||||||
import { getDeploymentHealth, HealthStatusLabels } from '../types';
|
import { getDeploymentHealth, HealthStatusLabels } from '../types';
|
||||||
import { getK8sPodsByDeployment } from '../service';
|
import { getK8sPodsByDeployment } from '../service';
|
||||||
@ -21,7 +49,7 @@ interface DeploymentRowProps {
|
|||||||
deployment: K8sDeploymentResponse;
|
deployment: K8sDeploymentResponse;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onViewLogs: (pod: K8sPodResponse) => void;
|
onViewLogs: (pod: K8sPodResponse, deploymentId: number) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,33 +62,46 @@ export const DeploymentRow: React.FC<DeploymentRowProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [pods, setPods] = useState<K8sPodResponse[]>([]);
|
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 [loading, setLoading] = useState(false);
|
||||||
const health = getDeploymentHealth(deployment);
|
const health = getDeploymentHealth(deployment);
|
||||||
const healthLabel = HealthStatusLabels[health];
|
|
||||||
|
|
||||||
const loadPods = async () => {
|
const loadPods = async (isInitial = false) => {
|
||||||
if (!isExpanded) return;
|
if (!isExpanded) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
|
||||||
const data = await getK8sPodsByDeployment(deployment.id);
|
const data = await getK8sPodsByDeployment(deployment.id);
|
||||||
setPods(data || []);
|
setPods(data || []);
|
||||||
|
if (isInitial) {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载Pod失败:', error);
|
console.error('加载Pod失败:', error);
|
||||||
|
if (isInitial) {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
toast({
|
toast({
|
||||||
title: '加载失败',
|
title: '加载失败',
|
||||||
description: '加载Pod列表失败',
|
description: '加载Pod列表失败',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 展开时加载Pod
|
// 展开时加载Pod
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
loadPods();
|
setInitialLoading(true);
|
||||||
|
loadPods(true);
|
||||||
|
} else {
|
||||||
|
// 收起时重置状态
|
||||||
|
setInitialLoading(true);
|
||||||
|
setPods([]);
|
||||||
}
|
}
|
||||||
}, [isExpanded]);
|
}, [isExpanded]);
|
||||||
|
|
||||||
@ -70,23 +111,106 @@ export const DeploymentRow: React.FC<DeploymentRowProps> = ({
|
|||||||
const ready = deployment.readyReplicas ?? 0;
|
const ready = deployment.readyReplicas ?? 0;
|
||||||
const desired = deployment.replicas ?? 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 (
|
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">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
{hasReplicas ? (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={onToggleExpand}
|
size="sm"
|
||||||
className="h-6 w-6 p-0"
|
className="h-6 w-6 p-0 pointer-events-none"
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="h-6 w-6" />
|
||||||
|
)}
|
||||||
<Layers className="h-4 w-4 text-blue-600" />
|
<Layers className="h-4 w-4 text-blue-600" />
|
||||||
<span className="font-medium">{deployment.deploymentName}</span>
|
<span className="font-medium">{deployment.deploymentName}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -95,14 +219,7 @@ export const DeploymentRow: React.FC<DeploymentRowProps> = ({
|
|||||||
<HealthBar deployment={deployment} />
|
<HealthBar deployment={deployment} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<Button
|
<span className="text-sm font-mono text-gray-700">{ready}/{desired}</span>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onToggleExpand}
|
|
||||||
className="h-7 font-mono"
|
|
||||||
>
|
|
||||||
{ready}/{desired}
|
|
||||||
</Button>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<span className="text-sm text-gray-600 font-mono truncate block max-w-xs" title={deployment.image}>
|
<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() : '-'}
|
{deployment.k8sUpdateTime ? dayjs(deployment.k8sUpdateTime).fromNow() : '-'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<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()}>
|
||||||
<OperationMenu deployment={deployment} onSuccess={onRefresh} />
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<>
|
<>
|
||||||
{loading ? (
|
{initialLoading ? (
|
||||||
<tr>
|
<>
|
||||||
<td colSpan={7} className="px-6 py-4 text-center">
|
{Array.from({ length: deployment.replicas || 1 }).map((_, index) => (
|
||||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-primary" />
|
<PodRowSkeleton key={index} />
|
||||||
</td>
|
))}
|
||||||
</tr>
|
</>
|
||||||
) : pods.length === 0 ? (
|
) : pods.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-6 py-4 text-center text-gray-500">
|
<td colSpan={7} className="px-6 py-4 text-center text-gray-500">
|
||||||
@ -140,12 +330,143 @@ export const DeploymentRow: React.FC<DeploymentRowProps> = ({
|
|||||||
<PodRow
|
<PodRow
|
||||||
key={pod.name}
|
key={pod.name}
|
||||||
pod={pod}
|
pod={pod}
|
||||||
|
deploymentId={deployment.id}
|
||||||
onViewLogs={onViewLogs}
|
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 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 { DeploymentRow } from './DeploymentRow';
|
||||||
import type { K8sDeploymentResponse, K8sPodResponse } from '../types';
|
import type { K8sDeploymentResponse, K8sPodResponse } from '../types';
|
||||||
|
|
||||||
@ -7,7 +7,7 @@ interface DeploymentTableProps {
|
|||||||
deployments: K8sDeploymentResponse[];
|
deployments: K8sDeploymentResponse[];
|
||||||
expandedIds: Set<number>;
|
expandedIds: Set<number>;
|
||||||
onToggleExpand: (id: number) => void;
|
onToggleExpand: (id: number) => void;
|
||||||
onViewLogs: (pod: K8sPodResponse) => void;
|
onViewLogs: (pod: K8sPodResponse, deploymentId: number) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,8 +27,8 @@ export const DeploymentTable: React.FC<DeploymentTableProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-auto">
|
||||||
<Table>
|
<table className="w-full caption-bottom text-sm" style={{ minWidth: '1200px' }}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-64">名称</TableHead>
|
<TableHead className="w-64">名称</TableHead>
|
||||||
@ -37,7 +37,7 @@ export const DeploymentTable: React.FC<DeploymentTableProps> = ({
|
|||||||
<TableHead className="w-80">镜像</TableHead>
|
<TableHead className="w-80">镜像</TableHead>
|
||||||
<TableHead className="w-24">重启</TableHead>
|
<TableHead className="w-24">重启</TableHead>
|
||||||
<TableHead className="w-32">更新时间</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -52,7 +52,7 @@ export const DeploymentTable: React.FC<DeploymentTableProps> = ({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</table>
|
||||||
</div>
|
</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 { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { 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 type { K8sPodResponse } from '../types';
|
||||||
import { PodPhaseLabels } from '../types';
|
import { PodPhaseLabels } from '../types';
|
||||||
|
|
||||||
interface PodRowProps {
|
interface PodRowProps {
|
||||||
pod: K8sPodResponse;
|
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 { toast } = useToast();
|
||||||
const [copiedField, setCopiedField] = React.useState<string | null>(null);
|
const [yamlDialogOpen, setYamlDialogOpen] = useState(false);
|
||||||
const phaseLabel = PodPhaseLabels[pod.phase];
|
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(() => {
|
const mainContainer = pod.containers[0];
|
||||||
setCopiedField(field);
|
|
||||||
toast({
|
// 复制镜像名称
|
||||||
title: '已复制',
|
const handleCopyImage = () => {
|
||||||
description: `已复制${field}到剪贴板`,
|
if (mainContainer?.image) {
|
||||||
|
navigator.clipboard.writeText(mainContainer.image).then(() => {
|
||||||
|
setCopiedImage(true);
|
||||||
|
toast({
|
||||||
|
title: '已复制',
|
||||||
|
description: '镜像名称已复制到剪贴板',
|
||||||
|
});
|
||||||
|
setTimeout(() => setCopiedImage(false), 2000);
|
||||||
});
|
});
|
||||||
setTimeout(() => setCopiedField(null), 2000);
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}>
|
<tr className="border-t border-gray-100 bg-gray-50/50 hover:bg-gray-100/50">
|
||||||
<div className="flex items-center gap-4 pl-8">
|
{/* Pod信息列(占6列) */}
|
||||||
{/* Pod名称 */}
|
<td className="px-6 py-4 bg-gray-50/50" colSpan={6}>
|
||||||
<div className="flex-shrink-0 w-48 flex items-center gap-2">
|
<div className="pl-8 space-y-3">
|
||||||
<Box className="h-3.5 w-3.5 text-green-600" />
|
{/* 第一行:Pod名称 + 状态 */}
|
||||||
<span className="text-sm font-mono text-gray-700">{pod.name}</span>
|
<div className="flex items-center gap-4">
|
||||||
</div>
|
{/* 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 flex-shrink-0`}>
|
||||||
<Badge variant={phaseLabel.variant} className={`${phaseLabel.color} text-xs`}>
|
{phaseLabel.label}
|
||||||
{phaseLabel.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 就绪状态 */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Badge variant={pod.ready ? 'default' : 'destructive'} className="text-xs">
|
|
||||||
{pod.ready ? '就绪' : '未就绪'}
|
|
||||||
</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}次
|
|
||||||
</Badge>
|
</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">
|
{!pod.ready && (
|
||||||
<span className="text-sm text-gray-600 font-mono">{pod.podIP || '-'}</span>
|
<Badge variant="destructive" className="text-xs flex-shrink-0">
|
||||||
{pod.podIP && (
|
未就绪
|
||||||
<Button
|
</Badge>
|
||||||
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"
|
{pod.restartCount > 0 && (
|
||||||
>
|
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 text-xs flex-shrink-0">
|
||||||
{copiedField === 'Pod IP' ? (
|
重启 {pod.restartCount}次
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 第二行:详细信息 */}
|
||||||
|
<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={handleCopyImage}
|
||||||
|
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<Copy className="h-3 w-3" />
|
<div className="text-center text-muted-foreground py-8">暂无标签</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</TabsContent>
|
||||||
)}
|
|
||||||
</div>
|
<TabsContent value="annotations" className="mt-4">
|
||||||
|
{pod.annotations && Object.keys(pod.annotations).length > 0 ? (
|
||||||
{/* 节点 */}
|
<div className="space-y-2">
|
||||||
<div className="flex-1 min-w-0">
|
{Object.entries(pod.annotations).map(([key, value]) => (
|
||||||
<span className="text-sm text-gray-600 truncate block">{pod.nodeName || '-'}</span>
|
<div
|
||||||
</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-shrink-0">
|
<div className="flex-1 min-w-0 font-mono text-xs break-all">
|
||||||
<Button
|
<span className="text-purple-600 dark:text-purple-400 font-semibold">
|
||||||
variant="ghost"
|
{key}
|
||||||
size="sm"
|
</span>
|
||||||
onClick={() => onViewLogs(pod)}
|
<span className="text-muted-foreground">: </span>
|
||||||
className="h-7"
|
<span className="text-foreground">{value}</span>
|
||||||
>
|
</div>
|
||||||
<FileText className="h-3 w-3 mr-1" />
|
</div>
|
||||||
日志
|
))}
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
</div>
|
<div className="text-center text-muted-foreground py-8">暂无注解</div>
|
||||||
</td>
|
)}
|
||||||
</tr>
|
</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 { Input } from '@/components/ui/input';
|
||||||
import { RefreshCw, Loader2, Cloud, History as HistoryIcon, Database, Search } from 'lucide-react';
|
import { RefreshCw, Loader2, Cloud, History as HistoryIcon, Database, Search } from 'lucide-react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
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 { StatsCards } from './components/StatsCards';
|
||||||
import { FilterTabs } from './components/FilterTabs';
|
import { FilterTabs } from './components/FilterTabs';
|
||||||
import { DeploymentTable } from './components/DeploymentTable';
|
import { DeploymentTable } from './components/DeploymentTable';
|
||||||
import { DeploymentTableSkeleton } from './components/DeploymentTableSkeleton';
|
import { DeploymentTableSkeleton } from './components/DeploymentTableSkeleton';
|
||||||
import { SyncHistoryTab } from './components/SyncHistoryTab';
|
import { SyncHistoryTab } from './components/SyncHistoryTab';
|
||||||
|
import PodLogDialog from './components/PodLogDialog';
|
||||||
import { getExternalSystemList } from '@/pages/Resource/External/List/service';
|
import { getExternalSystemList } from '@/pages/Resource/External/List/service';
|
||||||
import { syncK8sNamespace, syncK8sDeployment, getK8sNamespaceList, getK8sDeploymentByNamespace } from './service';
|
import { syncK8sNamespace, syncK8sDeployment, getK8sNamespaceList, getK8sDeploymentByNamespace } from './service';
|
||||||
import type { ExternalSystemResponse } from '@/pages/Resource/External/List/types';
|
import type { ExternalSystemResponse } from '@/pages/Resource/External/List/types';
|
||||||
@ -23,10 +26,12 @@ import dayjs from 'dayjs';
|
|||||||
|
|
||||||
const K8sManagement: React.FC = () => {
|
const K8sManagement: React.FC = () => {
|
||||||
const { toast } = useToast();
|
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 [k8sClusters, setK8sClusters] = useState<ExternalSystemResponse[]>([]);
|
||||||
const [selectedClusterId, setSelectedClusterId] = useState<number | null>(null);
|
|
||||||
const [namespaces, setNamespaces] = useState<K8sNamespaceResponse[]>([]);
|
const [namespaces, setNamespaces] = useState<K8sNamespaceResponse[]>([]);
|
||||||
const [selectedNamespaceId, setSelectedNamespaceId] = useState<number | null>(null);
|
|
||||||
const [deployments, setDeployments] = useState<K8sDeploymentResponse[]>([]);
|
const [deployments, setDeployments] = useState<K8sDeploymentResponse[]>([]);
|
||||||
const [syncingNamespace, setSyncingNamespace] = useState(false);
|
const [syncingNamespace, setSyncingNamespace] = useState(false);
|
||||||
const [syncingDeployment, setSyncingDeployment] = useState(false);
|
const [syncingDeployment, setSyncingDeployment] = useState(false);
|
||||||
@ -35,6 +40,8 @@ const K8sManagement: React.FC = () => {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [activeFilter, setActiveFilter] = useState<HealthStatus | 'all'>('all');
|
const [activeFilter, setActiveFilter] = useState<HealthStatus | 'all'>('all');
|
||||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date());
|
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();
|
const { expandedIds, toggleExpand } = useDeploymentExpand();
|
||||||
|
|
||||||
@ -46,9 +53,14 @@ const K8sManagement: React.FC = () => {
|
|||||||
const enabledClusters = (clusters || []).filter(c => c.enabled);
|
const enabledClusters = (clusters || []).filter(c => c.enabled);
|
||||||
setK8sClusters(enabledClusters);
|
setK8sClusters(enabledClusters);
|
||||||
|
|
||||||
// 如果有集群,默认选择第一个
|
// 如果有集群且store中没有选中的集群,默认选择第一个
|
||||||
if (enabledClusters.length > 0 && !selectedClusterId) {
|
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) {
|
} catch (error) {
|
||||||
console.error('加载K8S集群失败:', error);
|
console.error('加载K8S集群失败:', error);
|
||||||
@ -69,9 +81,13 @@ const K8sManagement: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const data = await getK8sNamespaceList(selectedClusterId);
|
const data = await getK8sNamespaceList(selectedClusterId);
|
||||||
setNamespaces(data || []);
|
setNamespaces(data || []);
|
||||||
// 默认选择第一个命名空间
|
// 如果有命名空间且store中没有选中的命名空间,默认选择第一个
|
||||||
if (data && data.length > 0 && !selectedNamespaceId) {
|
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) {
|
} catch (error) {
|
||||||
console.error('加载命名空间失败:', error);
|
console.error('加载命名空间失败:', error);
|
||||||
@ -232,9 +248,9 @@ const K8sManagement: React.FC = () => {
|
|||||||
}, [deployments]);
|
}, [deployments]);
|
||||||
|
|
||||||
// 处理日志查看
|
// 处理日志查看
|
||||||
const handleViewLogs = (pod: K8sPodResponse) => {
|
const handleViewLogs = (pod: K8sPodResponse, deploymentId: number) => {
|
||||||
// TODO: 打开日志抽屉
|
setSelectedPod({ ...pod, deploymentId });
|
||||||
console.log('查看日志:', pod.name);
|
setLogDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (initialLoading) {
|
if (initialLoading) {
|
||||||
@ -292,7 +308,12 @@ const K8sManagement: React.FC = () => {
|
|||||||
<div className="flex items-center gap-4 flex-wrap">
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
<Select
|
<Select
|
||||||
value={selectedClusterId ? String(selectedClusterId) : undefined}
|
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">
|
<SelectTrigger className="w-48">
|
||||||
<SelectValue placeholder="选择K8S集群" />
|
<SelectValue placeholder="选择K8S集群" />
|
||||||
@ -308,7 +329,7 @@ const K8sManagement: React.FC = () => {
|
|||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={selectedNamespaceId ? String(selectedNamespaceId) : undefined}
|
value={selectedNamespaceId ? String(selectedNamespaceId) : undefined}
|
||||||
onValueChange={(value) => setSelectedNamespaceId(Number(value))}
|
onValueChange={(value) => dispatch(setSelectedNamespaceId(Number(value)))}
|
||||||
disabled={!selectedClusterId || namespaces.length === 0}
|
disabled={!selectedClusterId || namespaces.length === 0}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-48">
|
<SelectTrigger className="w-48">
|
||||||
@ -398,6 +419,16 @@ const K8sManagement: React.FC = () => {
|
|||||||
onViewLogs={handleViewLogs}
|
onViewLogs={handleViewLogs}
|
||||||
onRefresh={loadDeployments}
|
onRefresh={loadDeployments}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Pod日志对话框 */}
|
||||||
|
{selectedPod && (
|
||||||
|
<PodLogDialog
|
||||||
|
open={logDialogOpen}
|
||||||
|
onOpenChange={setLogDialogOpen}
|
||||||
|
deploymentId={selectedPod.deploymentId}
|
||||||
|
podName={selectedPod.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@ -163,15 +163,15 @@ export interface K8sSyncHistoryQuery extends BaseQuery {
|
|||||||
*/
|
*/
|
||||||
export enum PodPhase {
|
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 {
|
export enum ContainerState {
|
||||||
/** 运行中 */
|
/** 运行中 */
|
||||||
RUNNING = 'running',
|
RUNNING = 'RUNNING',
|
||||||
/** 等待中 */
|
/** 等待中 */
|
||||||
WAITING = 'waiting',
|
WAITING = 'WAITING',
|
||||||
/** 已终止 */
|
/** 已终止 */
|
||||||
TERMINATED = 'terminated',
|
TERMINATED = 'TERMINATED',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pod状态标签映射
|
// 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 {configureStore} from '@reduxjs/toolkit';
|
||||||
import userReducer from './userSlice';
|
import userReducer from './userSlice';
|
||||||
|
import k8sReducer from './k8sSlice';
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
user: userReducer,
|
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