diff --git a/src/app/admin/api/app-builder/hooks.ts b/src/app/admin/api/app-builder/hooks.ts new file mode 100644 index 000000000..425e93fee --- /dev/null +++ b/src/app/admin/api/app-builder/hooks.ts @@ -0,0 +1,12 @@ +'use client'; + +import { useTRPC } from '@/lib/trpc/utils'; +import { useQuery } from '@tanstack/react-query'; + +export function useAdminAppBuilderProject(projectId: string | null) { + const trpc = useTRPC(); + return useQuery({ + ...trpc.admin.appBuilder.get.queryOptions({ id: projectId ?? '' }), + enabled: Boolean(projectId), + }); +} diff --git a/src/app/admin/app-builder/[id]/page.tsx b/src/app/admin/app-builder/[id]/page.tsx new file mode 100644 index 000000000..08d1a4a29 --- /dev/null +++ b/src/app/admin/app-builder/[id]/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from 'react'; +import { redirect } from 'next/navigation'; +import { getUserFromAuth } from '@/lib/user.server'; +import { AppBuilderProjectDetail } from '../../components/AppBuilder/AppBuilderProjectDetail'; + +export default async function AppBuilderProjectDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); + if (authFailedResponse) { + redirect('/admin/unauthorized'); + } + + const { id } = await params; + const projectId = decodeURIComponent(id); + + return ( + Loading project details...}> + + + ); +} diff --git a/src/app/admin/components/AppBuilder/AppBuilderProjectDetail.tsx b/src/app/admin/components/AppBuilder/AppBuilderProjectDetail.tsx new file mode 100644 index 000000000..e5af9bb53 --- /dev/null +++ b/src/app/admin/components/AppBuilder/AppBuilderProjectDetail.tsx @@ -0,0 +1,297 @@ +'use client'; + +import AdminPage from '@/app/admin/components/AdminPage'; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { useAdminAppBuilderProject } from '@/app/admin/api/app-builder/hooks'; +import { + User, + Building2, + Calendar, + MessageSquare, + Cpu, + ExternalLink, + Loader2, + FileCode, + Rocket, +} from 'lucide-react'; +import Link from 'next/link'; +import { formatDistanceToNow } from 'date-fns'; + +function formatRelativeTime(timestamp: string | null): string { + if (!timestamp) return 'Never'; + return formatDistanceToNow(new Date(timestamp), { addSuffix: true }); +} + +function formatAbsoluteTime(timestamp: string): string { + return new Date(timestamp).toLocaleString(); +} + +type AppBuilderProjectDetailPageProps = { + children: React.ReactNode; + projectTitle: string | undefined; +}; + +function AppBuilderProjectDetailPage({ children, projectTitle }: AppBuilderProjectDetailPageProps) { + const breadcrumbs = ( + <> + + App Builder Projects + + + + {projectTitle ?? 'Project Details'} + + + ); + + return {children}; +} + +export function AppBuilderProjectDetail({ projectId }: { projectId: string }) { + const { data: project, isLoading, error } = useAdminAppBuilderProject(projectId); + + if (isLoading) { + return ( + +
+ + Loading project details... +
+
+ ); + } + + if (error) { + return ( + + + + {error instanceof Error ? error.message : 'Failed to load project'} + + + + ); + } + + if (!project) { + return ( + + + Project not found + + + ); + } + + return ( + +
+ {/* Basic Information Card */} + + + Project Information + Basic details about this App Builder project + + + {/* Title */} +
+
Title
+
{project.title}
+
+ + {/* Model */} +
+ +
+
Model
+
{project.model_id}
+
+
+ + {/* Template */} +
+ +
+
Template
+
{project.template ?? 'nextjs-starter (default)'}
+
+
+ + {/* Owner */} +
+ {project.owned_by_user_id ? ( + + ) : ( + + )} +
+
Owner
+ {project.owned_by_user_id ? ( + + {project.owner_email ?? project.owned_by_user_id} + + ) : project.owned_by_organization_id ? ( + + {project.owner_org_name ?? project.owned_by_organization_id} + + ) : ( + Unknown + )} +
+
+ + {/* Message Count */} +
+ +
+
Messages
+
{project.message_count}
+
+
+ + {/* Deployment Status */} +
+ +
+
Deployment
+ {project.is_deployed ? ( + + Deployed + + ) : ( + Not Deployed + )} +
+
+ + {/* Created At */} +
+ +
+
Created
+
+ {formatRelativeTime(project.created_at)} +
+
+
+ + {/* Last Activity */} +
+ +
+
Last Activity
+
+ {formatRelativeTime(project.last_message_at)} +
+
+
+
+
+ + {/* Session Information Card */} + + + Session Information + Cloud Agent and CLI session details + + + {/* Cloud Agent Session ID */} +
+
+ Cloud Agent Session ID +
+ {project.session_id ? ( + {project.session_id} + ) : ( + No session + )} +
+ + {/* CLI Session Link */} +
+
CLI Session
+ {project.cli_session_id ? ( +
+ + {project.cli_session_id} + + + + +
+ ) : project.session_id ? ( + + No linked CLI session found for this cloud agent session + + ) : ( + No session available + )} +
+
+
+ + {/* IDs Card */} + + + Technical Details + Internal identifiers and metadata + + +
+
Project ID
+ {project.id} +
+ {project.deployment_id && ( +
+
Deployment ID
+ {project.deployment_id} +
+ )} + {project.created_by_user_id && ( +
+
Created By User ID
+ + {project.created_by_user_id} + +
+ )} +
+
Updated At
+
+ {formatAbsoluteTime(project.updated_at)} +
+
+
+
+
+
+ ); +} diff --git a/src/app/admin/components/AppBuilder/AppBuilderProjectsTable.tsx b/src/app/admin/components/AppBuilder/AppBuilderProjectsTable.tsx index dac5768f0..7aa9b1980 100644 --- a/src/app/admin/components/AppBuilder/AppBuilderProjectsTable.tsx +++ b/src/app/admin/components/AppBuilder/AppBuilderProjectsTable.tsx @@ -286,7 +286,19 @@ export function AppBuilderProjectsTable() { ) : ( projects.map(project => ( - + router.push(`/admin/app-builder/${project.id}`)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + router.push(`/admin/app-builder/${project.id}`); + } + }} + > e.stopPropagation()} > {project.owner_email || project.owned_by_user_id} @@ -309,6 +322,7 @@ export function AppBuilderProjectsTable() { e.stopPropagation()} > {project.owner_org_name || project.owned_by_organization_id} @@ -347,7 +361,10 @@ export function AppBuilderProjectsTable() { variant="ghost" size="sm" className="text-destructive hover:bg-destructive/10 hover:text-destructive" - onClick={() => handleDeleteClick(project)} + onClick={e => { + e.stopPropagation(); + handleDeleteClick(project); + }} > diff --git a/src/app/admin/components/SessionTraceViewer.tsx b/src/app/admin/components/SessionTraceViewer.tsx index 51938f000..21092d851 100644 --- a/src/app/admin/components/SessionTraceViewer.tsx +++ b/src/app/admin/components/SessionTraceViewer.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import AdminPage from '@/app/admin/components/AdminPage'; import { BreadcrumbItem, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Input } from '@/components/ui/input'; @@ -44,10 +45,22 @@ function convertToMessage(cloudMessage: CloudMessage): Message & { } export function SessionTraceViewer() { + const router = useRouter(); + const searchParams = useSearchParams(); + const sessionIdFromUrl = searchParams.get('sessionId'); + const [inputValue, setInputValue] = useState(''); const [searchedSessionId, setSearchedSessionId] = useState(null); const [validationError, setValidationError] = useState(null); + // Initialize from URL parameter on mount + useEffect(() => { + if (sessionIdFromUrl && UUID_REGEX.test(sessionIdFromUrl)) { + setInputValue(sessionIdFromUrl); + setSearchedSessionId(sessionIdFromUrl); + } + }, [sessionIdFromUrl]); + const sessionQuery = useAdminSessionTrace(searchedSessionId); const messagesQuery = useAdminSessionMessages(searchedSessionId); const apiHistoryQuery = useAdminApiConversationHistory(searchedSessionId); @@ -66,6 +79,8 @@ export function SessionTraceViewer() { } setValidationError(null); setSearchedSessionId(trimmed); + // Update URL to make the link shareable (replace to avoid growing history stack) + router.replace(`/admin/session-traces?sessionId=${trimmed}`); }; const downloadJson = (data: unknown, filename: string) => { diff --git a/src/routers/admin-app-builder-router.ts b/src/routers/admin-app-builder-router.ts index 17f976993..0116509b1 100644 --- a/src/routers/admin-app-builder-router.ts +++ b/src/routers/admin-app-builder-router.ts @@ -5,6 +5,7 @@ import { app_builder_messages, kilocode_users, organizations, + cliSessions, } from '@/db/schema'; import * as z from 'zod'; import { eq, and, or, ilike, desc, asc, count, isNotNull, type SQL } from 'drizzle-orm'; @@ -24,10 +25,15 @@ const DeleteProjectSchema = z.object({ id: z.string().uuid(), }); +const GetProjectSchema = z.object({ + id: z.string().uuid(), +}); + export type AdminAppBuilderProject = { id: string; title: string; model_id: string; + template: string | null; session_id: string | null; deployment_id: string | null; created_by_user_id: string | null; @@ -42,7 +48,80 @@ export type AdminAppBuilderProject = { is_deployed: boolean; }; +export type AdminAppBuilderProjectDetail = AdminAppBuilderProject & { + cli_session_id: string | null; +}; + export const adminAppBuilderRouter = createTRPCRouter({ + get: adminProcedure.input(GetProjectSchema).query(async ({ input }) => { + const { id: projectId } = input; + + // Query project with joins for owner info + const [result] = await db + .select({ + project: app_builder_projects, + owner_user: { + id: kilocode_users.id, + email: kilocode_users.google_user_email, + }, + owner_org: { + id: organizations.id, + name: organizations.name, + }, + }) + .from(app_builder_projects) + .leftJoin(kilocode_users, eq(app_builder_projects.owned_by_user_id, kilocode_users.id)) + .leftJoin(organizations, eq(app_builder_projects.owned_by_organization_id, organizations.id)) + .where(eq(app_builder_projects.id, projectId)) + .limit(1); + + if (!result) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Project not found', + }); + } + + // Get message count + const [messageCountResult] = await db + .select({ count: count() }) + .from(app_builder_messages) + .where(eq(app_builder_messages.project_id, projectId)); + + // Look up the CLI session by cloud_agent_session_id + let cliSessionId: string | null = null; + if (result.project.session_id) { + const [cliSession] = await db + .select({ session_id: cliSessions.session_id }) + .from(cliSessions) + .where(eq(cliSessions.cloud_agent_session_id, result.project.session_id)) + .limit(1); + cliSessionId = cliSession?.session_id ?? null; + } + + const projectDetail: AdminAppBuilderProjectDetail = { + id: result.project.id, + title: result.project.title, + model_id: result.project.model_id, + template: result.project.template, + session_id: result.project.session_id, + deployment_id: result.project.deployment_id, + created_by_user_id: result.project.created_by_user_id, + owned_by_user_id: result.project.owned_by_user_id, + owned_by_organization_id: result.project.owned_by_organization_id, + created_at: result.project.created_at, + updated_at: result.project.updated_at, + last_message_at: result.project.last_message_at, + owner_email: result.owner_user?.email ?? null, + owner_org_name: result.owner_org?.name ?? null, + message_count: messageCountResult?.count ?? 0, + is_deployed: result.project.deployment_id !== null, + cli_session_id: cliSessionId, + }; + + return projectDetail; + }), + list: adminProcedure.input(ListProjectsSchema).query(async ({ input }) => { const { offset, limit, sortBy, sortOrder, search, ownerType } = input; const searchTerm = search?.trim() || ''; @@ -141,6 +220,7 @@ export const adminAppBuilderRouter = createTRPCRouter({ id: row.project.id, title: row.project.title, model_id: row.project.model_id, + template: row.project.template, session_id: row.project.session_id, deployment_id: row.project.deployment_id, created_by_user_id: row.project.created_by_user_id,