diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index e071ba5..d54ffeb 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -57,21 +57,70 @@ type ProjectWithRelations = Project & { type GetProjectsResponse = ProjectWithRelations[] export const GET = withAuth(async (req, _context, session) => { - // Get optional namespace filter from query params + // Get query parameters for filtering const { searchParams } = new URL(req.url) - const namespace = searchParams.get('namespace') + const allParam = searchParams.get('all') + const keywordParam = searchParams.get('keyword') + const createdFromParam = searchParams.get('createdFrom') + const createdToParam = searchParams.get('createdTo') // Build where clause const whereClause: Prisma.ProjectWhereInput = { userId: session.user.id, } - // Add namespace filter if provided (filter projects by sandbox namespace) - if (namespace) { - whereClause.sandboxes = { - some: { - k8sNamespace: namespace, + // Add keyword filter if provided (searches in both name and description) + if (keywordParam) { + whereClause.OR = [ + { + name: { + contains: keywordParam, + mode: 'insensitive', + }, + }, + { + description: { + contains: keywordParam, + mode: 'insensitive', + }, }, + ] + } + + // Add createdAt date filters if provided + const createdAtFilter: { gte?: Date; lte?: Date } = {} + if (createdFromParam) { + const createdFrom = new Date(createdFromParam) + if (!isNaN(createdFrom.getTime())) { + createdAtFilter.gte = createdFrom + } + } + if (createdToParam) { + const createdTo = new Date(createdToParam) + if (!isNaN(createdTo.getTime())) { + createdAtFilter.lte = createdTo + } + } + if (Object.keys(createdAtFilter).length > 0) { + whereClause.createdAt = createdAtFilter + } + + // Add namespace filter from user's kubeconfig (unless 'all' parameter is provided) + if (allParam !== 'true') { + try { + const k8sService = await getK8sServiceForUser(session.user.id) + const namespace = k8sService.getDefaultNamespace() + whereClause.sandboxes = { + some: { + k8sNamespace: namespace, + }, + } + } catch { + // If user doesn't have kubeconfig configured, log warning but don't fail + // Return empty array instead of filtering by namespace + logger.warn( + `User ${session.user.id} does not have KUBECONFIG configured, returning all projects` + ) } } @@ -88,7 +137,7 @@ export const GET = withAuth(async (req, _context, session) }) logger.info( - `Fetched ${projects.length} projects for user ${session.user.id}${namespace ? ` in namespace ${namespace}` : ''}` + `Fetched ${projects.length} projects for user ${session.user.id}${allParam === 'true' ? ' (all namespaces)' : ''}` ) return NextResponse.json(projects) diff --git a/app/globals.css b/app/globals.css index 1b16fb1..b26903b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -184,3 +184,19 @@ @apply leading-7 [&:not(:first-child)]:mt-6 text-muted-foreground; } } + +/* Terminal scroll indicator animation */ +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in { + animation: fade-in 0.2s ease-out; +} diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 5902d57..88d8890 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -12,16 +12,11 @@ import PageHeader from '@/components/features/projectList/PageHeader'; import ProjectCard from '@/components/features/projectList/ProjectCard'; import { Spinner } from '@/components/ui/spinner'; import { useProjects } from '@/hooks/use-projects'; -import { useSealos } from '@/provider/sealos'; export default function ProjectsPage() { - const { sealosNs } = useSealos(); - // Fetch projects with automatic polling (every 3 seconds) - // Pass sealosNs if in Sealos environment for namespace filtering - const { data: projects, isLoading } = useProjects({ - namespace: sealosNs, - }); + // Namespace is automatically determined from user's kubeconfig + const { data: projects, isLoading } = useProjects(); if (isLoading) { return ( @@ -53,4 +48,4 @@ export default function ProjectsPage() { ); -} \ No newline at end of file +} diff --git a/components/terminal/terminal-container.tsx b/components/terminal/terminal-container.tsx index 73b9586..72aed35 100644 --- a/components/terminal/terminal-container.tsx +++ b/components/terminal/terminal-container.tsx @@ -1,9 +1,14 @@ /** * TerminalContainer Component * - * Main container that combines toolbar and display - * Manages tab state and renders separate terminal instances for each tab - * Each tab gets its own iframe with independent WebSocket connection + * Root container component that manages terminal tabs and combines toolbar with display area. + * Implements multi-tab functionality where each tab maintains an independent terminal instance. + * + * Architecture: + * - Tab state management (add, close, switch) + * - Renders all tabs but only shows the active one (maintains state) + * - Passes project and sandbox data to child components + * - Each tab gets unique terminal instance with independent WebSocket */ 'use client'; @@ -14,6 +19,10 @@ import type { Prisma } from '@prisma/client'; import { TerminalDisplay } from './terminal-display'; import { type Tab, TerminalToolbar } from './terminal-toolbar'; +// ============================================================================ +// Types +// ============================================================================ + type Project = Prisma.ProjectGetPayload<{ include: { sandboxes: true; @@ -24,24 +33,31 @@ type Project = Prisma.ProjectGetPayload<{ type Sandbox = Prisma.SandboxGetPayload; export interface TerminalContainerProps { - /** Project data */ project: Project; - /** Sandbox data */ sandbox: Sandbox | undefined; } -/** - * Terminal container with toolbar and display - * Renders separate terminal instances for each tab to ensure independent WebSocket connections - */ +// ============================================================================ +// Component +// ============================================================================ + export function TerminalContainer({ project, sandbox }: TerminalContainerProps) { - // Tab management + // ========================================================================= + // Tab State Management + // ========================================================================= + const [tabs, setTabs] = useState([{ id: '1', name: 'Terminal 1' }]); const [activeTabId, setActiveTabId] = useState('1'); - // Tab operations + // ========================================================================= + // Tab Operations + // ========================================================================= + + /** + * Create and activate a new terminal tab + */ const handleTabAdd = () => { - const newId = Date.now().toString(); // Use timestamp for unique ID + const newId = Date.now().toString(); const newTab: Tab = { id: newId, name: `Terminal ${tabs.length + 1}`, @@ -50,24 +66,36 @@ export function TerminalContainer({ project, sandbox }: TerminalContainerProps) setActiveTabId(newId); }; + /** + * Close a terminal tab + * Maintains at least one tab and switches to first tab if closing active tab + */ const handleTabClose = (id: string) => { - if (tabs.length === 1) return; // Keep at least one terminal + if (tabs.length === 1) return; - const newTabs = tabs.filter((t) => t.id !== id); - setTabs(newTabs); + const remainingTabs = tabs.filter((t) => t.id !== id); + setTabs(remainingTabs); + // Switch to first tab if we're closing the active tab if (activeTabId === id) { - setActiveTabId(newTabs[0].id); + setActiveTabId(remainingTabs[0].id); } }; + /** + * Switch to a different tab + */ const handleTabSelect = (id: string) => { setActiveTabId(id); }; + // ========================================================================= + // Render + // ========================================================================= + return (
- {/* Toolbar */} + {/* Toolbar with tabs and operations */} - {/* Terminal Displays - render all tabs but only show active one */} + {/* Terminal display area with tab switching */}
{tabs.map((tab) => (
+ {/* Each tab maintains its own terminal instance */}
); -} +} \ No newline at end of file diff --git a/components/terminal/terminal-display.tsx b/components/terminal/terminal-display.tsx index fa2656e..4da0477 100644 --- a/components/terminal/terminal-display.tsx +++ b/components/terminal/terminal-display.tsx @@ -1,13 +1,19 @@ /** * TerminalDisplay Component * - * Pure display component for terminal iframe - * VSCode Dark Modern theme style + * Wrapper component for XtermTerminal that manages connection states and loading UI. + * Displays appropriate status messages when terminal is not ready or sandbox is not running. + * + * Features: + * - Loading overlays during initialization and connection + * - Status-based conditional rendering + * - Connection status indicators + * - Automatic reconnection feedback */ 'use client'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { AlertCircle, Terminal as TerminalIcon } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; @@ -19,67 +25,145 @@ import { } from '@/lib/util/status-colors'; import { cn } from '@/lib/utils'; +import { XtermTerminal } from './xterm-terminal'; + +// ============================================================================ +// Types +// ============================================================================ + export interface TerminalDisplayProps { - /** ttyd URL */ ttydUrl?: string | null; - /** Sandbox status */ status: string; - /** Unique tab ID for this terminal instance */ tabId: string; } -/** - * Display terminal iframe or status message - * Each terminal tab gets its own iframe with unique key to ensure separate WebSocket connections - */ +type ConnectionStatus = 'connecting' | 'connected' | 'error'; + +// ============================================================================ +// Component +// ============================================================================ + export function TerminalDisplay({ ttydUrl, status, tabId }: TerminalDisplayProps) { - const [iframeLoaded, setIframeLoaded] = useState(false); + // ========================================================================= + // State Management + // ========================================================================= + + const [terminalReady, setTerminalReady] = useState(false); + const [connectionStatus, setConnectionStatus] = useState('connecting'); + + // ========================================================================= + // Event Handlers + // ========================================================================= + + const handleReady = useCallback(() => { + console.log('[TerminalDisplay] Terminal initialized successfully'); + setTerminalReady(true); + }, []); + + const handleConnected = useCallback(() => { + console.log('[TerminalDisplay] WebSocket connection established'); + setConnectionStatus('connected'); + }, []); + + const handleDisconnected = useCallback(() => { + console.log('[TerminalDisplay] WebSocket connection closed'); + setConnectionStatus('connecting'); + }, []); - // Only show terminal iframe if status is RUNNING and URL is available + // ========================================================================= + // Conditional Rendering Logic + // ========================================================================= + + // Only render terminal when sandbox is running and ttyd URL is available if (status === 'RUNNING' && ttydUrl) { + const isLoading = connectionStatus === 'connecting' || !terminalReady; + const showReconnectIndicator = connectionStatus === 'connecting' && terminalReady; + const showErrorIndicator = connectionStatus === 'error' && terminalReady; + return (
- {/* Loading overlay */} - {!iframeLoaded && ( + {/* Loading Overlay */} + {isLoading && (
- Connecting to terminal... + + {!terminalReady ? 'Initializing terminal...' : 'Establishing connection...'} +
)} - {/* Terminal iframe - unique key per tab ensures separate WebSocket connection */} -