Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 57 additions & 8 deletions app/api/projects/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,70 @@ type ProjectWithRelations = Project & {
type GetProjectsResponse = ProjectWithRelations[]

export const GET = withAuth<GetProjectsResponse>(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`
)
Comment on lines +119 to +123

Copilot AI Nov 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says 'Return empty array' but the code actually returns all projects without namespace filtering. This is misleading and potentially a security issue - users without kubeconfig will see projects from all namespaces instead of no projects. The behavior should either match the comment or the comment should be corrected to reflect the actual behavior.

Suggested change
// 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`
)
// If user doesn't have kubeconfig configured, log warning and return empty array
logger.warn(
`User ${session.user.id} does not have KUBECONFIG configured, returning empty array`
)
return NextResponse.json([])

Copilot uses AI. Check for mistakes.
}
}

Expand All @@ -88,7 +137,7 @@ export const GET = withAuth<GetProjectsResponse>(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)
Expand Down
16 changes: 16 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
11 changes: 3 additions & 8 deletions app/projects/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -53,4 +48,4 @@ export default function ProjectsPage() {
</div>
</div>
);
}
}
67 changes: 48 additions & 19 deletions components/terminal/terminal-container.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -24,24 +33,31 @@ type Project = Prisma.ProjectGetPayload<{
type Sandbox = Prisma.SandboxGetPayload<object>;

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<Tab[]>([{ 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}`,
Expand All @@ -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 (
<div className="flex flex-col h-full bg-[#1e1e1e]">
{/* Toolbar */}
{/* Toolbar with tabs and operations */}
<TerminalToolbar
project={project}
sandbox={sandbox}
Expand All @@ -78,7 +106,7 @@ export function TerminalContainer({ project, sandbox }: TerminalContainerProps)
onTabAdd={handleTabAdd}
/>

{/* Terminal Displays - render all tabs but only show active one */}
{/* Terminal display area with tab switching */}
<div className="flex-1 bg-black relative">
{tabs.map((tab) => (
<div
Expand All @@ -88,6 +116,7 @@ export function TerminalContainer({ project, sandbox }: TerminalContainerProps)
display: tab.id === activeTabId ? 'block' : 'none',
}}
>
{/* Each tab maintains its own terminal instance */}
<TerminalDisplay
key={tab.id}
ttydUrl={sandbox?.ttydUrl}
Expand All @@ -99,4 +128,4 @@ export function TerminalContainer({ project, sandbox }: TerminalContainerProps)
</div>
</div>
);
}
}
Loading