1.33 日志通用查询

This commit is contained in:
dengqichen 2025-12-16 17:55:45 +08:00
parent 82eb9ca6d6
commit 8f09e63ea1
13 changed files with 897 additions and 181 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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