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 };
|
||||
size: { width: number; height: number };
|
||||
connectionStatus?: ConnectionStatus;
|
||||
titleNode?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -27,7 +28,7 @@ interface TerminalWindowManagerProps<TResource = any> {
|
||||
/** 终端类型 */
|
||||
type: TerminalType;
|
||||
/** 获取窗口标题 */
|
||||
getWindowTitle: (resource: TResource) => string;
|
||||
getWindowTitle: (resource: TResource) => string | React.ReactNode;
|
||||
/** 获取窗口副标题 */
|
||||
getWindowSubtitle: (resource: TResource) => string;
|
||||
/** 获取资源ID */
|
||||
@ -36,6 +37,7 @@ interface TerminalWindowManagerProps<TResource = any> {
|
||||
renderTerminal: (windowId: string, resource: TResource, callbacks: {
|
||||
onCloseReady: () => void;
|
||||
onStatusChange: (status: ConnectionStatus) => void;
|
||||
updateWindowTitle: (titleNode: React.ReactNode) => void;
|
||||
}) => React.ReactNode;
|
||||
/** 窗口打开回调 */
|
||||
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) => {
|
||||
switch (status) {
|
||||
@ -195,7 +206,7 @@ export function TerminalWindowManager<TResource = any>({
|
||||
>
|
||||
<DraggableWindow
|
||||
id={win.id}
|
||||
title={getWindowTitle(win.resource)}
|
||||
title={win.titleNode || getWindowTitle(win.resource)}
|
||||
onClose={() => closeWindow(win.id)}
|
||||
onMinimize={() => minimizeWindow(win.id)}
|
||||
isActive={activeWindowId === win.id}
|
||||
@ -206,6 +217,7 @@ export function TerminalWindowManager<TResource = any>({
|
||||
{renderTerminal(win.id, win.resource, {
|
||||
onCloseReady: () => actuallyCloseWindow(win.id),
|
||||
onStatusChange: (status) => updateWindowStatus(win.id, status),
|
||||
updateWindowTitle: (titleNode) => updateWindowTitle(win.id, titleNode),
|
||||
})}
|
||||
</DraggableWindow>
|
||||
</div>
|
||||
|
||||
@ -91,7 +91,7 @@ const DialogHeader = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
|
||||
interface DraggableWindowProps {
|
||||
id: string;
|
||||
title: string;
|
||||
title: string | React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
onMinimize: () => void;
|
||||
|
||||
@ -230,10 +230,10 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
|
||||
const showSearchBar = searchFields && searchFields.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 固定区域:搜索栏 */}
|
||||
{(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 || (
|
||||
<>
|
||||
{searchFields?.map(renderSearchField)}
|
||||
@ -249,58 +249,65 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="rounded-md border">
|
||||
<Table minWidth={minWidth}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map(col => (
|
||||
<TableHead key={col.key} width={col.width} sticky={col.sticky}>
|
||||
{col.title}
|
||||
</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)}>
|
||||
{/* 可滚动区域:表格 + 分页 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 表格 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="rounded-md border">
|
||||
<Table minWidth={minWidth}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map(col => (
|
||||
<TableCell key={col.key} width={col.width} sticky={col.sticky} className={col.className}>
|
||||
{renderCell(col, record, index)}
|
||||
</TableCell>
|
||||
<TableHead key={col.key} width={col.width} sticky={col.sticky}>
|
||||
{col.title}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<span className="text-muted-foreground">{emptyText}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</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 => (
|
||||
<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 && (
|
||||
<DataTablePagination
|
||||
pageIndex={pageNum}
|
||||
pageSize={pageSize}
|
||||
pageCount={pageCount}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
{/* 分页 */}
|
||||
{pageCount > 0 && (
|
||||
<div className="flex-shrink-0 pt-4">
|
||||
<DataTablePagination
|
||||
pageIndex={pageNum}
|
||||
pageSize={pageSize}
|
||||
pageCount={pageCount}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -120,7 +120,13 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
||||
<TabsContent value="runtime" className="mt-0 h-[380px]">
|
||||
<RuntimeTabContent
|
||||
app={app}
|
||||
onLogClick={() => setLogDialogOpen(true)}
|
||||
onLogClick={() => {
|
||||
// 调用全局方法打开日志窗口
|
||||
const openLogWindow = (window as any).__openLogWindow;
|
||||
if (openLogWindow) {
|
||||
openLogWindow({ app, environment });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</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 { TeamBookmarkDialog } from './Bookmark/components/TeamBookmarkDialog';
|
||||
import { CategoryManageDialog } from './Bookmark/components/CategoryManageDialog';
|
||||
import { LogWindowManager } from './components/LogWindowManager';
|
||||
import { useDeploymentData } from './hooks/useDeploymentData';
|
||||
import { usePendingApproval } from './hooks/usePendingApproval';
|
||||
import type { ApplicationConfig } from './types';
|
||||
@ -239,6 +240,9 @@ const Dashboard: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 日志窗口管理器 */}
|
||||
<LogWindowManager />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -8,6 +9,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 type { ServerResponse } from '@/pages/Resource/Server/List/types';
|
||||
|
||||
@ -26,6 +31,8 @@ export const DockerRuntimeConfig: React.FC<DockerRuntimeConfigProps> = ({
|
||||
}) => {
|
||||
const [servers, setServers] = useState<ServerResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [serverSearchValue, setServerSearchValue] = useState('');
|
||||
const [serverPopoverOpen, setServerPopoverOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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-2">
|
||||
<Label>Docker服务器</Label>
|
||||
<Select
|
||||
value={dockerServerId?.toString() || ''}
|
||||
onValueChange={(value) => onDockerServerChange(value ? Number(value) : null)}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loading ? '加载中...' : '选择Docker服务器'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{loading ? '加载中...' : '暂无服务器'}
|
||||
<Popover open={serverPopoverOpen} onOpenChange={setServerPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={loading || servers.length === 0}
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!dockerServerId && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
servers.map((server) => (
|
||||
<SelectItem key={server.id} value={server.id!.toString()}>
|
||||
{server.serverName} ({server.hostIp})
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -7,7 +8,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { getK8sNamespaceList, getK8sDeploymentByNamespace } from '@/pages/Resource/K8s/List/service';
|
||||
|
||||
@ -33,6 +37,10 @@ export const K8sRuntimeConfig: React.FC<K8sRuntimeConfigProps> = ({
|
||||
const [deployments, setDeployments] = useState<any[]>([]);
|
||||
const [loadingNamespaces, setLoadingNamespaces] = 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系统列表
|
||||
useEffect(() => {
|
||||
@ -130,67 +138,147 @@ export const K8sRuntimeConfig: React.FC<K8sRuntimeConfigProps> = ({
|
||||
{/* Namespace选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label>Namespace</Label>
|
||||
<Select
|
||||
value={k8sNamespaceId?.toString() || ''}
|
||||
onValueChange={(value) => {
|
||||
onK8sNamespaceChange(Number(value));
|
||||
}}
|
||||
disabled={!k8sSystemId || loadingNamespaces}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingNamespaces ? '加载中...' : '选择Namespace'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadingNamespaces ? (
|
||||
<div className="p-4 text-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
||||
{k8sSystemId ? (
|
||||
<Popover open={namespacePopoverOpen} onOpenChange={setNamespacePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={loadingNamespaces || namespaces.length === 0}
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!k8sNamespaceId && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{k8sNamespaceId
|
||||
? (() => {
|
||||
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>
|
||||
) : namespaces.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{k8sSystemId ? '暂无Namespace' : '请先选择K8S系统'}
|
||||
</div>
|
||||
) : (
|
||||
namespaces.map((ns) => (
|
||||
<SelectItem key={ns.id} value={ns.id.toString()}>
|
||||
{ns.namespaceName}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ScrollArea className="h-[200px]">
|
||||
<div className="p-1">
|
||||
{namespaces
|
||||
.filter((ns) =>
|
||||
ns.namespaceName.toLowerCase().includes(namespaceSearchValue.toLowerCase())
|
||||
)
|
||||
.map((ns) => (
|
||||
<div
|
||||
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',
|
||||
ns.id === k8sNamespaceId && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
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>
|
||||
|
||||
{/* Deployment选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label>Deployment</Label>
|
||||
<Select
|
||||
value={k8sDeploymentId?.toString() || ''}
|
||||
onValueChange={(value) => {
|
||||
onK8sDeploymentIdChange(Number(value));
|
||||
}}
|
||||
disabled={!k8sNamespaceId || loadingDeployments}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingDeployments ? '加载中...' : '选择Deployment'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadingDeployments ? (
|
||||
<div className="p-4 text-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
||||
{k8sNamespaceId ? (
|
||||
<Popover open={deploymentPopoverOpen} onOpenChange={setDeploymentPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={loadingDeployments || deployments.length === 0}
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!k8sDeploymentId && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{k8sDeploymentId
|
||||
? (() => {
|
||||
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>
|
||||
) : deployments.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{k8sNamespaceId ? '暂无Deployment' : '请先选择Namespace'}
|
||||
</div>
|
||||
) : (
|
||||
deployments.map((deployment) => (
|
||||
<SelectItem key={deployment.id} value={deployment.id.toString()}>
|
||||
{deployment.deploymentName}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ScrollArea className="h-[200px]">
|
||||
<div className="p-1">
|
||||
{deployments
|
||||
.filter((deployment) =>
|
||||
deployment.deploymentName.toLowerCase().includes(deploymentSearchValue.toLowerCase())
|
||||
)
|
||||
.map((deployment) => (
|
||||
<div
|
||||
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',
|
||||
deployment.id === k8sDeploymentId && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
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>
|
||||
);
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 type { ServerResponse } from '@/pages/Resource/Server/List/types';
|
||||
|
||||
@ -26,6 +24,8 @@ export const ServerRuntimeConfig: React.FC<ServerRuntimeConfigProps> = ({
|
||||
}) => {
|
||||
const [servers, setServers] = useState<ServerResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [serverSearchValue, setServerSearchValue] = useState('');
|
||||
const [serverPopoverOpen, setServerPopoverOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||
<div className="space-y-2">
|
||||
<Label>服务器</Label>
|
||||
<Select
|
||||
value={serverId?.toString() || ''}
|
||||
onValueChange={(value) => onServerChange(value ? Number(value) : null)}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loading ? '加载中...' : '选择服务器'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{loading ? '加载中...' : '暂无服务器'}
|
||||
<Popover open={serverPopoverOpen} onOpenChange={setServerPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={loading || servers.length === 0}
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!serverId && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
servers.map((server) => (
|
||||
<SelectItem key={server.id} value={server.id!.toString()}>
|
||||
{server.serverName} ({server.hostIp})
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -554,7 +554,7 @@ const TeamApplicationDialog: React.FC<TeamApplicationDialogProps> = ({
|
||||
|
||||
return (
|
||||
<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>
|
||||
<DialogTitle>
|
||||
{mode === 'edit' ? '编辑' : '添加'}应用配置 - {environmentName}
|
||||
|
||||
@ -11,7 +11,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
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 type { Environment } from '@/pages/Deploy/Environment/List/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(() => [
|
||||
{
|
||||
@ -475,31 +489,23 @@ export const TeamApplicationManageDialog: React.FC<
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<DialogTitle>管理应用配置</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<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>
|
||||
ref={tableRef}
|
||||
fetchFn={fetchData}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
minWidth="1000px"
|
||||
pageSize={10}
|
||||
/>
|
||||
</div>
|
||||
<PaginatedTable<TeamApplication, any>
|
||||
ref={tableRef}
|
||||
fetchFn={fetchData}
|
||||
columns={columns}
|
||||
searchFields={searchFields}
|
||||
toolbar={toolbar}
|
||||
rowKey="id"
|
||||
minWidth="1000px"
|
||||
pageSize={15}
|
||||
/>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user