From 716de2cd5231dda0b43d259afd1b02289e1b8dd2 Mon Sep 17 00:00:00 2001 From: Che <30403707+Che-Zhu@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:28:39 +0800 Subject: [PATCH] refactor: remove unused projects and terminal entrypoints --- app/api/projects/route.ts | 279 -------------------------------------- hooks/use-projects.ts | 85 ------------ hooks/use-terminal.ts | 135 ------------------ lib/fetch-client.ts | 6 +- 4 files changed, 3 insertions(+), 502 deletions(-) delete mode 100644 app/api/projects/route.ts delete mode 100644 hooks/use-projects.ts delete mode 100644 hooks/use-terminal.ts diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts deleted file mode 100644 index e118c56..0000000 --- a/app/api/projects/route.ts +++ /dev/null @@ -1,279 +0,0 @@ -import type { Database, Environment, Prisma, Project, Sandbox } from '@prisma/client' -import { NextResponse } from 'next/server' - -import { withAuth } from '@/lib/api-auth' -import { EnvironmentCategory } from '@/lib/const' -import { prisma } from '@/lib/db' -import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper' -import { KubernetesUtils } from '@/lib/k8s/kubernetes-utils' -import { VERSIONS } from '@/lib/k8s/versions' -import { logger as baseLogger } from '@/lib/logger' -import { generateRandomString } from '@/lib/util/common' - -const logger = baseLogger.child({ module: 'api/projects' }) - -/** - * Validate project name format - * Rules: - * - Only letters, numbers, spaces, and hyphens allowed - * - Must start with a letter - * - Must end with a letter - */ -function validateProjectName(name: string): { valid: boolean; error?: string } { - // Check if name is empty or only whitespace - if (!name || name.trim().length === 0) { - return { valid: false, error: 'Project name cannot be empty' } - } - - // Check if name contains only allowed characters (letters, numbers, spaces, hyphens) - const allowedPattern = /^[a-zA-Z0-9\s-]+$/ - if (!allowedPattern.test(name)) { - return { - valid: false, - error: 'Project name can only contain letters, numbers, spaces, and hyphens', - } - } - - // Check if name starts with a letter - const trimmedName = name.trim() - if (!/^[a-zA-Z]/.test(trimmedName)) { - return { valid: false, error: 'Project name must start with a letter' } - } - - // Check if name ends with a letter - if (!/[a-zA-Z]$/.test(trimmedName)) { - return { valid: false, error: 'Project name must end with a letter' } - } - - return { valid: true } -} - -type ProjectWithRelations = Project & { - databases: Database[] - sandboxes: Sandbox[] - environments: Environment[] -} - -type GetProjectsResponse = ProjectWithRelations[] - -export const GET = withAuth(async (req, _context, session) => { - // Get query parameters for filtering - const { searchParams } = new URL(req.url) - 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 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 - // Skip namespace filtering and return all projects for the user - logger.warn( - `User ${session.user.id} does not have KUBECONFIG configured, returning all projects` - ) - } - } - - const projects = await prisma.project.findMany({ - where: whereClause, - include: { - databases: true, - sandboxes: true, - environments: true, - }, - orderBy: { - updatedAt: 'desc', - }, - }) - - logger.info( - `Fetched ${projects.length} projects for user ${session.user.id}${allParam === 'true' ? ' (all namespaces)' : ''}` - ) - - return NextResponse.json(projects) -}) - -type PostProjectResponse = { error: string; errorCode?: string; message?: string } | Project - -export const POST = withAuth(async (req, _context, session) => { - const body = await req.json() - const { name, description } = body - - if (!name || typeof name !== 'string') { - return NextResponse.json({ error: 'Project name is required' }, { status: 400 }) - } - - // Validate project name format - const nameValidation = validateProjectName(name) - if (!nameValidation.valid) { - return NextResponse.json( - { - error: nameValidation.error || 'Invalid project name format', - errorCode: 'INVALID_PROJECT_NAME', - }, - { status: 400 } - ) - } - - logger.info(`Creating project: ${name} for user: ${session.user.id}`) - - // Get K8s service for user - will throw if KUBECONFIG is missing - let k8sService - let namespace - try { - k8sService = await getK8sServiceForUser(session.user.id) - namespace = k8sService.getDefaultNamespace() - } catch (error) { - // Check if error is due to missing kubeconfig - if (error instanceof Error && error.message.includes('does not have KUBECONFIG configured')) { - logger.warn(`Project creation failed - missing kubeconfig for user: ${session.user.id}`) - return NextResponse.json( - { - error: 'Kubeconfig not configured', - errorCode: 'KUBECONFIG_MISSING', - message: 'Please configure your kubeconfig before creating a project', - }, - { status: 400 } - ) - } - // Re-throw other errors - throw error - } - - // Generate K8s compatible names - const k8sProjectName = KubernetesUtils.toK8sProjectName(name) - const randomSuffix = KubernetesUtils.generateRandomString() - const ttydAuthToken = generateRandomString(24) // 24 chars = ~143 bits entropy for terminal auth - const fileBrowserUsername = `fb-${randomSuffix}` // filebrowser username - const fileBrowserPassword = generateRandomString(16) // 16 char random password - const sandboxName = `${k8sProjectName}-${randomSuffix}` - - // Create project with sandbox in a transaction - const result = await prisma.$transaction(async (tx) => { - // 1. Create Project with status CREATING - const project = await tx.project.create({ - data: { - name, - description, - userId: session.user.id, - status: 'CREATING', - }, - }) - - // 2. Create Sandbox record - lockedUntil is null so reconcile job can process immediately - const sandbox = await tx.sandbox.create({ - data: { - projectId: project.id, - name: sandboxName, - k8sNamespace: namespace, - sandboxName: sandboxName, - status: 'CREATING', - lockedUntil: null, // Unlocked - ready for reconcile job to process - // Resource configuration from versions - runtimeImage: VERSIONS.RUNTIME_IMAGE, - cpuRequest: VERSIONS.RESOURCES.SANDBOX.requests.cpu, - cpuLimit: VERSIONS.RESOURCES.SANDBOX.limits.cpu, - memoryRequest: VERSIONS.RESOURCES.SANDBOX.requests.memory, - memoryLimit: VERSIONS.RESOURCES.SANDBOX.limits.memory, - }, - }) - - // 3. Create Environment record for ttyd access token - const ttydEnv = await tx.environment.create({ - data: { - projectId: project.id, - key: 'TTYD_ACCESS_TOKEN', - value: ttydAuthToken, - category: EnvironmentCategory.TTYD, - isSecret: true, // Mark as secret since it's an access token - }, - }) - - // 4. Create Environment records for filebrowser credentials - const fileBrowserUsernameEnv = await tx.environment.create({ - data: { - projectId: project.id, - key: 'FILE_BROWSER_USERNAME', - value: fileBrowserUsername, - category: EnvironmentCategory.FILE_BROWSER, - isSecret: false, - }, - }) - - const fileBrowserPasswordEnv = await tx.environment.create({ - data: { - projectId: project.id, - key: 'FILE_BROWSER_PASSWORD', - value: fileBrowserPassword, - category: EnvironmentCategory.FILE_BROWSER, - isSecret: true, // Mark as secret since it's a password - }, - }) - - return { - project, - sandbox, - ttydEnv, - fileBrowserUsernameEnv, - fileBrowserPasswordEnv, - } - }, { - timeout: 20000, - }) - - logger.info( - `Project created: ${result.project.id} with sandbox: ${result.sandbox.id}, ttyd env: ${result.ttydEnv.id}, filebrowser username env: ${result.fileBrowserUsernameEnv.id}, filebrowser password env: ${result.fileBrowserPasswordEnv.id}` - ) - - return NextResponse.json(result.project) -}) diff --git a/hooks/use-projects.ts b/hooks/use-projects.ts deleted file mode 100644 index 3e1eb87..0000000 --- a/hooks/use-projects.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Hook for fetching projects list with React Query - * - * Provides automatic caching, refetching, and state synchronization - */ - -import type { Prisma } from '@prisma/client' -import { useQuery } from '@tanstack/react-query' - -import { GET } from '@/lib/fetch-client' - -type ProjectWithRelations = Prisma.ProjectGetPayload<{ - include: { - sandboxes: true - databases: true - environments: true - } -}> - -interface UseProjectsOptions { - /** Enable automatic refetching every 3 seconds */ - refetchInterval?: number - /** Enable refetch on window focus */ - refetchOnWindowFocus?: boolean - /** Show all projects regardless of namespace */ - all?: boolean - /** Search keyword - matches both name and description (case-insensitive partial match) */ - keyword?: string - /** Filter by creation date - projects created from this date (inclusive) */ - createdFrom?: string | Date - /** Filter by creation date - projects created until this date (inclusive) */ - createdTo?: string | Date -} - -/** - * Fetch projects list with automatic polling - * - * @param options - Query options - * @returns Query result with projects list - */ -export function useProjects(options: UseProjectsOptions = {}) { - const { - refetchInterval = 3000, - refetchOnWindowFocus = true, - all, - keyword, - createdFrom, - createdTo, - } = options - - // Build query key for caching - const queryKey = ['projects', all, keyword, createdFrom, createdTo] - - return useQuery({ - queryKey, - queryFn: async () => { - const params = new URLSearchParams() - - if (all) { - params.append('all', 'true') - } - if (keyword) { - params.append('keyword', keyword) - } - if (createdFrom) { - params.append( - 'createdFrom', - createdFrom instanceof Date ? createdFrom.toISOString() : createdFrom - ) - } - if (createdTo) { - params.append('createdTo', createdTo instanceof Date ? createdTo.toISOString() : createdTo) - } - - const queryString = params.toString() - const url = queryString ? `/api/projects?${queryString}` : '/api/projects' - - return GET(url) - }, - refetchInterval, - refetchOnWindowFocus, - staleTime: 2000, // Consider data stale after 2 seconds - retry: 2, - }) -} diff --git a/hooks/use-terminal.ts b/hooks/use-terminal.ts deleted file mode 100644 index 65f3e45..0000000 --- a/hooks/use-terminal.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * useTerminal Hook - * - * Manages terminal WebSocket connection state and reconnection logic - */ - -'use client' - -import { useCallback, useEffect, useRef, useState } from 'react' - -export interface UseTerminalOptions { - /** ttyd URL with authentication token */ - ttydUrl: string | null | undefined - /** Enable auto-reconnect on disconnect */ - autoReconnect?: boolean - /** Reconnect delay in milliseconds */ - reconnectDelay?: number - /** Callback when connected */ - onConnected?: () => void - /** Callback when disconnected */ - onDisconnected?: () => void - /** Callback when error occurs */ - onError?: (error: Error) => void -} - -export interface UseTerminalReturn { - /** Current connection status */ - status: 'disconnected' | 'connecting' | 'connected' | 'error' - /** Whether terminal is ready to use */ - isReady: boolean - /** WebSocket URL for xterm component */ - wsUrl: string | null - /** Manually trigger reconnection */ - reconnect: () => void - /** Disconnect and stop auto-reconnect */ - disconnect: () => void - /** Callback to handle connected event from XtermTerminal */ - handleConnected: () => void - /** Callback to handle disconnected event from XtermTerminal */ - handleDisconnected: () => void -} - -export function useTerminal(options: UseTerminalOptions): UseTerminalReturn { - const { - ttydUrl, - autoReconnect = true, - reconnectDelay = 3000, - onConnected, - onDisconnected, - } = options - - const [status, setStatus] = useState<'disconnected' | 'connecting' | 'connected' | 'error'>( - ttydUrl ? 'connecting' : 'disconnected' - ) - const [wsUrl, setWsUrl] = useState(ttydUrl ?? null) - const [prevTtydUrl, setPrevTtydUrl] = useState(ttydUrl) - - if (ttydUrl !== prevTtydUrl) { - setPrevTtydUrl(ttydUrl) - setWsUrl(ttydUrl ?? null) - setStatus(ttydUrl ? 'connecting' : 'disconnected') - } - const reconnectTimeoutRef = useRef(null) - const shouldReconnectRef = useRef(true) - - // Handle connection status callbacks - const handleConnected = useCallback(() => { - setStatus('connected') - shouldReconnectRef.current = autoReconnect - onConnected?.() - }, [autoReconnect, onConnected]) - - const handleDisconnected = useCallback(() => { - setStatus('disconnected') - onDisconnected?.() - - // Schedule reconnection if enabled - if (shouldReconnectRef.current && wsUrl) { - console.log(`[useTerminal] Reconnecting in ${reconnectDelay}ms...`) - reconnectTimeoutRef.current = setTimeout(() => { - setStatus('connecting') - // Trigger re-render to reconnect - setWsUrl((prev) => (prev ? `${prev}` : null)) - }, reconnectDelay) - } - }, [wsUrl, reconnectDelay, onDisconnected]) - - // Manual reconnection - const reconnect = useCallback(() => { - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - reconnectTimeoutRef.current = null - } - - shouldReconnectRef.current = true - setStatus('connecting') - - // Force reconnection by updating wsUrl - if (ttydUrl) { - setWsUrl(`${ttydUrl}`) - } - }, [ttydUrl]) - - // Manual disconnection - const disconnect = useCallback(() => { - shouldReconnectRef.current = false - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - reconnectTimeoutRef.current = null - } - - setStatus('disconnected') - setWsUrl(null) - }, []) - - // Cleanup on unmount - useEffect(() => { - return () => { - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - } - } - }, []) - - return { - status, - isReady: status === 'connected', - wsUrl, - reconnect, - disconnect, - handleConnected, - handleDisconnected, - } -} diff --git a/lib/fetch-client.ts b/lib/fetch-client.ts index 0960192..536d252 100644 --- a/lib/fetch-client.ts +++ b/lib/fetch-client.ts @@ -9,10 +9,10 @@ * import { GET, POST, PUT, DELETE } from '@/lib/fetch-client' * * // GET request - * const projects = await GET('/api/projects') + * const projects = await GET('/api/my-resource') * * // POST with body - * const newProject = await POST('/api/projects', { + * const newProject = await POST('/api/my-resource', { * name: 'My Project', * description: 'A cool project' * }) @@ -306,4 +306,4 @@ export async function PATCH( export async function DELETE(url: string, config?: FetchConfig): Promise { const response = await request('DELETE', url, undefined, config) return parseJson(response) -} \ No newline at end of file +}