mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-01-09 13:09:53 -06:00
added logs for builds
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)} />
|
||||
</>;
|
||||
}
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user