1.33 日志通用查询
This commit is contained in:
parent
82eb9ca6d6
commit
8f09e63ea1
@ -18,6 +18,7 @@ export interface TerminalWindow<TResource = any> {
|
|||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
size: { width: number; height: number };
|
size: { width: number; height: number };
|
||||||
connectionStatus?: ConnectionStatus;
|
connectionStatus?: ConnectionStatus;
|
||||||
|
titleNode?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,7 +28,7 @@ interface TerminalWindowManagerProps<TResource = any> {
|
|||||||
/** 终端类型 */
|
/** 终端类型 */
|
||||||
type: TerminalType;
|
type: TerminalType;
|
||||||
/** 获取窗口标题 */
|
/** 获取窗口标题 */
|
||||||
getWindowTitle: (resource: TResource) => string;
|
getWindowTitle: (resource: TResource) => string | React.ReactNode;
|
||||||
/** 获取窗口副标题 */
|
/** 获取窗口副标题 */
|
||||||
getWindowSubtitle: (resource: TResource) => string;
|
getWindowSubtitle: (resource: TResource) => string;
|
||||||
/** 获取资源ID */
|
/** 获取资源ID */
|
||||||
@ -36,6 +37,7 @@ interface TerminalWindowManagerProps<TResource = any> {
|
|||||||
renderTerminal: (windowId: string, resource: TResource, callbacks: {
|
renderTerminal: (windowId: string, resource: TResource, callbacks: {
|
||||||
onCloseReady: () => void;
|
onCloseReady: () => void;
|
||||||
onStatusChange: (status: ConnectionStatus) => void;
|
onStatusChange: (status: ConnectionStatus) => void;
|
||||||
|
updateWindowTitle: (titleNode: React.ReactNode) => void;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
/** 窗口打开回调 */
|
/** 窗口打开回调 */
|
||||||
onOpenWindow?: (windowId: string) => void;
|
onOpenWindow?: (windowId: string) => void;
|
||||||
@ -145,6 +147,15 @@ export function TerminalWindowManager<TResource = any>({
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 更新窗口标题
|
||||||
|
const updateWindowTitle = useCallback((windowId: string, titleNode: React.ReactNode) => {
|
||||||
|
setWindows(prev =>
|
||||||
|
prev.map(w =>
|
||||||
|
w.id === windowId ? { ...w, titleNode } : w
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 获取状态对应的按钮样式
|
// 获取状态对应的按钮样式
|
||||||
const getButtonStyle = (status?: ConnectionStatus) => {
|
const getButtonStyle = (status?: ConnectionStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@ -195,7 +206,7 @@ export function TerminalWindowManager<TResource = any>({
|
|||||||
>
|
>
|
||||||
<DraggableWindow
|
<DraggableWindow
|
||||||
id={win.id}
|
id={win.id}
|
||||||
title={getWindowTitle(win.resource)}
|
title={win.titleNode || getWindowTitle(win.resource)}
|
||||||
onClose={() => closeWindow(win.id)}
|
onClose={() => closeWindow(win.id)}
|
||||||
onMinimize={() => minimizeWindow(win.id)}
|
onMinimize={() => minimizeWindow(win.id)}
|
||||||
isActive={activeWindowId === win.id}
|
isActive={activeWindowId === win.id}
|
||||||
@ -206,6 +217,7 @@ export function TerminalWindowManager<TResource = any>({
|
|||||||
{renderTerminal(win.id, win.resource, {
|
{renderTerminal(win.id, win.resource, {
|
||||||
onCloseReady: () => actuallyCloseWindow(win.id),
|
onCloseReady: () => actuallyCloseWindow(win.id),
|
||||||
onStatusChange: (status) => updateWindowStatus(win.id, status),
|
onStatusChange: (status) => updateWindowStatus(win.id, status),
|
||||||
|
updateWindowTitle: (titleNode) => updateWindowTitle(win.id, titleNode),
|
||||||
})}
|
})}
|
||||||
</DraggableWindow>
|
</DraggableWindow>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -91,7 +91,7 @@ const DialogHeader = ({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-1.5 text-center sm:text-left flex-shrink-0 pl-6 pr-12 pt-6",
|
"flex flex-col space-y-1.5 text-center sm:text-left flex-shrink-0 pl-6 pr-16 pt-6",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
|
|
||||||
interface DraggableWindowProps {
|
interface DraggableWindowProps {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string | React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onMinimize: () => void;
|
onMinimize: () => void;
|
||||||
|
|||||||
@ -230,10 +230,10 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
|
|||||||
const showSearchBar = searchFields && searchFields.length > 0;
|
const showSearchBar = searchFields && searchFields.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0">
|
<div className="flex flex-col h-full">
|
||||||
{/* 搜索栏 */}
|
{/* 固定区域:搜索栏 */}
|
||||||
{(showSearchBar || searchBar || toolbar) && (
|
{(showSearchBar || searchBar || toolbar) && (
|
||||||
<div className={`flex flex-wrap items-center gap-4 ${searchGap}`}>
|
<div className={`flex-shrink-0 flex flex-wrap items-center gap-4 ${searchGap}`}>
|
||||||
{searchBar || (
|
{searchBar || (
|
||||||
<>
|
<>
|
||||||
{searchFields?.map(renderSearchField)}
|
{searchFields?.map(renderSearchField)}
|
||||||
@ -249,58 +249,65 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 表格 */}
|
{/* 可滚动区域:表格 + 分页 */}
|
||||||
<div className="rounded-md border">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<Table minWidth={minWidth}>
|
{/* 表格 */}
|
||||||
<TableHeader>
|
<div className="flex-1 overflow-auto">
|
||||||
<TableRow>
|
<div className="rounded-md border">
|
||||||
{columns.map(col => (
|
<Table minWidth={minWidth}>
|
||||||
<TableHead key={col.key} width={col.width} sticky={col.sticky}>
|
<TableHeader>
|
||||||
{col.title}
|
<TableRow>
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
<span className="text-muted-foreground">{loadingText}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : hasData ? (
|
|
||||||
data.content.map((record, index) => (
|
|
||||||
<TableRow key={getRowKey(record, rowKey, index)}>
|
|
||||||
{columns.map(col => (
|
{columns.map(col => (
|
||||||
<TableCell key={col.key} width={col.width} sticky={col.sticky} className={col.className}>
|
<TableHead key={col.key} width={col.width} sticky={col.sticky}>
|
||||||
{renderCell(col, record, index)}
|
{col.title}
|
||||||
</TableCell>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
</TableHeader>
|
||||||
) : (
|
<TableBody>
|
||||||
<TableRow>
|
{loading ? (
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
<TableRow>
|
||||||
<span className="text-muted-foreground">{emptyText}</span>
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
</TableCell>
|
<div className="flex items-center justify-center gap-2">
|
||||||
</TableRow>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
)}
|
<span className="text-muted-foreground">{loadingText}</span>
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
</TableCell>
|
||||||
</div>
|
</TableRow>
|
||||||
|
) : hasData ? (
|
||||||
|
data.content.map((record, index) => (
|
||||||
|
<TableRow key={getRowKey(record, rowKey, index)}>
|
||||||
|
{columns.map(col => (
|
||||||
|
<TableCell key={col.key} width={col.width} sticky={col.sticky} className={col.className}>
|
||||||
|
{renderCell(col, record, index)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
<span className="text-muted-foreground">{emptyText}</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 分页 */}
|
||||||
{pageCount > 0 && (
|
{pageCount > 0 && (
|
||||||
<DataTablePagination
|
<div className="flex-shrink-0 pt-4">
|
||||||
pageIndex={pageNum}
|
<DataTablePagination
|
||||||
pageSize={pageSize}
|
pageIndex={pageNum}
|
||||||
pageCount={pageCount}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
pageCount={pageCount}
|
||||||
/>
|
onPageChange={handlePageChange}
|
||||||
)}
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -120,7 +120,13 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
|||||||
<TabsContent value="runtime" className="mt-0 h-[380px]">
|
<TabsContent value="runtime" className="mt-0 h-[380px]">
|
||||||
<RuntimeTabContent
|
<RuntimeTabContent
|
||||||
app={app}
|
app={app}
|
||||||
onLogClick={() => setLogDialogOpen(true)}
|
onLogClick={() => {
|
||||||
|
// 调用全局方法打开日志窗口
|
||||||
|
const openLogWindow = (window as any).__openLogWindow;
|
||||||
|
if (openLogWindow) {
|
||||||
|
openLogWindow({ app, environment });
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
416
frontend/src/pages/Dashboard/components/LogViewerWindow.tsx
Normal file
416
frontend/src/pages/Dashboard/components/LogViewerWindow.tsx
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* 日志查看窗口组件
|
||||||
|
* 用于在可拖动窗口中显示日志流
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Server,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Square,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
ScrollText,
|
||||||
|
Circle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { LogStreamViewer } from './LogStreamViewer';
|
||||||
|
import { useLogStream } from '../hooks/useLogStream';
|
||||||
|
import { LogStreamStatus } from '../types/logStream';
|
||||||
|
import type { ApplicationConfig, DeployEnvironment } from '../types';
|
||||||
|
import type { ConnectionStatus } from '@/components/Terminal';
|
||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
interface LogViewerWindowProps {
|
||||||
|
windowId: string;
|
||||||
|
app: ApplicationConfig;
|
||||||
|
environment: DeployEnvironment;
|
||||||
|
onCloseReady: () => void;
|
||||||
|
onStatusChange: (status: ConnectionStatus) => void;
|
||||||
|
updateWindowTitle: (titleNode: React.ReactNode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogViewerWindow: React.FC<LogViewerWindowProps> = ({
|
||||||
|
windowId,
|
||||||
|
app,
|
||||||
|
environment,
|
||||||
|
onCloseReady,
|
||||||
|
onStatusChange,
|
||||||
|
updateWindowTitle,
|
||||||
|
}) => {
|
||||||
|
const [lines, setLines] = useState(500);
|
||||||
|
const [podName, setPodName] = useState('');
|
||||||
|
const [podNames, setPodNames] = useState<string[]>([]);
|
||||||
|
const [loadingPods, setLoadingPods] = useState(false);
|
||||||
|
|
||||||
|
const closeAllRef = useRef<(() => void) | null>(null);
|
||||||
|
const onCloseReadyRef = useRef(onCloseReady);
|
||||||
|
const onStatusChangeRef = useRef(onStatusChange);
|
||||||
|
const updateWindowTitleRef = useRef(updateWindowTitle);
|
||||||
|
|
||||||
|
// 保持最新的回调引用
|
||||||
|
useEffect(() => {
|
||||||
|
onCloseReadyRef.current = onCloseReady;
|
||||||
|
}, [onCloseReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onStatusChangeRef.current = onStatusChange;
|
||||||
|
}, [onStatusChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateWindowTitleRef.current = updateWindowTitle;
|
||||||
|
}, [updateWindowTitle]);
|
||||||
|
|
||||||
|
// 使用WebSocket日志流Hook
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
logs,
|
||||||
|
error,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
start,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
clearLogs,
|
||||||
|
} = useLogStream({
|
||||||
|
teamAppId: app.teamApplicationId,
|
||||||
|
autoConnect: false,
|
||||||
|
maxLines: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取K8S Pod列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (app.runtimeType === 'K8S') {
|
||||||
|
setLoadingPods(true);
|
||||||
|
request.get<string[]>(`/api/v1/team-applications/${app.teamApplicationId}/pod-names`)
|
||||||
|
.then((response) => {
|
||||||
|
if (response && response.length > 0) {
|
||||||
|
setPodNames(response);
|
||||||
|
setPodName(response[0]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[LogViewer] Failed to fetch pod names:', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingPods(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [app.runtimeType, app.teamApplicationId]);
|
||||||
|
|
||||||
|
// 窗口打开时连接
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 连接成功后自动启动日志流
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === LogStreamStatus.CONNECTED) {
|
||||||
|
if (app.runtimeType === 'K8S' && loadingPods) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const startParams: any = { lines };
|
||||||
|
if (app.runtimeType === 'K8S' && podName) {
|
||||||
|
startParams.name = podName;
|
||||||
|
}
|
||||||
|
start(startParams);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [status, loadingPods, podName]);
|
||||||
|
|
||||||
|
// 同步状态到窗口管理器
|
||||||
|
useEffect(() => {
|
||||||
|
const connectionStatus: ConnectionStatus =
|
||||||
|
status === LogStreamStatus.CONNECTED || status === LogStreamStatus.STREAMING ? 'connected' :
|
||||||
|
status === LogStreamStatus.CONNECTING ? 'connecting' :
|
||||||
|
status === LogStreamStatus.ERROR ? 'error' :
|
||||||
|
'disconnected';
|
||||||
|
|
||||||
|
onStatusChangeRef.current(connectionStatus);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
// 注册优雅关闭方法
|
||||||
|
useEffect(() => {
|
||||||
|
const closeMethodName = `__closeSSH_${windowId}`;
|
||||||
|
(window as any)[closeMethodName] = () => {
|
||||||
|
console.log(`[LogViewerWindow] 调用优雅关闭方法: ${windowId}`);
|
||||||
|
// 先断开连接
|
||||||
|
if (closeAllRef.current) {
|
||||||
|
closeAllRef.current();
|
||||||
|
}
|
||||||
|
disconnect();
|
||||||
|
// 再通知窗口可以关闭
|
||||||
|
onCloseReadyRef.current();
|
||||||
|
};
|
||||||
|
console.log(`[LogViewerWindow] 注册优雅关闭方法: ${closeMethodName}`);
|
||||||
|
|
||||||
|
// 保存关闭方法
|
||||||
|
closeAllRef.current = () => {
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
delete (window as any)[closeMethodName];
|
||||||
|
console.log(`[LogViewerWindow] 注销优雅关闭方法: ${closeMethodName}`);
|
||||||
|
};
|
||||||
|
}, [windowId, disconnect]);
|
||||||
|
|
||||||
|
const getRuntimeIcon = () => {
|
||||||
|
switch (app.runtimeType) {
|
||||||
|
case 'K8S':
|
||||||
|
return { icon: Box, color: 'text-purple-600', bg: 'bg-purple-100', label: 'Kubernetes' };
|
||||||
|
case 'DOCKER':
|
||||||
|
return { icon: Container, color: 'text-orange-600', bg: 'bg-orange-100', label: 'Docker' };
|
||||||
|
case 'SERVER':
|
||||||
|
return { icon: Server, color: 'text-gray-600', bg: 'bg-gray-100', label: '服务器' };
|
||||||
|
default:
|
||||||
|
return { icon: Server, color: 'text-gray-600', bg: 'bg-gray-100', label: '未知' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtimeConfig = getRuntimeIcon();
|
||||||
|
const RuntimeIcon = runtimeConfig.icon;
|
||||||
|
|
||||||
|
const getStatusIndicator = () => {
|
||||||
|
switch (status) {
|
||||||
|
case LogStreamStatus.CONNECTING:
|
||||||
|
return { color: 'text-yellow-500', label: '连接中' };
|
||||||
|
case LogStreamStatus.CONNECTED:
|
||||||
|
return { color: 'text-blue-500', label: '已连接' };
|
||||||
|
case LogStreamStatus.STREAMING:
|
||||||
|
return { color: 'text-green-500', label: '流式传输中' };
|
||||||
|
case LogStreamStatus.PAUSED:
|
||||||
|
return { color: 'text-orange-500', label: '已暂停' };
|
||||||
|
case LogStreamStatus.STOPPED:
|
||||||
|
return { color: 'text-gray-500', label: '已停止' };
|
||||||
|
case LogStreamStatus.ERROR:
|
||||||
|
return { color: 'text-red-500', label: '错误' };
|
||||||
|
default:
|
||||||
|
return { color: 'text-gray-500', label: '未连接' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusIndicator = getStatusIndicator();
|
||||||
|
|
||||||
|
// 构建动态标题
|
||||||
|
const buildTitle = useCallback(() => {
|
||||||
|
const RuntimeIcon = runtimeConfig.icon;
|
||||||
|
const statusColor = statusIndicator.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 状态指示点 */}
|
||||||
|
<Circle className={`h-2.5 w-2.5 fill-current ${statusColor}`} />
|
||||||
|
|
||||||
|
{/* 运行时图标 */}
|
||||||
|
<RuntimeIcon className={`h-4 w-4 ${runtimeConfig.color}`} />
|
||||||
|
|
||||||
|
{/* 应用名称 */}
|
||||||
|
<span>{app.applicationName}</span>
|
||||||
|
|
||||||
|
{/* 分隔符 */}
|
||||||
|
<span className="text-muted-foreground">|</span>
|
||||||
|
|
||||||
|
{/* 日志查看标识 */}
|
||||||
|
<ScrollText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-normal">日志查看</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [app.applicationName, runtimeConfig, statusIndicator.color]);
|
||||||
|
|
||||||
|
// 状态变化时更新标题
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateWindowTitleRef.current) {
|
||||||
|
updateWindowTitleRef.current(buildTitle());
|
||||||
|
}
|
||||||
|
}, [buildTitle]);
|
||||||
|
|
||||||
|
const handleRestart = () => {
|
||||||
|
clearLogs();
|
||||||
|
|
||||||
|
// 如果已断开连接,需要重新建立连接
|
||||||
|
if (status === LogStreamStatus.DISCONNECTED || status === LogStreamStatus.ERROR) {
|
||||||
|
connect(); // 连接成功后会自动触发START(通过useEffect)
|
||||||
|
} else {
|
||||||
|
// 如果已连接,直接发送START消息
|
||||||
|
const startParams: any = { lines };
|
||||||
|
if (app.runtimeType === 'K8S' && podName) {
|
||||||
|
startParams.name = podName;
|
||||||
|
}
|
||||||
|
start(startParams);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
{/* 紧凑控制栏 */}
|
||||||
|
<div className="px-3 py-1.5 border-b flex-shrink-0 bg-muted/20">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={lines}
|
||||||
|
onChange={(e) => setLines(Number(e.target.value))}
|
||||||
|
min={10}
|
||||||
|
max={1000}
|
||||||
|
placeholder="行数"
|
||||||
|
className="h-7 w-16 text-xs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{app.runtimeType === 'K8S' && (
|
||||||
|
<>
|
||||||
|
{loadingPods ? (
|
||||||
|
<div className="h-7 w-40 flex items-center justify-center border rounded-md bg-background">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : podNames.length > 0 ? (
|
||||||
|
<Select value={podName} onValueChange={setPodName}>
|
||||||
|
<SelectTrigger className="h-7 w-40 text-xs">
|
||||||
|
<SelectValue placeholder="选择Pod" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{podNames.map((name) => (
|
||||||
|
<SelectItem key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={podName}
|
||||||
|
onChange={(e) => setPodName(e.target.value)}
|
||||||
|
placeholder="无可用Pod"
|
||||||
|
className="h-7 w-40 text-xs"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{/* 启动/恢复/重试按钮 */}
|
||||||
|
{(status === LogStreamStatus.DISCONNECTED ||
|
||||||
|
status === LogStreamStatus.CONNECTED ||
|
||||||
|
status === LogStreamStatus.ERROR) && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRestart}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<Play className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{status === LogStreamStatus.ERROR ? '重试' : '启动'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 连接中按钮 */}
|
||||||
|
{status === LogStreamStatus.CONNECTING && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" disabled className="h-7 w-7 p-0">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>连接中</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 恢复按钮(暂停状态) */}
|
||||||
|
{status === LogStreamStatus.PAUSED && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" onClick={resume} className="h-7 w-7 p-0">
|
||||||
|
<Play className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>恢复</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 暂停按钮(流式传输中) */}
|
||||||
|
{status === LogStreamStatus.STREAMING && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" onClick={pause} className="h-7 w-7 p-0">
|
||||||
|
<Pause className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>暂停</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 停止按钮 */}
|
||||||
|
{(status === LogStreamStatus.CONNECTING ||
|
||||||
|
status === LogStreamStatus.STREAMING ||
|
||||||
|
status === LogStreamStatus.PAUSED) && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" onClick={stop} className="h-7 w-7 p-0">
|
||||||
|
<Square className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>停止</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 清空按钮 */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={clearLogs}
|
||||||
|
disabled={status === LogStreamStatus.CONNECTING}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>清空</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 日志显示区域 */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<LogStreamViewer
|
||||||
|
logs={logs}
|
||||||
|
status={status}
|
||||||
|
error={error}
|
||||||
|
autoScroll={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
76
frontend/src/pages/Dashboard/components/LogWindowManager.tsx
Normal file
76
frontend/src/pages/Dashboard/components/LogWindowManager.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* 日志窗口管理器
|
||||||
|
* 使用 TerminalWindowManager 支持多窗口、拖拽、调整大小
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { TerminalWindowManager } from '@/components/Terminal';
|
||||||
|
import { Box, Container, Server, ScrollText } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { ApplicationConfig, DeployEnvironment } from '../types';
|
||||||
|
import { LogViewerWindow } from './LogViewerWindow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志窗口资源类型
|
||||||
|
*/
|
||||||
|
export interface LogWindowResource {
|
||||||
|
app: ApplicationConfig;
|
||||||
|
environment: DeployEnvironment;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogWindowManagerProps {
|
||||||
|
onOpenWindow?: (windowId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogWindowManager: React.FC<LogWindowManagerProps> = ({ onOpenWindow }) => {
|
||||||
|
// 获取运行时图标和配置
|
||||||
|
const getRuntimeConfig = (runtimeType: string | null) => {
|
||||||
|
switch (runtimeType) {
|
||||||
|
case 'K8S':
|
||||||
|
return { icon: Box, color: 'text-purple-600', bg: 'bg-purple-100', label: 'Kubernetes' };
|
||||||
|
case 'DOCKER':
|
||||||
|
return { icon: Container, color: 'text-orange-600', bg: 'bg-orange-100', label: 'Docker' };
|
||||||
|
case 'SERVER':
|
||||||
|
return { icon: Server, color: 'text-gray-600', bg: 'bg-gray-100', label: '服务器' };
|
||||||
|
default:
|
||||||
|
return { icon: Server, color: 'text-gray-600', bg: 'bg-gray-100', label: '未知' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TerminalWindowManager<LogWindowResource>
|
||||||
|
type="log"
|
||||||
|
getWindowTitle={(resource) => {
|
||||||
|
const runtimeConfig = getRuntimeConfig(resource.app.runtimeType);
|
||||||
|
const RuntimeIcon = runtimeConfig.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ScrollText className="h-4 w-4" />
|
||||||
|
<span>{resource.app.applicationName} - 日志查看</span>
|
||||||
|
<span className="text-muted-foreground">|</span>
|
||||||
|
<Badge variant="outline" className={runtimeConfig.bg}>
|
||||||
|
<RuntimeIcon className={`h-3 w-3 mr-1 ${runtimeConfig.color}`} />
|
||||||
|
{runtimeConfig.label}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">{resource.environment.environmentName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
getWindowSubtitle={(resource) => resource.app.applicationName}
|
||||||
|
getResourceId={(resource) => `${resource.app.teamApplicationId}-${resource.environment.environmentId}`}
|
||||||
|
renderTerminal={(windowId, resource, { onCloseReady, onStatusChange, updateWindowTitle }) => {
|
||||||
|
return (
|
||||||
|
<LogViewerWindow
|
||||||
|
windowId={windowId}
|
||||||
|
app={resource.app}
|
||||||
|
environment={resource.environment}
|
||||||
|
onCloseReady={onCloseReady}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
updateWindowTitle={updateWindowTitle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onOpenWindow={onOpenWindow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -8,6 +8,7 @@ import { EnvironmentTabs } from './components/EnvironmentTabs';
|
|||||||
import { PendingApprovalModal } from './components/PendingApprovalModal';
|
import { PendingApprovalModal } from './components/PendingApprovalModal';
|
||||||
import { TeamBookmarkDialog } from './Bookmark/components/TeamBookmarkDialog';
|
import { TeamBookmarkDialog } from './Bookmark/components/TeamBookmarkDialog';
|
||||||
import { CategoryManageDialog } from './Bookmark/components/CategoryManageDialog';
|
import { CategoryManageDialog } from './Bookmark/components/CategoryManageDialog';
|
||||||
|
import { LogWindowManager } from './components/LogWindowManager';
|
||||||
import { useDeploymentData } from './hooks/useDeploymentData';
|
import { useDeploymentData } from './hooks/useDeploymentData';
|
||||||
import { usePendingApproval } from './hooks/usePendingApproval';
|
import { usePendingApproval } from './hooks/usePendingApproval';
|
||||||
import type { ApplicationConfig } from './types';
|
import type { ApplicationConfig } from './types';
|
||||||
@ -239,6 +240,9 @@ const Dashboard: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 日志窗口管理器 */}
|
||||||
|
<LogWindowManager />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -8,6 +9,10 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Search, Check, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { getServerList } from '@/pages/Resource/Server/List/service';
|
import { getServerList } from '@/pages/Resource/Server/List/service';
|
||||||
import type { ServerResponse } from '@/pages/Resource/Server/List/types';
|
import type { ServerResponse } from '@/pages/Resource/Server/List/types';
|
||||||
|
|
||||||
@ -26,6 +31,8 @@ export const DockerRuntimeConfig: React.FC<DockerRuntimeConfigProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [servers, setServers] = useState<ServerResponse[]>([]);
|
const [servers, setServers] = useState<ServerResponse[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [serverSearchValue, setServerSearchValue] = useState('');
|
||||||
|
const [serverPopoverOpen, setServerPopoverOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadServers();
|
loadServers();
|
||||||
@ -48,28 +55,70 @@ export const DockerRuntimeConfig: React.FC<DockerRuntimeConfigProps> = ({
|
|||||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Docker服务器</Label>
|
<Label>Docker服务器</Label>
|
||||||
<Select
|
<Popover open={serverPopoverOpen} onOpenChange={setServerPopoverOpen}>
|
||||||
value={dockerServerId?.toString() || ''}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={(value) => onDockerServerChange(value ? Number(value) : null)}
|
<Button
|
||||||
disabled={loading}
|
variant="outline"
|
||||||
>
|
role="combobox"
|
||||||
<SelectTrigger>
|
disabled={loading || servers.length === 0}
|
||||||
<SelectValue placeholder={loading ? '加载中...' : '选择Docker服务器'} />
|
className={cn(
|
||||||
</SelectTrigger>
|
'w-full justify-between',
|
||||||
<SelectContent>
|
!dockerServerId && 'text-muted-foreground'
|
||||||
{servers.length === 0 ? (
|
)}
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
>
|
||||||
{loading ? '加载中...' : '暂无服务器'}
|
{dockerServerId
|
||||||
|
? (() => {
|
||||||
|
const selectedServer = servers.find((s) => s.id === dockerServerId);
|
||||||
|
return selectedServer ? `${selectedServer.serverName} (${selectedServer.hostIp})` : '选择Docker服务器';
|
||||||
|
})()
|
||||||
|
: loading
|
||||||
|
? '加载中...'
|
||||||
|
: servers.length === 0
|
||||||
|
? '暂无服务器'
|
||||||
|
: '选择Docker服务器'}
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
|
<div className="flex items-center border-b px-3">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<input
|
||||||
|
placeholder="搜索服务器..."
|
||||||
|
className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
value={serverSearchValue}
|
||||||
|
onChange={(e) => setServerSearchValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[200px]">
|
||||||
|
<div className="p-1">
|
||||||
|
{servers
|
||||||
|
.filter((server) =>
|
||||||
|
server.serverName.toLowerCase().includes(serverSearchValue.toLowerCase()) ||
|
||||||
|
server.hostIp?.toLowerCase().includes(serverSearchValue.toLowerCase())
|
||||||
|
)
|
||||||
|
.map((server) => (
|
||||||
|
<div
|
||||||
|
key={server.id}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground',
|
||||||
|
server.id === dockerServerId && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onDockerServerChange(server.id!);
|
||||||
|
setServerSearchValue('');
|
||||||
|
setServerPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{server.serverName} ({server.hostIp})
|
||||||
|
</span>
|
||||||
|
{server.id === dockerServerId && <Check className="ml-2 h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</ScrollArea>
|
||||||
servers.map((server) => (
|
</PopoverContent>
|
||||||
<SelectItem key={server.id} value={server.id!.toString()}>
|
</Popover>
|
||||||
{server.serverName} ({server.hostIp})
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -7,7 +8,10 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Loader2, Search, Check, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { getExternalSystemList } from '@/pages/Resource/External/List/service';
|
import { getExternalSystemList } from '@/pages/Resource/External/List/service';
|
||||||
import { getK8sNamespaceList, getK8sDeploymentByNamespace } from '@/pages/Resource/K8s/List/service';
|
import { getK8sNamespaceList, getK8sDeploymentByNamespace } from '@/pages/Resource/K8s/List/service';
|
||||||
|
|
||||||
@ -33,6 +37,10 @@ export const K8sRuntimeConfig: React.FC<K8sRuntimeConfigProps> = ({
|
|||||||
const [deployments, setDeployments] = useState<any[]>([]);
|
const [deployments, setDeployments] = useState<any[]>([]);
|
||||||
const [loadingNamespaces, setLoadingNamespaces] = useState(false);
|
const [loadingNamespaces, setLoadingNamespaces] = useState(false);
|
||||||
const [loadingDeployments, setLoadingDeployments] = useState(false);
|
const [loadingDeployments, setLoadingDeployments] = useState(false);
|
||||||
|
const [namespaceSearchValue, setNamespaceSearchValue] = useState('');
|
||||||
|
const [namespacePopoverOpen, setNamespacePopoverOpen] = useState(false);
|
||||||
|
const [deploymentSearchValue, setDeploymentSearchValue] = useState('');
|
||||||
|
const [deploymentPopoverOpen, setDeploymentPopoverOpen] = useState(false);
|
||||||
|
|
||||||
// 加载K8S系统列表
|
// 加载K8S系统列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -130,67 +138,147 @@ export const K8sRuntimeConfig: React.FC<K8sRuntimeConfigProps> = ({
|
|||||||
{/* Namespace选择 */}
|
{/* Namespace选择 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Namespace</Label>
|
<Label>Namespace</Label>
|
||||||
<Select
|
{k8sSystemId ? (
|
||||||
value={k8sNamespaceId?.toString() || ''}
|
<Popover open={namespacePopoverOpen} onOpenChange={setNamespacePopoverOpen}>
|
||||||
onValueChange={(value) => {
|
<PopoverTrigger asChild>
|
||||||
onK8sNamespaceChange(Number(value));
|
<Button
|
||||||
}}
|
variant="outline"
|
||||||
disabled={!k8sSystemId || loadingNamespaces}
|
role="combobox"
|
||||||
>
|
disabled={loadingNamespaces || namespaces.length === 0}
|
||||||
<SelectTrigger>
|
className={cn(
|
||||||
<SelectValue placeholder={loadingNamespaces ? '加载中...' : '选择Namespace'} />
|
'w-full justify-between',
|
||||||
</SelectTrigger>
|
!k8sNamespaceId && 'text-muted-foreground'
|
||||||
<SelectContent>
|
)}
|
||||||
{loadingNamespaces ? (
|
>
|
||||||
<div className="p-4 text-center">
|
{k8sNamespaceId
|
||||||
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
? (() => {
|
||||||
|
const selectedNs = namespaces.find((ns) => ns.id === k8sNamespaceId);
|
||||||
|
return selectedNs ? selectedNs.namespaceName : '选择Namespace';
|
||||||
|
})()
|
||||||
|
: loadingNamespaces
|
||||||
|
? '加载中...'
|
||||||
|
: namespaces.length === 0
|
||||||
|
? '暂无Namespace'
|
||||||
|
: '选择Namespace'}
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
|
<div className="flex items-center border-b px-3">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<input
|
||||||
|
placeholder="搜索Namespace..."
|
||||||
|
className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
value={namespaceSearchValue}
|
||||||
|
onChange={(e) => setNamespaceSearchValue(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : namespaces.length === 0 ? (
|
<ScrollArea className="h-[200px]">
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-1">
|
||||||
{k8sSystemId ? '暂无Namespace' : '请先选择K8S系统'}
|
{namespaces
|
||||||
</div>
|
.filter((ns) =>
|
||||||
) : (
|
ns.namespaceName.toLowerCase().includes(namespaceSearchValue.toLowerCase())
|
||||||
namespaces.map((ns) => (
|
)
|
||||||
<SelectItem key={ns.id} value={ns.id.toString()}>
|
.map((ns) => (
|
||||||
{ns.namespaceName}
|
<div
|
||||||
</SelectItem>
|
key={ns.id}
|
||||||
))
|
className={cn(
|
||||||
)}
|
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground',
|
||||||
</SelectContent>
|
ns.id === k8sNamespaceId && 'bg-accent text-accent-foreground'
|
||||||
</Select>
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onK8sNamespaceChange(ns.id);
|
||||||
|
setNamespaceSearchValue('');
|
||||||
|
setNamespacePopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{ns.namespaceName}</span>
|
||||||
|
{ns.id === k8sNamespaceId && <Check className="ml-2 h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" disabled className="w-full justify-between text-muted-foreground">
|
||||||
|
请先选择K8S系统
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Deployment选择 */}
|
{/* Deployment选择 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Deployment</Label>
|
<Label>Deployment</Label>
|
||||||
<Select
|
{k8sNamespaceId ? (
|
||||||
value={k8sDeploymentId?.toString() || ''}
|
<Popover open={deploymentPopoverOpen} onOpenChange={setDeploymentPopoverOpen}>
|
||||||
onValueChange={(value) => {
|
<PopoverTrigger asChild>
|
||||||
onK8sDeploymentIdChange(Number(value));
|
<Button
|
||||||
}}
|
variant="outline"
|
||||||
disabled={!k8sNamespaceId || loadingDeployments}
|
role="combobox"
|
||||||
>
|
disabled={loadingDeployments || deployments.length === 0}
|
||||||
<SelectTrigger>
|
className={cn(
|
||||||
<SelectValue placeholder={loadingDeployments ? '加载中...' : '选择Deployment'} />
|
'w-full justify-between',
|
||||||
</SelectTrigger>
|
!k8sDeploymentId && 'text-muted-foreground'
|
||||||
<SelectContent>
|
)}
|
||||||
{loadingDeployments ? (
|
>
|
||||||
<div className="p-4 text-center">
|
{k8sDeploymentId
|
||||||
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
? (() => {
|
||||||
|
const selectedDeployment = deployments.find((d) => d.id === k8sDeploymentId);
|
||||||
|
return selectedDeployment ? selectedDeployment.deploymentName : '选择Deployment';
|
||||||
|
})()
|
||||||
|
: loadingDeployments
|
||||||
|
? '加载中...'
|
||||||
|
: deployments.length === 0
|
||||||
|
? '暂无Deployment'
|
||||||
|
: '选择Deployment'}
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
|
<div className="flex items-center border-b px-3">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<input
|
||||||
|
placeholder="搜索Deployment..."
|
||||||
|
className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
value={deploymentSearchValue}
|
||||||
|
onChange={(e) => setDeploymentSearchValue(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : deployments.length === 0 ? (
|
<ScrollArea className="h-[200px]">
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-1">
|
||||||
{k8sNamespaceId ? '暂无Deployment' : '请先选择Namespace'}
|
{deployments
|
||||||
</div>
|
.filter((deployment) =>
|
||||||
) : (
|
deployment.deploymentName.toLowerCase().includes(deploymentSearchValue.toLowerCase())
|
||||||
deployments.map((deployment) => (
|
)
|
||||||
<SelectItem key={deployment.id} value={deployment.id.toString()}>
|
.map((deployment) => (
|
||||||
{deployment.deploymentName}
|
<div
|
||||||
</SelectItem>
|
key={deployment.id}
|
||||||
))
|
className={cn(
|
||||||
)}
|
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground',
|
||||||
</SelectContent>
|
deployment.id === k8sDeploymentId && 'bg-accent text-accent-foreground'
|
||||||
</Select>
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onK8sDeploymentIdChange(deployment.id);
|
||||||
|
setDeploymentSearchValue('');
|
||||||
|
setDeploymentPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{deployment.deploymentName}</span>
|
||||||
|
{deployment.id === k8sDeploymentId && <Check className="ml-2 h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" disabled className="w-full justify-between text-muted-foreground">
|
||||||
|
请先选择Namespace
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import { Button } from '@/components/ui/button';
|
||||||
Select,
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
SelectContent,
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
SelectItem,
|
import { Search, Check, ChevronDown } from 'lucide-react';
|
||||||
SelectTrigger,
|
import { cn } from '@/lib/utils';
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { getServerList } from '@/pages/Resource/Server/List/service';
|
import { getServerList } from '@/pages/Resource/Server/List/service';
|
||||||
import type { ServerResponse } from '@/pages/Resource/Server/List/types';
|
import type { ServerResponse } from '@/pages/Resource/Server/List/types';
|
||||||
|
|
||||||
@ -26,6 +24,8 @@ export const ServerRuntimeConfig: React.FC<ServerRuntimeConfigProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [servers, setServers] = useState<ServerResponse[]>([]);
|
const [servers, setServers] = useState<ServerResponse[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [serverSearchValue, setServerSearchValue] = useState('');
|
||||||
|
const [serverPopoverOpen, setServerPopoverOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadServers();
|
loadServers();
|
||||||
@ -44,32 +44,84 @@ export const ServerRuntimeConfig: React.FC<ServerRuntimeConfigProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 过滤服务器列表
|
||||||
|
const filteredServers = servers.filter(
|
||||||
|
(server) =>
|
||||||
|
server.serverName?.toLowerCase().includes(serverSearchValue.toLowerCase()) ||
|
||||||
|
server.hostIp?.toLowerCase().includes(serverSearchValue.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>服务器</Label>
|
<Label>服务器</Label>
|
||||||
<Select
|
<Popover open={serverPopoverOpen} onOpenChange={setServerPopoverOpen}>
|
||||||
value={serverId?.toString() || ''}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={(value) => onServerChange(value ? Number(value) : null)}
|
<Button
|
||||||
disabled={loading}
|
variant="outline"
|
||||||
>
|
role="combobox"
|
||||||
<SelectTrigger>
|
disabled={loading || servers.length === 0}
|
||||||
<SelectValue placeholder={loading ? '加载中...' : '选择服务器'} />
|
className={cn(
|
||||||
</SelectTrigger>
|
'w-full justify-between',
|
||||||
<SelectContent>
|
!serverId && 'text-muted-foreground'
|
||||||
{servers.length === 0 ? (
|
)}
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
>
|
||||||
{loading ? '加载中...' : '暂无服务器'}
|
{serverId
|
||||||
|
? (() => {
|
||||||
|
const selectedServer = servers.find((s) => s.id === serverId);
|
||||||
|
return selectedServer
|
||||||
|
? `${selectedServer.serverName} (${selectedServer.hostIp})`
|
||||||
|
: '选择服务器';
|
||||||
|
})()
|
||||||
|
: loading
|
||||||
|
? '加载中...'
|
||||||
|
: servers.length === 0
|
||||||
|
? '暂无服务器'
|
||||||
|
: '选择服务器'}
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
|
<div className="flex items-center border-b px-3">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<input
|
||||||
|
placeholder="搜索服务器..."
|
||||||
|
className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
value={serverSearchValue}
|
||||||
|
onChange={(e) => setServerSearchValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[200px]">
|
||||||
|
<div className="p-1">
|
||||||
|
{filteredServers.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
未找到服务器
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredServers.map((server) => (
|
||||||
|
<div
|
||||||
|
key={server.id}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground',
|
||||||
|
server.id === serverId && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onServerChange(server.id!);
|
||||||
|
setServerSearchValue('');
|
||||||
|
setServerPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex-1 truncate">
|
||||||
|
{server.serverName} ({server.hostIp})
|
||||||
|
</div>
|
||||||
|
{server.id === serverId && <Check className="ml-2 h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</ScrollArea>
|
||||||
servers.map((server) => (
|
</PopoverContent>
|
||||||
<SelectItem key={server.id} value={server.id!.toString()}>
|
</Popover>
|
||||||
{server.serverName} ({server.hostIp})
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@ -554,7 +554,7 @@ const TeamApplicationDialog: React.FC<TeamApplicationDialogProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-2xl h-[680px] flex flex-col">
|
<DialogContent className="max-w-4xl h-[680px] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{mode === 'edit' ? '编辑' : '添加'}应用配置 - {environmentName}
|
{mode === 'edit' ? '编辑' : '添加'}应用配置 - {environmentName}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { PaginatedTable, type ColumnDef, type PaginatedTableRef } from '@/components/ui/paginated-table';
|
import { PaginatedTable, type ColumnDef, type SearchFieldDef, type PaginatedTableRef } from '@/components/ui/paginated-table';
|
||||||
import { Plus, Edit, Trash2, GitBranch, Hammer, Box, Container, Server } from 'lucide-react';
|
import { Plus, Edit, Trash2, GitBranch, Hammer, Box, Container, Server } from 'lucide-react';
|
||||||
import type { Environment } from '@/pages/Deploy/Environment/List/types';
|
import type { Environment } from '@/pages/Deploy/Environment/List/types';
|
||||||
import type { TeamApplication, Application } from '../types';
|
import type { TeamApplication, Application } from '../types';
|
||||||
@ -214,6 +214,20 @@ export const TeamApplicationManageDialog: React.FC<
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 搜索字段定义
|
||||||
|
const searchFields: SearchFieldDef[] = useMemo(() => [
|
||||||
|
{ key: 'applicationName', type: 'input', placeholder: '应用名称', width: 'w-[180px]' },
|
||||||
|
{ key: 'applicationCode', type: 'input', placeholder: '应用代码', width: 'w-[180px]' },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
// 工具栏
|
||||||
|
const toolbar = useMemo(() => (
|
||||||
|
<Button onClick={handleOpenAddAppDialog}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
添加应用
|
||||||
|
</Button>
|
||||||
|
), []);
|
||||||
|
|
||||||
// 列定义
|
// 列定义
|
||||||
const columns: ColumnDef<TeamApplication>[] = useMemo(() => [
|
const columns: ColumnDef<TeamApplication>[] = useMemo(() => [
|
||||||
{
|
{
|
||||||
@ -475,31 +489,23 @@ export const TeamApplicationManageDialog: React.FC<
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-5xl">
|
<DialogContent className="max-w-6xl h-[85vh] flex flex-col p-0 overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>管理应用配置</DialogTitle>
|
<DialogTitle>管理应用配置</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogBody>
|
<DialogBody>
|
||||||
{/* 工具栏 */}
|
|
||||||
<div className="flex items-center justify-end mb-4">
|
|
||||||
<Button onClick={handleOpenAddAppDialog}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
添加应用
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 应用列表表格 */}
|
{/* 应用列表表格 */}
|
||||||
<div className="border rounded-lg">
|
<PaginatedTable<TeamApplication, any>
|
||||||
<PaginatedTable<TeamApplication, any>
|
ref={tableRef}
|
||||||
ref={tableRef}
|
fetchFn={fetchData}
|
||||||
fetchFn={fetchData}
|
columns={columns}
|
||||||
columns={columns}
|
searchFields={searchFields}
|
||||||
rowKey="id"
|
toolbar={toolbar}
|
||||||
minWidth="1000px"
|
rowKey="id"
|
||||||
pageSize={10}
|
minWidth="1000px"
|
||||||
/>
|
pageSize={15}
|
||||||
</div>
|
/>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user