diff --git a/package.json b/package.json index a83ce69..41355a5 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "dev-live": "bun src/server.ts", "build": "next build", "start": "next start", "prisma-generate": "bunx prisma generate && bun ./fix-wrong-zod-imports.js", diff --git a/src/app/project/app/[tabName]/app-tabs.tsx b/src/app/project/app/[tabName]/app-tabs.tsx index b325a00..525c81e 100644 --- a/src/app/project/app/[tabName]/app-tabs.tsx +++ b/src/app/project/app/[tabName]/app-tabs.tsx @@ -10,7 +10,7 @@ import DomainsList from "./domains/domains"; import StorageList from "./storage/storages"; import { AppExtendedModel } from "@/model/app-extended.model"; import { BuildJobModel } from "@/model/build-job"; -import BuildsTab from "./overview/builds"; +import BuildsTab from "./overview/deployments"; import Logs from "./overview/logs"; export default function AppTabs({ diff --git a/src/app/project/app/[tabName]/overview/build-logs-overlay.tsx b/src/app/project/app/[tabName]/overview/build-logs-overlay.tsx new file mode 100644 index 0000000..60cfd69 --- /dev/null +++ b/src/app/project/app/[tabName]/overview/build-logs-overlay.tsx @@ -0,0 +1,51 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import React, { useEffect } from "react"; +import { set } from "date-fns"; +import { DeploymentInfoModel } from "@/model/deployment-info.model"; +import LogsStreamed from "./logs-streamed"; +import { formatDate } from "@/lib/format.utils"; +import { podLogsSocket } from "@/lib/sockets"; + +export function BuildLogsDialog({ + deploymentInfo, + onClose +}: { + deploymentInfo?: DeploymentInfoModel; + onClose: () => void; +}) { + + if (!deploymentInfo) { + return <>>; + } + + return ( + { + podLogsSocket.emit('leavePodLog', { streamKey: deploymentInfo.buildJobName }); + onClose(); + }}> + + + Build Logs + + View the build logs for the selected deployment {formatDate(deploymentInfo.createdAt)}. + + + + {!deploymentInfo.buildJobName && 'For this build is no log available'} + {deploymentInfo.buildJobName && } + + + + ) +} diff --git a/src/app/project/app/[tabName]/overview/builds.tsx b/src/app/project/app/[tabName]/overview/deployments.tsx similarity index 90% rename from src/app/project/app/[tabName]/overview/builds.tsx rename to src/app/project/app/[tabName]/overview/deployments.tsx index 806d977..bdcbc08 100644 --- a/src/app/project/app/[tabName]/overview/builds.tsx +++ b/src/app/project/app/[tabName]/overview/deployments.tsx @@ -17,6 +17,7 @@ import { DeploymentInfoModel } from "@/model/deployment-info.model"; import DeploymentStatusBadge from "./deployment-status-badge"; import { io } from "socket.io-client"; import { podLogsSocket } from "@/lib/sockets"; +import { BuildLogsDialog } from "./build-logs-overlay"; export default function BuildsTab({ app @@ -27,6 +28,7 @@ export default function BuildsTab({ const { openDialog } = useConfirmDialog(); const [appBuilds, setAppBuilds] = useState(undefined); const [error, setError] = useState(undefined); + const [selectedDeploymentForLogs, setSelectedDeploymentForLogs] = useState(undefined); const updateBuilds = async () => { setError(undefined); @@ -90,7 +92,7 @@ export default function BuildsTab({ return <> - {item.buildJobName && Show Logs} + {item.buildJobName && setSelectedDeploymentForLogs(item)}>Show Logs} {item.buildJobName && item.status === 'BUILDING' && deleteBuildClick(item.buildJobName!)}>Stop Build} > @@ -98,6 +100,7 @@ export default function BuildsTab({ /> } - + + setSelectedDeploymentForLogs(undefined)} /> >; } diff --git a/src/app/project/app/[tabName]/overview/logs-streamed.tsx b/src/app/project/app/[tabName]/overview/logs-streamed.tsx index c0f8949..d5cbb63 100644 --- a/src/app/project/app/[tabName]/overview/logs-streamed.tsx +++ b/src/app/project/app/[tabName]/overview/logs-streamed.tsx @@ -1,17 +1,21 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { podLogsSocket } from "@/lib/sockets"; import { Textarea } from "@/components/ui/textarea"; +import React from "react"; export default function LogsStreamed({ namespace, podName, + buildJobName, }: { - namespace: string; - podName: string; + namespace?: string; + podName?: string; + buildJobName?: string; }) { const [isConnected, setIsConnected] = useState(false); const [transport, setTransport] = useState("N/A"); const [logs, setLogs] = useState(''); + const textAreaRef = useRef(null); function onConnect() { setIsConnected(true); @@ -28,40 +32,47 @@ export default function LogsStreamed({ } const myListener = (e: string) => { - setLogs(e); + setLogs((prevLogs) => prevLogs + e); } useEffect(() => { - if (!podName) { + if (!buildJobName && (!namespace || !podName)) { return; } - const logEventName = `${namespace}_${podName}`; - console.log('Connecting to logs ' + logEventName); + const streamKey = buildJobName ? buildJobName : `${namespace}_${podName}`; + console.log('Connecting to logs ' + streamKey); if (podLogsSocket.connected) { onConnect(); } - podLogsSocket.emit('joinPodLog', { namespace, podName }); + podLogsSocket.emit('joinPodLog', { namespace, podName, buildJobName }); podLogsSocket.on("connect", onConnect); podLogsSocket.on("disconnect", onDisconnect); - podLogsSocket.on(logEventName, myListener); + podLogsSocket.on(streamKey, myListener); return () => { if (!podName) { return; } - console.log('Disconnecting from logs ' + logEventName); - podLogsSocket.emit('leavePodLog', { namespace, podName }); + console.log('Disconnecting from logs ' + streamKey); + podLogsSocket.emit('leavePodLog', { streamKey: streamKey }); setLogs(''); podLogsSocket.off("connect", onConnect); podLogsSocket.off("disconnect", onDisconnect); - podLogsSocket.off(logEventName, myListener); + podLogsSocket.off(streamKey, myListener); }; - }, [namespace, podName]); + }, [namespace, podName, buildJobName]); + + useEffect(() => { + if (textAreaRef.current) { + // Scroll to the bottom every time logs change + textAreaRef.current.scrollTop = textAreaRef.current.scrollHeight; + } + }, [logs]); return <> - + Status: {isConnected ? 'Connected' : 'Disconnected'} >; } diff --git a/src/server/services/build.service.ts b/src/server/services/build.service.ts index 921502b..0a97a14 100644 --- a/src/server/services/build.service.ts +++ b/src/server/services/build.service.ts @@ -4,6 +4,7 @@ import { V1Job, V1JobStatus } from "@kubernetes/client-node"; import { StringUtils } from "../utils/string.utils"; import { BuildJobModel } from "@/model/build-job"; import { ServiceException } from "@/model/service.exception.model"; +import { PodsInfoModel } from "@/model/pods-info.model"; const kanikoImage = "gcr.io/kaniko-project/executor:latest"; export const registryURL = "registry-svc.registry-and-build.svc.cluster.local" @@ -97,6 +98,20 @@ class BuildService { return builds; } + + async getPodForJob(jobName: string) { + const res = await k3s.core.listNamespacedPod(buildNamespace, undefined, undefined, undefined, undefined, `job-name=${jobName}`); + const jobs = res.body.items; + if (jobs.length === 0) { + throw new ServiceException(`No pod found for job ${jobName}`); + } + const pod = jobs[0]; + return { + podName: pod.metadata?.name!, + containerName: pod.spec?.containers?.[0].name! + } as PodsInfoModel; + } + async waitForJobCompletion(jobName: string) { const POLL_INTERVAL = 10000; // 10 seconds return await new Promise((resolve, reject) => { diff --git a/src/server/services/log-stream.service.ts b/src/server/services/log-stream.service.ts index 15dc1eb..c63ec6b 100644 --- a/src/server/services/log-stream.service.ts +++ b/src/server/services/log-stream.service.ts @@ -3,45 +3,69 @@ import k3s from "../adapter/kubernetes-api.adapter"; import { DefaultEventsMap, Socket } from "socket.io"; import stream from "stream"; import { PodsInfoModel } from "@/model/pods-info.model"; +import buildService, { buildNamespace } from "./build.service"; class LogStreamService { async streamLogs(socket: Socket) { console.log('[CONNECT] Client connected:', socket.id); - let logStream: stream.PassThrough; - let k3sStreamRequest: any; - let streamKey: string; + type socketStreamsBody = { + logStream: stream.PassThrough, + k3sStreamRequest: any + }; + const socketStreams = new Map(); - socket.on('joinPodLog', async (podInfo) => { - const { namespace, podName } = podInfo; - if (!namespace || !podName) { + socket.on('joinPodLog', (podInfo) => this.streamWrapper(socket, async () => { + let { namespace, podName, buildJobName } = podInfo; + let pod; + let streamKey; + if (namespace && podName) { + pod = await deploymentService.getPodByName(namespace, podName); + streamKey = `${namespace}_${podName}`; + + } else if (buildJobName) { + namespace = buildNamespace; + pod = await buildService.getPodForJob(buildJobName); + streamKey = `${buildJobName}`; + + } else { + console.error('Invalid pod info for streaming logs', podInfo); return; } - const pod = await deploymentService.getPodByName(namespace, podName); - streamKey = `${namespace}_${pod.podName}`; + if (socketStreams.has(streamKey)) { + console.error(`[INFO] Client ${socket.id} already joined log stream for ${streamKey}`); + return; + } // create stream if not existing const retVal = await this.createLogStreamForPod(socket, streamKey, namespace, pod); - logStream = retVal.logStream; - k3sStreamRequest = retVal.k3sStreamRequest; + socketStreams.set(streamKey, { + logStream: retVal.logStream, + k3sStreamRequest: retVal.k3sStreamRequest + }); - console.log(`[CONNECTED] Client ${socket.id} joined log stream for ${streamKey}`); - }); + console.log(`[JOIN] Client ${socket.id} joined log stream for ${streamKey}`); + })); - socket.on('leavePodLog', () => { - // Über alle Räume iterieren, die dieser Socket abonniert hat - logStream?.end(); - k3sStreamRequest?.abort(); + socket.on('leavePodLog', (data) => { + const streamKey = data?.streamKey; + const socketInfo = socketStreams.get(streamKey); + socketStreams.delete(streamKey); + socketInfo?.logStream?.end(); + socketInfo?.k3sStreamRequest?.abort(); console.log(`[LEAVE] Client ${socket.id} left log stream for ${streamKey}`); }); socket.on('disconnect', () => { - // Über alle Räume iterieren, die dieser Socket abonniert hat - logStream?.end(); - k3sStreamRequest?.abort(); - console.log(`[DISCONNECTED] Client ${socket.id} disconnected log stream for ${streamKey}`); + const streamKeys = Array.from(socketStreams.keys()); + for (const [streamKey, socketInfo] of Array.from(socketStreams.entries())) { + socketInfo?.logStream?.end(); + socketInfo?.k3sStreamRequest?.abort(); + } + socketStreams.clear(); + console.log(`[DISCONNECTED] Client ${socket.id} disconnected log stream for ${streamKeys}`); }); } @@ -53,11 +77,23 @@ class LogStreamService { let k3sStreamRequest = await k3s.log.log(namespace, pod.podName, pod.containerName, logStream, { follow: true, - pretty: false, - tailLines: 100, + tailLines: namespace === buildNamespace ? undefined : 100, + previous: false, + timestamps: true, + pretty: false }); return { logStream, k3sStreamRequest }; } + + private async streamWrapper(socket: Socket, + func: () => Promise) { + try { + return await func(); + } catch (ex) { + console.error(ex); + socket.emit('error', (ex as Error)?.message ?? 'An unknown error occurred.'); + } + } } const logService = new LogStreamService();