From a7ef4649e5234269c1f3ae3c0d8a5032bab15d36 Mon Sep 17 00:00:00 2001 From: keyhan Date: Sun, 5 Apr 2026 16:33:48 +0330 Subject: [PATCH] feat: add app preview via NodePort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: getPreviewInfo() in KubernetesService auto-patches ClusterIP service to NodePort for direct external access - Backend: GET /applications/:id/preview endpoint returns access URL with nodePort, host IP (extracted from kubeconfig), and ingress URL - Frontend: '🌐 Preview' button on app detail page (visible when running) opens the deployed app in a new browser tab via NodePort URL - Tested: service patched to NodePort 30107 successfully --- .../applications/applications.controller.ts | 10 +++ backend/src/kubernetes/kubernetes.service.ts | 76 +++++++++++++++++++ frontend/src/app/dashboard/apps/[id]/page.tsx | 22 ++++++ 3 files changed, 108 insertions(+) diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index 0caf5ff..273ac7b 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -122,6 +122,16 @@ export class ApplicationsController { return updated; } + @Get(':id/preview') + @ApiOperation({ summary: 'Get preview URL for the deployed application' }) + async getPreview(@Param('id') id: string, @Request() req: any) { + const app = await this.applicationsService.findOne( + id, + req.user.role === UserRole.ADMIN ? undefined : req.user.id, + ); + return this.kubernetesService.getPreviewInfo(app); + } + @Delete(':id') @ApiOperation({ summary: 'Delete an application and all its resources' }) async delete(@Param('id') id: string, @Request() req: any) { diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 2d5f00e..1f60ee5 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -686,6 +686,82 @@ export class KubernetesService implements OnModuleInit { this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(resources)}`); } + /** + * Get preview info for a deployed application. + * Patches the service to NodePort if needed, and returns the access URL. + */ + async getPreviewInfo(app: Application): Promise<{ + url: string; + nodePort: number; + host: string; + ingressUrl?: string; + }> { + const { coreApi, networkingApi, kc } = await this.getK8sClient(app.clusterId); + const namespace = `user-${app.userId.split('-')[0]}`; + const domain = this.configService.get('platform.domain'); + const clusterServer = kc.getCurrentCluster()?.server || ''; + // Extract host IP from cluster API server URL (e.g., https://217.197.107.252:6443 → 217.197.107.252) + let hostIp = '127.0.0.1'; + try { + const serverUrl = new URL(clusterServer); + hostIp = serverUrl.hostname; + } catch {} + + // Read current service + let nodePort = 0; + try { + const svcResponse = await coreApi.readNamespacedService(app.name, namespace); + const svc = svcResponse.body; + + if (svc.spec?.type === 'NodePort') { + // Already NodePort, read the assigned port + nodePort = svc.spec.ports?.[0]?.nodePort || 0; + } else { + // Patch ClusterIP → NodePort so we can access from outside + const patchBody = { + spec: { + type: 'NodePort', + ports: [ + { + port: 80, + targetPort: app.port, + protocol: 'TCP', + }, + ], + }, + }; + + const patchedResponse = await coreApi.patchNamespacedService( + app.name, + namespace, + patchBody, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } }, + ); + nodePort = patchedResponse.body.spec?.ports?.[0]?.nodePort || 0; + this.logger.log(`Patched service ${app.name} to NodePort: ${nodePort}`); + } + } catch (e: any) { + this.logger.warn(`Failed to get/patch service for ${app.name}: ${e.message}`); + throw new Error(`Service not found for "${app.name}". Make sure the app is deployed.`); + } + + // Build ingress URL + const subdomain = app.subdomain || app.name; + const ingressUrl = `https://${subdomain}.${domain}`; + + return { + url: `http://${hostIp}:${nodePort}`, + nodePort, + host: hostIp, + ingressUrl, + }; + } + async deleteApplication(app: Application): Promise { const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index e9b3d6a..64882e7 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -176,6 +176,16 @@ export default function AppDetailPage() { onError: () => toast.error('Failed to update resources'), }); + const previewMutation = useMutation({ + mutationFn: () => api.get(`/applications/${appId}/preview`).then((r) => r.data), + onSuccess: (data: { url: string; nodePort: number; host: string; ingressUrl?: string }) => { + // Open the preview URL in a new tab + window.open(data.url, '_blank'); + toast.success(`Preview opened on port ${data.nodePort}`); + }, + onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'), + }); + const uploadMutation = useMutation({ mutationFn: (file: File) => { const formData = new FormData(); @@ -314,6 +324,18 @@ export default function AppDetailPage() { {redeployMutation.isPending ? '⏳ Rebuilding...' : '🔄 Redeploy'} )} + + {/* Preview: open the running app in a new tab */} + {isRunning && ( + + )} )}