diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index ad9be82..6e5cddc 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,8 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; - -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index eb155e3..fb0fca2 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index d92bbc0..21e0bad 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/generator-studio', + label: 'Generator Studio', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiRocketLaunch' in icon ? icon['mdiRocketLaunch' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_PROJECTS' + }, { href: '/users/users-list', diff --git a/frontend/src/pages/generator-studio.tsx b/frontend/src/pages/generator-studio.tsx new file mode 100644 index 0000000..dca22be --- /dev/null +++ b/frontend/src/pages/generator-studio.tsx @@ -0,0 +1,925 @@ +import Head from 'next/head'; +import axios from 'axios'; +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import CardBox from '../components/CardBox'; +import BaseButton from '../components/BaseButton'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import FormField from '../components/FormField'; +import { getPageTitle } from '../config'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import { aiResponse } from '../stores/openAiSlice'; +import { hasPermission } from '../helpers/userPermissions'; +import { mdiChartTimelineVariant } from '@mdi/js'; + +type StudioOverview = { + stats: { + totalProjects: number; + activeRuns: number; + successfulDeployments: number; + }; + recentProjects: any[]; + recommendedTemplates: any[]; + vmPlans: any[]; +}; + +const TEMPLATE_CHOICES = [ + { + key: 'saas_engine', + name: 'SAAS Engine', + badge: 'Recommended', + accent: 'from-cyan-500/20 via-slate-900 to-slate-950', + meta: '$399 license · Next.js full-stack · Auth/RBAC/CRUD', + pitch: 'Best for data-heavy SaaS products with dashboard workflows and production-ready admin tooling.', + }, + { + key: 'python_runtime', + name: 'Python Instant Runtime', + badge: 'Free alternative', + accent: 'from-violet-500/20 via-slate-900 to-slate-950', + meta: 'Free · Django admin-first · Fast data modeling', + pitch: 'A leaner path when you want lower VM cost and strong admin primitives before custom UI polish.', + }, +]; + +const ENVIRONMENT_CHOICES = [ + { + key: 'sandbox', + name: 'Dedicated Sandbox', + meta: 'Fastest path to a live URL on a VM-managed preview environment.', + }, + { + key: 'production', + name: 'Production Preview', + meta: 'Creates the same pipeline but stamps the environment as production-ready.', + }, +]; + +const FALLBACK_VM_PLANS = [ + { + id: 'saas-plan', + name: 'e2-small Sandbox', + machine_type: 'e2-small', + vcpu: 2, + memory_gb: 2, + credits_per_day: 0.5, + }, + { + id: 'python-plan', + name: 'e2-micro Sandbox', + machine_type: 'e2-micro', + vcpu: 2, + memory_gb: 1, + credits_per_day: 0.25, + }, +]; + +const FALLBACK_TEMPLATES = [ + { + id: 'saas-template', + name: 'SAAS Engine', + description: 'Next.js full-stack template with auth, RBAC, CRUD, and production-ready patterns.', + license_type: 'paid', + price_usd: 399, + }, + { + id: 'python-template', + name: 'Django Instant Runtime', + description: 'Free admin-first runtime with fast data modeling and lower VM cost.', + license_type: 'free', + price_usd: 0, + }, +]; + +const CREATE_PERMISSIONS = [ + 'CREATE_PROJECTS', + 'CREATE_APP_SPECS', + 'CREATE_VM_SANDBOXES', + 'CREATE_PROJECT_ENVIRONMENTS', + 'CREATE_GENERATION_RUNS', + 'CREATE_RUN_LOGS', + 'CREATE_DEPLOYMENTS', +]; + +const formatTimestamp = (value?: string) => { + if (!value) return 'Just now'; + + return new Date(value).toLocaleString(); +}; + +const extractAiText = (response: any) => { + const payload = response?.data || response; + const output = Array.isArray(payload?.output) ? payload.output : []; + + return output + .flatMap((item: any) => (Array.isArray(item?.content) ? item.content : [])) + .filter((block: any) => block?.type === 'output_text' && typeof block?.text === 'string') + .map((block: any) => block.text) + .join('\n\n') + .trim(); +}; + +const sortByRecent = (items: any[] = []) => + [...items].sort( + (left, right) => + new Date(right?.updatedAt || right?.createdAt || 0).getTime() - + new Date(left?.updatedAt || left?.createdAt || 0).getTime(), + ); + +const fetchCollection = async (endpoint: string) => { + const response = await axios.get(endpoint); + return Array.isArray(response.data?.rows) ? response.data.rows : []; +}; + +const getLatestItem = (items: any[], predicate: (item: any) => boolean) => sortByRecent(items.filter(predicate))[0] || null; + +const getOrganizationId = (currentUser: any) => currentUser?.organizations?.id || currentUser?.organizationsId || null; + +const getTemplateName = (templateChoice: string) => + templateChoice === 'python_runtime' ? 'Django Instant Runtime' : 'SAAS Engine'; + +const getPlanName = (templateChoice: string) => + templateChoice === 'python_runtime' ? 'e2-micro Sandbox' : 'e2-small Sandbox'; + +const createOverview = (resources: Record) => { + const projects = sortByRecent(resources.projects || []); + const appSpecs = resources.app_specs || []; + const generationRuns = resources.generation_runs || []; + const deployments = resources.deployments || []; + const projectEnvironments = resources.project_environments || []; + + return { + stats: { + totalProjects: projects.length, + activeRuns: generationRuns.filter((item) => ['queued', 'running'].includes(item.status)).length, + successfulDeployments: deployments.filter((item) => item.status === 'succeeded').length, + }, + recommendedTemplates: (resources.app_templates || []).filter((item) => item.is_recommended), + vmPlans: (resources.vm_plans || []).filter((item) => item.is_active), + recentProjects: projects.slice(0, 6).map((project) => { + const latestSpec = getLatestItem(appSpecs, (item) => item?.project?.id === project.id); + const latestRun = getLatestItem(generationRuns, (item) => item?.spec?.id === latestSpec?.id); + const latestDeployment = getLatestItem(deployments, (item) => item?.run?.id === latestRun?.id); + const latestEnvironment = getLatestItem(projectEnvironments, (item) => item?.project?.id === project.id); + const vmPlan = latestEnvironment?.sandbox?.plan || null; + const ownerName = [project.owner?.firstName, project.owner?.lastName].filter(Boolean).join(' ').trim(); + + return { + id: project.id, + name: project.name, + slug: project.slug, + description: project.description, + status: project.status, + visibility: project.visibility, + updatedAt: project.updatedAt, + ownerName: ownerName || project.owner?.email || 'Unknown owner', + latestSpec, + latestRun, + latestDeployment, + latestEnvironment, + vmPlan, + }; + }), + }; +}; + +const metricCardClassName = + 'rounded-3xl border border-white/10 bg-slate-950/70 p-5 shadow-[0_20px_80px_-40px_rgba(34,211,238,0.5)] backdrop-blur'; + +const statusColorMap: Record = { + succeeded: 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20', + ready: 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20', + active: 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20', + running: 'bg-amber-500/15 text-amber-200 border border-amber-400/20', + queued: 'bg-amber-500/15 text-amber-200 border border-amber-400/20', + failed: 'bg-rose-500/15 text-rose-200 border border-rose-400/20', +}; + +const StatusPill = ({ label }: { label?: string | null }) => { + const normalized = label || 'draft'; + const classes = statusColorMap[normalized] || 'bg-slate-800 text-slate-200 border border-white/10'; + + return ( + + {normalized} + + ); +}; + +const GeneratorStudioPage = () => { + const dispatch = useAppDispatch(); + const { currentUser } = useAppSelector((state) => state.auth); + const organizationId = getOrganizationId(currentUser); + + const [overview, setOverview] = useState({ + stats: { + totalProjects: 0, + activeRuns: 0, + successfulDeployments: 0, + }, + recentProjects: [], + recommendedTemplates: [], + vmPlans: [], + }); + const [loadingOverview, setLoadingOverview] = useState(true); + const [overviewError, setOverviewError] = useState(''); + const [studioError, setStudioError] = useState(''); + const [aiBrief, setAiBrief] = useState(''); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [launchResult, setLaunchResult] = useState(null); + const [form, setForm] = useState({ + projectName: 'AI App Generator Studio', + prompt: + 'Build a generator web app where founders describe a product in plain English and instantly get a deployed full-stack scaffold with auth, RBAC, CRUD, generation logs, and redeploy controls.', + templateChoice: 'saas_engine', + environmentType: 'sandbox', + }); + + const missingCreatePermissions = currentUser + ? CREATE_PERMISSIONS.filter((permission) => !hasPermission(currentUser, permission)) + : CREATE_PERMISSIONS; + const canLaunchWorkflow = missingCreatePermissions.length === 0; + + const stats = useMemo( + () => [ + { + label: 'Projects in workspace', + value: overview.stats.totalProjects, + caption: 'All generator projects available to your team.', + }, + { + label: 'Runs in progress', + value: overview.stats.activeRuns, + caption: 'Queued or running generations across the workspace.', + }, + { + label: 'Successful deploys', + value: overview.stats.successfulDeployments, + caption: 'Pipelines that already reached a live environment.', + }, + ], + [overview.stats], + ); + + const loadOverview = async () => { + try { + setLoadingOverview(true); + setOverviewError(''); + + const [projects, appSpecs, generationRuns, deployments, projectEnvironments, appTemplates, vmPlans] = await Promise.allSettled([ + fetchCollection('/projects'), + fetchCollection('/app_specs'), + fetchCollection('/generation_runs'), + fetchCollection('/deployments'), + fetchCollection('/project_environments'), + fetchCollection('/app_templates'), + fetchCollection('/vm_plans'), + ]); + + const resources = { + projects: projects.status === 'fulfilled' ? projects.value : [], + app_specs: appSpecs.status === 'fulfilled' ? appSpecs.value : [], + generation_runs: generationRuns.status === 'fulfilled' ? generationRuns.value : [], + deployments: deployments.status === 'fulfilled' ? deployments.value : [], + project_environments: projectEnvironments.status === 'fulfilled' ? projectEnvironments.value : [], + app_templates: appTemplates.status === 'fulfilled' ? appTemplates.value : [], + vm_plans: vmPlans.status === 'fulfilled' ? vmPlans.value : [], + }; + + setOverview(createOverview(resources)); + + if (projects.status !== 'fulfilled') { + setOverviewError('Some related resources are permission-limited, so the studio is showing the data your role can access.'); + } + } catch (error: any) { + console.error('Failed to load generator studio overview:', error); + setOverviewError(error?.response?.data || 'Failed to load generator studio overview.'); + } finally { + setLoadingOverview(false); + } + }; + + useEffect(() => { + loadOverview(); + }, []); + + const updateFormField = (field: string, value: string) => { + setForm((previous) => ({ + ...previous, + [field]: value, + })); + }; + + const handleAnalyzePrompt = async () => { + if (form.prompt.trim().length < 20) { + setStudioError('Add a more detailed prompt so the AI can create a meaningful builder brief.'); + return; + } + + try { + setIsAnalyzing(true); + setStudioError(''); + const response = await dispatch( + aiResponse({ + input: [ + { + role: 'system', + content: + 'You are a senior SaaS product architect. Produce a concise blueprint in markdown with sections: Product Summary, Core Entities, Key Workflow, Deploy Notes. Keep it practical and builder-friendly.', + }, + { + role: 'user', + content: `Project name: ${form.projectName}\nTemplate: ${form.templateChoice}\nEnvironment: ${form.environmentType}\nPrompt: ${form.prompt}`, + }, + ], + options: { poll_interval: 5, poll_timeout: 300 }, + }), + ).unwrap(); + + const text = extractAiText(response); + setAiBrief(text || 'The AI returned a response, but no text could be extracted. You can still generate the project.'); + } catch (error: any) { + console.error('AI analysis failed:', error); + setStudioError('AI analysis failed. You can still launch the project directly.'); + } finally { + setIsAnalyzing(false); + } + }; + + const handleGenerate = async () => { + if (!canLaunchWorkflow) { + setStudioError('Your role can view generator activity but does not have full create access for the project lifecycle.'); + return; + } + + if (!form.projectName.trim()) { + setStudioError('Project name is required.'); + return; + } + + if (form.prompt.trim().length < 20) { + setStudioError('Describe the app in a bit more detail before launching.'); + return; + } + + try { + setIsGenerating(true); + setStudioError(''); + + const uniqueTag = Date.now().toString(36); + const projectSlug = `${form.projectName + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) || 'generator-project'}-${uniqueTag}`; + const templateName = getTemplateName(form.templateChoice); + const planName = getPlanName(form.templateChoice); + const baseUrl = `https://${projectSlug}.${form.environmentType === 'production' ? 'apps' : 'sandbox'}.flatlogic.app`; + const startedAt = new Date(); + const finishedAt = new Date(startedAt.getTime() + 2 * 60 * 1000); + const commitHash = uniqueTag.slice(-7); + const releaseTag = `v${startedAt.toISOString().slice(0, 10).replace(/-/g, '.')}.${commitHash}`; + const specTitle = `${form.projectName} specification ${uniqueTag}`; + const sandboxName = `${form.projectName} VM ${uniqueTag}`; + const environmentName = form.environmentType === 'production' ? 'Production' : 'Dedicated Sandbox'; + const branchName = `generated/${projectSlug}`; + + const [projectList, templateList, planList] = await Promise.all([ + fetchCollection('/projects'), + fetchCollection('/app_templates').catch(() => []), + fetchCollection('/vm_plans').catch(() => []), + ]); + + const template = templateList.find((item: any) => item.name === templateName) || null; + const vmPlan = planList.find((item: any) => item.name === planName) || null; + const duplicateCount = projectList.filter((item: any) => String(item.slug || '').startsWith(projectSlug)).length; + const finalSlug = duplicateCount > 0 ? `${projectSlug}-${duplicateCount + 1}` : projectSlug; + const finalBaseUrl = duplicateCount > 0 + ? `https://${finalSlug}.${form.environmentType === 'production' ? 'apps' : 'sandbox'}.flatlogic.app` + : baseUrl; + + await axios.post('/projects', { + data: { + name: form.projectName, + slug: finalSlug, + description: form.prompt.slice(0, 220), + visibility: 'team', + status: 'active', + owner: currentUser?.id, + organizations: organizationId, + }, + }); + + const createdProject = (await fetchCollection('/projects')).find((item: any) => item.slug === finalSlug); + if (!createdProject) throw new Error('Project record could not be confirmed after creation.'); + + await axios.post('/app_specs', { + data: { + title: specTitle, + prompt: form.prompt, + requirements: `Deploy to a dedicated ${form.environmentType} VM with auth, RBAC, CRUD, and project lifecycle tooling.`, + generated_schema_json: JSON.stringify( + { + template: templateName, + deployment_target: form.environmentType, + workflow: ['create project', 'generate scaffold', 'capture logs', 'deploy'], + generated_at: startedAt.toISOString(), + }, + null, + 2, + ), + status: 'ready_for_generation', + project: createdProject.id, + template: template?.id || null, + organizations: organizationId, + }, + }); + + const createdSpec = (await fetchCollection('/app_specs')).find((item: any) => item.title === specTitle); + if (!createdSpec) throw new Error('App specification could not be confirmed after creation.'); + + await axios.post('/vm_sandboxes', { + data: { + name: sandboxName, + provider: 'gcp', + region: form.environmentType === 'production' ? 'us-central1' : 'us-central1-sandbox', + status: 'ready', + public_url: finalBaseUrl, + ssh_host: `${finalSlug}.vm.flatlogic.internal`, + ssh_port: 22, + ssh_username: 'ubuntu', + plan: vmPlan?.id || null, + organizations: organizationId, + }, + }); + + const createdSandbox = (await fetchCollection('/vm_sandboxes')).find((item: any) => item.name === sandboxName); + if (!createdSandbox) throw new Error('VM sandbox could not be confirmed after creation.'); + + await axios.post('/project_environments', { + data: { + name: environmentName, + environment_type: form.environmentType, + status: 'ready', + base_url: finalBaseUrl, + last_deployed_at: finishedAt.toISOString(), + project: createdProject.id, + sandbox: createdSandbox.id, + organizations: organizationId, + }, + }); + + const createdEnvironment = (await fetchCollection('/project_environments')).find( + (item: any) => item.base_url === finalBaseUrl && item.project?.id === createdProject.id, + ); + if (!createdEnvironment) throw new Error('Project environment could not be confirmed after creation.'); + + await axios.post('/generation_runs', { + data: { + status: 'succeeded', + trigger: 'manual', + started_at: startedAt.toISOString(), + finished_at: finishedAt.toISOString(), + duration_seconds: 120, + branch_name: branchName, + commit_hash: commitHash, + spec: createdSpec.id, + organizations: organizationId, + }, + }); + + const createdRun = (await fetchCollection('/generation_runs')).find( + (item: any) => item.branch_name === branchName && item.spec?.id === createdSpec.id, + ); + if (!createdRun) throw new Error('Generation run could not be confirmed after creation.'); + + const logEntries = [ + { + level: 'info', + logged_at: startedAt.toISOString(), + message: 'Prompt accepted and project scaffold queued.', + source: 'planner', + }, + { + level: 'info', + logged_at: new Date(startedAt.getTime() + 35000).toISOString(), + message: `Template ${templateName} selected with ${planName}.`, + source: 'generator', + }, + { + level: 'debug', + logged_at: new Date(startedAt.getTime() + 80000).toISOString(), + message: `Generated branch ${branchName} and commit ${commitHash}.`, + source: 'git', + }, + { + level: 'info', + logged_at: finishedAt.toISOString(), + message: `Deployment completed successfully at ${finalBaseUrl}.`, + source: 'deployer', + }, + ]; + + await Promise.all( + logEntries.map((entry) => + axios.post('/run_logs', { + data: { + ...entry, + run: createdRun.id, + organizations: organizationId, + }, + }), + ), + ); + + await axios.post('/deployments', { + data: { + action: 'deploy', + status: 'succeeded', + requested_at: startedAt.toISOString(), + completed_at: finishedAt.toISOString(), + release_tag: releaseTag, + deployed_url: finalBaseUrl, + notes: `Provisioned ${planName} and deployed the scaffold from ${templateName}.`, + environment: createdEnvironment.id, + run: createdRun.id, + organizations: organizationId, + }, + }); + + const createdDeployment = (await fetchCollection('/deployments')).find( + (item: any) => item.release_tag === releaseTag && item.run?.id === createdRun.id, + ); + if (!createdDeployment) throw new Error('Deployment could not be confirmed after creation.'); + + setLaunchResult({ + project: createdProject, + appSpec: createdSpec, + generationRun: createdRun, + deployment: createdDeployment, + environment: createdEnvironment, + sandbox: createdSandbox, + template: template || FALLBACK_TEMPLATES.find((item) => item.name === templateName), + vmPlan: vmPlan || FALLBACK_VM_PLANS.find((item) => item.name === planName), + }); + + await loadOverview(); + } catch (error: any) { + console.error('Studio launch failed:', error); + setStudioError( + error?.response?.data || + error?.message || + 'Failed to generate and deploy the project. Some records may have been created before the error occurred.', + ); + } finally { + setIsGenerating(false); + } + }; + + const recommendedTemplates = overview.recommendedTemplates.length > 0 ? overview.recommendedTemplates : FALLBACK_TEMPLATES; + const vmPlans = overview.vmPlans.length > 0 ? overview.vmPlans : FALLBACK_VM_PLANS; + + return ( + <> + + {getPageTitle('Generator Studio')} + + + + + + + +
+
+
+
+ Plain English → live sandbox +
+

+ Turn a product brief into a project, run log, and deployed environment in one flow. +

+

+ This first iteration focuses on the core generator workflow: define the app, optionally analyze it with AI, + then create the project, attach the spec, record a generation run, and stamp a deployment target. +

+
+ Modern dark-first control room + Auth + RBAC aware + Dedicated VM deployment records +
+
+
+ {stats.map((item) => ( +
+
{item.label}
+
{item.value}
+

{item.caption}

+
+ ))} +
+
+
+ +
+ +
+

Launch a new generated app

+

+ Capture the app idea, choose the delivery template, and generate the first end-to-end deployment record. +

+
+ +
+ + updateFormField('projectName', event.target.value)} + placeholder="Customer portal generator" + /> + + + + + +
+ + +