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:
@@ -122,6 +122,16 @@ export class ApplicationsController {
|
|||||||
return updated;
|
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')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
||||||
async delete(@Param('id') id: string, @Request() req: any) {
|
async delete(@Param('id') id: string, @Request() req: any) {
|
||||||
|
|||||||
@@ -686,6 +686,82 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(resources)}`);
|
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> {
|
async deleteApplication(app: Application): Promise<void> {
|
||||||
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|||||||
@@ -176,6 +176,16 @@ export default function AppDetailPage() {
|
|||||||
onError: () => toast.error('Failed to update resources'),
|
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({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: (file: File) => {
|
mutationFn: (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -314,6 +324,18 @@ export default function AppDetailPage() {
|
|||||||
{redeployMutation.isPending ? '⏳ Rebuilding...' : '🔄 Redeploy'}
|
{redeployMutation.isPending ? '⏳ Rebuilding...' : '🔄 Redeploy'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Preview: open the running app in a new tab */}
|
||||||
|
{isRunning && (
|
||||||
|
<button
|
||||||
|
onClick={() => previewMutation.mutate()}
|
||||||
|
disabled={previewMutation.isPending}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 disabled:opacity-50 transition-colors"
|
||||||
|
title="Open the running application in a new browser tab"
|
||||||
|
>
|
||||||
|
{previewMutation.isPending ? '⏳ Loading...' : '🌐 Preview'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user