1.30 k8s管理

This commit is contained in:
dengqichen 2025-12-13 22:39:03 +08:00
parent 7471571e8c
commit 7e1d78a898
16 changed files with 1540 additions and 246 deletions

View 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` 提供日志格式化函数

View 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 '';
};

View 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;

View 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',
},
});
};

View 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;
}

View File

@ -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>

View File

@ -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>
</>
);
};

View File

@ -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>
);
};

View 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;

View File

@ -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>
</>
);
};

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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状态标签映射

View 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;

View File

@ -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,
},
});

View 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;