feat: add app preview via NodePort

- 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
This commit is contained in:
keyhan
2026-04-05 16:33:48 +03:30
parent 51e56c6996
commit a7ef4649e5
3 changed files with 108 additions and 0 deletions
@@ -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<void> {
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;