added logs for builds

This commit is contained in:
biersoeckli
2024-11-02 13:57:53 +00:00
parent 8faeb78c1b
commit c804cd4bdf
7 changed files with 156 additions and 39 deletions

View File

@@ -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({

View File

@@ -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 (
<Dialog open={!!deploymentInfo} onOpenChange={(isO) => {
podLogsSocket.emit('leavePodLog', { streamKey: deploymentInfo.buildJobName });
onClose();
}}>
<DialogContent className="w-[70%]">
<DialogHeader>
<DialogTitle>Build Logs</DialogTitle>
<DialogDescription>
View the build logs for the selected deployment {formatDate(deploymentInfo.createdAt)}.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{!deploymentInfo.buildJobName && 'For this build is no log available'}
{deploymentInfo.buildJobName && <LogsStreamed buildJobName={deploymentInfo.buildJobName} />}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -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<DeploymentInfoModel[] | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const [selectedDeploymentForLogs, setSelectedDeploymentForLogs] = useState<DeploymentInfoModel | undefined>(undefined);
const updateBuilds = async () => {
setError(undefined);
@@ -90,7 +92,7 @@ export default function BuildsTab({
return <>
<div className="flex gap-4">
<div className="flex-1"></div>
{item.buildJobName && <Button variant="secondary">Show Logs</Button>}
{item.buildJobName && <Button variant="secondary" onClick={() => setSelectedDeploymentForLogs(item)}>Show Logs</Button>}
{item.buildJobName && item.status === 'BUILDING' && <Button variant="destructive" onClick={() => deleteBuildClick(item.buildJobName!)}>Stop Build</Button>}
</div>
</>
@@ -98,6 +100,7 @@ export default function BuildsTab({
/>
}
</CardContent>
</Card >
</Card>
<BuildLogsDialog deploymentInfo={selectedDeploymentForLogs} onClose={() => setSelectedDeploymentForLogs(undefined)} />
</>;
}

View File

@@ -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<string>('');
const textAreaRef = useRef<HTMLTextAreaElement>(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 <>
<Textarea value={logs} readOnly className="h-[400px] bg-slate-900 text-white" />
<Textarea ref={textAreaRef} value={logs} readOnly className="h-[400px] bg-slate-900 text-white" />
<div className="text-sm pl-1">Status: {isConnected ? 'Connected' : 'Disconnected'}</div>
</>;
}

View File

@@ -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<void>((resolve, reject) => {

View File

@@ -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<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>) {
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<string, socketStreamsBody>();
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<T>(socket: Socket<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>,
func: () => Promise<T>) {
try {
return await func();
} catch (ex) {
console.error(ex);
socket.emit('error', (ex as Error)?.message ?? 'An unknown error occurred.');
}
}
}
const logService = new LogStreamService();