feat: show build logs alongside pod logs in app detail page
This commit is contained in:
@@ -18,9 +18,9 @@ export class BuildService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Docker image for the application using Kaniko inside K8s.
|
* Builds a Docker image for the application using Kaniko inside K8s.
|
||||||
* Returns the full image URI (registry/repo:tag).
|
* Returns { imageUri, buildLog } — the full image URI and the build logs.
|
||||||
*/
|
*/
|
||||||
async buildImage(app: Application): Promise<string> {
|
async buildImage(app: Application): Promise<{ imageUri: string; buildLog: string }> {
|
||||||
// Internal registry (used by Kaniko inside K8s for pushing)
|
// Internal registry (used by Kaniko inside K8s for pushing)
|
||||||
const internalRegistryUrl = this.configService.get<string>('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000';
|
const internalRegistryUrl = this.configService.get<string>('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000';
|
||||||
// External registry URL (used by kubelet for pulling — NodePort or external)
|
// External registry URL (used by kubelet for pulling — NodePort or external)
|
||||||
@@ -239,17 +239,26 @@ export class BuildService {
|
|||||||
// Wait for build to complete
|
// Wait for build to complete
|
||||||
await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600);
|
await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600);
|
||||||
|
|
||||||
|
// Capture build logs on success
|
||||||
|
let buildLog = '';
|
||||||
|
try {
|
||||||
|
buildLog = await this.getBuildLogs(coreApi, buildPodName, buildNamespace!);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
this.logger.log(`Build completed successfully: ${pullImageUri}`);
|
this.logger.log(`Build completed successfully: ${pullImageUri}`);
|
||||||
return pullImageUri;
|
return { imageUri: pullImageUri, buildLog };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Try to get build logs for debugging
|
// Try to get build logs for debugging
|
||||||
|
let buildLog = '';
|
||||||
try {
|
try {
|
||||||
const logs = await this.getBuildLogs(coreApi, buildPodName, buildNamespace!);
|
buildLog = await this.getBuildLogs(coreApi, buildPodName, buildNamespace!);
|
||||||
this.logger.error(`Build logs for ${buildPodName}:\n${logs}`);
|
this.logger.error(`Build logs for ${buildPodName}:\n${buildLog}`);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
this.logger.error(`Build failed for ${app.name}:`, error.body || error.message);
|
this.logger.error(`Build failed for ${app.name}:`, error.body || error.message);
|
||||||
throw new Error(`Image build failed: ${error.body?.message || error.message}`);
|
const err = new Error(`Image build failed: ${error.body?.message || error.message}`);
|
||||||
|
(err as any).buildLog = buildLog;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,11 +37,17 @@ export class DeploymentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('applications/:appId/logs')
|
@Get('applications/:appId/logs')
|
||||||
@ApiOperation({ summary: 'Get application logs' })
|
@ApiOperation({ summary: 'Get application pod logs' })
|
||||||
async getLogs(@Param('appId') appId: string, @Request() req: any) {
|
async getLogs(@Param('appId') appId: string, @Request() req: any) {
|
||||||
return { logs: await this.deploymentsService.getLogs(appId, req.user.id) };
|
return { logs: await this.deploymentsService.getLogs(appId, req.user.id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('applications/:appId/build-logs')
|
||||||
|
@ApiOperation({ summary: 'Get build logs for latest deployment' })
|
||||||
|
async getBuildLogs(@Param('appId') appId: string, @Request() req: any) {
|
||||||
|
return this.deploymentsService.getBuildLogs(appId, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('applications/:appId/stop')
|
@Post('applications/:appId/stop')
|
||||||
@ApiOperation({ summary: 'Stop an application' })
|
@ApiOperation({ summary: 'Stop an application' })
|
||||||
async stop(@Param('appId') appId: string, @Request() req: any) {
|
async stop(@Param('appId') appId: string, @Request() req: any) {
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ export class DeploymentsService {
|
|||||||
try {
|
try {
|
||||||
// Step 1: Build image
|
// Step 1: Build image
|
||||||
await this.updateStatus(deploymentId, DeploymentStatus.BUILDING);
|
await this.updateStatus(deploymentId, DeploymentStatus.BUILDING);
|
||||||
const imageUri = await this.buildService.buildImage(app);
|
const { imageUri, buildLog } = await this.buildService.buildImage(app);
|
||||||
|
|
||||||
|
// Save build log
|
||||||
|
await this.deploymentsRepository.update(deploymentId, { buildLog });
|
||||||
|
|
||||||
// Step 2: Update app with new image tag
|
// Step 2: Update app with new image tag
|
||||||
await this.applicationsService.updateImageTag(app.id, imageUri);
|
await this.applicationsService.updateImageTag(app.id, imageUri);
|
||||||
@@ -62,9 +65,13 @@ export class DeploymentsService {
|
|||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Deployment ${deploymentId} failed:`, error);
|
this.logger.error(`Deployment ${deploymentId} failed:`, error);
|
||||||
|
|
||||||
|
// Save build log if available (attached by build service on failure)
|
||||||
|
const buildLog = error.buildLog || null;
|
||||||
await this.deploymentsRepository.update(deploymentId, {
|
await this.deploymentsRepository.update(deploymentId, {
|
||||||
status: DeploymentStatus.FAILED,
|
status: DeploymentStatus.FAILED,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
|
...(buildLog ? { buildLog } : {}),
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -97,6 +104,27 @@ export class DeploymentsService {
|
|||||||
return this.kubernetesService.getPodLogs(app);
|
return this.kubernetesService.getPodLogs(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getBuildLogs(applicationId: string, userId: string): Promise<{ buildLog: string | null; status: string; version: string | null; createdAt: Date }> {
|
||||||
|
// Verify user access
|
||||||
|
await this.applicationsService.findOne(applicationId, userId);
|
||||||
|
|
||||||
|
const latest = await this.deploymentsRepository.findOne({
|
||||||
|
where: { applicationId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latest) {
|
||||||
|
return { buildLog: null, status: 'no_deployment', version: null, createdAt: new Date() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildLog: latest.buildLog || null,
|
||||||
|
status: latest.status,
|
||||||
|
version: latest.version,
|
||||||
|
createdAt: latest.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async stopDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
|
async stopDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
|
||||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||||
await this.kubernetesService.scaleDeployment(app, 0);
|
await this.kubernetesService.scaleDeployment(app, 0);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default function AppDetailPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const appId = params.id as string;
|
const appId = params.id as string;
|
||||||
const [showLogs, setShowLogs] = useState(false);
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
const [logTab, setLogTab] = useState<'pod' | 'build'>('pod');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const logsEndRef = useRef<HTMLPreElement>(null);
|
const logsEndRef = useRef<HTMLPreElement>(null);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
@@ -74,8 +75,15 @@ export default function AppDetailPage() {
|
|||||||
const { data: logsData } = useQuery<{ logs: string }>({
|
const { data: logsData } = useQuery<{ logs: string }>({
|
||||||
queryKey: ['logs', appId],
|
queryKey: ['logs', appId],
|
||||||
queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data),
|
queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data),
|
||||||
enabled: showLogs,
|
enabled: showLogs && logTab === 'pod',
|
||||||
refetchInterval: showLogs ? 3000 : false,
|
refetchInterval: showLogs && logTab === 'pod' ? 3000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: buildLogsData } = useQuery<{ buildLog: string | null; status: string; version: string | null }>({
|
||||||
|
queryKey: ['build-logs', appId],
|
||||||
|
queryFn: () => api.get(`/deployments/applications/${appId}/build-logs`).then((r) => r.data),
|
||||||
|
enabled: showLogs && logTab === 'build',
|
||||||
|
refetchInterval: showLogs && logTab === 'build' ? 5000 : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: resourceUsage, isLoading: resourcesLoading } = useQuery<ResourceUsage>({
|
const { data: resourceUsage, isLoading: resourcesLoading } = useQuery<ResourceUsage>({
|
||||||
@@ -779,17 +787,23 @@ export default function AppDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pod Logs */}
|
{/* Logs — Pod & Build */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">📋 Pod Logs</h2>
|
<h2 className="text-lg font-semibold text-gray-900">📋 Logs</h2>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{showLogs && (
|
{showLogs && logTab === 'pod' && (
|
||||||
<span className="text-xs text-gray-400 flex items-center space-x-1">
|
<span className="text-xs text-gray-400 flex items-center space-x-1">
|
||||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||||
<span>Live (every 3s)</span>
|
<span>Live (every 3s)</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showLogs && logTab === 'build' && (
|
||||||
|
<span className="text-xs text-gray-400 flex items-center space-x-1">
|
||||||
|
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||||
|
<span>Auto-refresh (every 5s)</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowLogs(!showLogs)}
|
onClick={() => setShowLogs(!showLogs)}
|
||||||
className="btn-secondary text-sm"
|
className="btn-secondary text-sm"
|
||||||
@@ -799,12 +813,66 @@ export default function AppDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showLogs && (
|
{showLogs && (
|
||||||
<pre
|
<div className="space-y-3">
|
||||||
ref={logsEndRef}
|
{/* Tab switcher */}
|
||||||
className="bg-gray-900 text-green-400 p-4 rounded-lg text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words"
|
<div className="flex space-x-1 bg-gray-100 rounded-lg p-1">
|
||||||
>
|
<button
|
||||||
{logsData?.logs || (isRunning ? 'Loading logs...' : isStopped ? 'Application is stopped. Start it to see logs.' : 'Waiting for pod to be ready...')}
|
onClick={() => setLogTab('pod')}
|
||||||
</pre>
|
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
logTab === 'pod'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🖥️ Pod Logs
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLogTab('build')}
|
||||||
|
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
logTab === 'build'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🔨 Build Logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pod logs */}
|
||||||
|
{logTab === 'pod' && (
|
||||||
|
<pre
|
||||||
|
ref={logsEndRef}
|
||||||
|
className="bg-gray-900 text-green-400 p-4 rounded-lg text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words"
|
||||||
|
>
|
||||||
|
{logsData?.logs || (isRunning ? 'Loading logs...' : isStopped ? 'Application is stopped. Start it to see logs.' : 'Waiting for pod to be ready...')}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Build logs */}
|
||||||
|
{logTab === 'build' && (
|
||||||
|
<div>
|
||||||
|
{buildLogsData?.version && (
|
||||||
|
<div className="flex items-center space-x-3 mb-2 text-xs text-gray-500">
|
||||||
|
<span>📌 {buildLogsData.version}</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded-full font-medium ${statusColors[buildLogsData.status] || 'bg-gray-100'}`}>
|
||||||
|
{buildLogsData.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<pre className="bg-gray-900 text-blue-300 p-4 rounded-lg text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words">
|
||||||
|
{buildLogsData?.buildLog || (
|
||||||
|
buildLogsData?.status === 'building'
|
||||||
|
? 'Build in progress... Logs will appear when complete.'
|
||||||
|
: buildLogsData?.status === 'pending'
|
||||||
|
? 'Build is pending...'
|
||||||
|
: buildLogsData?.status === 'no_deployment'
|
||||||
|
? 'No deployments yet. Deploy your app to see build logs.'
|
||||||
|
: 'No build logs available for this deployment.'
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user