build tab updates periodically

This commit is contained in:
biersoeckli
2024-10-31 16:32:54 +00:00
parent 6df636ce21
commit 45aaecc534
8 changed files with 205 additions and 31 deletions

View File

@@ -14,12 +14,10 @@ import BuildsTab from "./overview/builds-tab";
export default function AppTabs({
app,
tabName,
appBuilds
tabName
}: {
app: AppExtendedModel;
tabName: string;
appBuilds: BuildJobModel[];
}) {
const router = useRouter();
@@ -37,7 +35,7 @@ export default function AppTabs({
<TabsTrigger value="storage">Storage</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<BuildsTab app={app} appBuilds={appBuilds} />
<BuildsTab app={app} />
</TabsContent>
<TabsContent value="general" className="space-y-4">
<GeneralAppSource app={app} />

View File

@@ -0,0 +1,26 @@
'use server'
import { AppRateLimitsModel, appRateLimitsZodModel } from "@/model/app-rate-limits.model";
import { appSourceInfoContainerZodModel, appSourceInfoGitZodModel, AppSourceInfoInputModel, appSourceInfoInputZodModel } from "@/model/app-source-info.model";
import { AuthFormInputSchema, authFormInputSchemaZod } from "@/model/auth-form";
import { BuildJobModel } from "@/model/build-job";
import { ErrorActionResult, ServerActionResult, SuccessActionResult } from "@/model/server-action-error-return.model";
import { ServiceException } from "@/model/service.exception.model";
import appService from "@/server/services/app.service";
import buildService from "@/server/services/build.service";
import userService from "@/server/services/user.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
export const getBuildsForApp = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
return await buildService.getBuildsForApp(appId);
}) as Promise<ServerActionResult<unknown, BuildJobModel[]>>;
export const deleteBuild = async (buildName: string) =>
simpleAction(async () => {
await getAuthUserSession();
await buildService.deleteBuild(buildName);
return new SuccessActionResult(undefined, 'Successfully stopped and deleted build.');
}) as Promise<ServerActionResult<unknown, void>>;

View File

@@ -0,0 +1,75 @@
'use client'
import { FieldValues, UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input";
import { isDate } from "date-fns";
import { BuildJobStatus } from "@/model/build-job";
export default function BuildStatusBadge(
{
children
}: {
children: BuildJobStatus
}
) {
return (<>
<span className={'px-2 py-1 rounded-lg text-sm font-semibold ' + getBackgroundColorForStatus(children) + ' ' + getTextColorForStatus(children)}>{getTextForStatus(children)}</span>
</>)
}
function getTextForStatus(status: BuildJobStatus) {
switch (status) {
case 'UNKNOWN':
return 'Unknown';
case 'FAILED':
return 'Failed';
case 'RUNNING':
return 'Running';
case 'SUCCEEDED':
return 'Success';
default:
return 'Unknown';
}
}
function getBackgroundColorForStatus(status: BuildJobStatus) {
switch (status) {
case 'UNKNOWN':
return 'bg-slate-100';
case 'FAILED':
return 'bg-red-100';
case 'RUNNING':
return 'bg-blue-100';
case 'SUCCEEDED':
return 'bg-green-100';
default:
return 'bg-slate-100';
}
}
function getTextColorForStatus(status: BuildJobStatus) {
switch (status) {
case 'UNKNOWN':
return 'text-slate-800';
case 'FAILED':
return 'text-red-800';
case 'RUNNING':
return 'text-blue-800';
case 'SUCCEEDED':
return 'text-green-800';
default:
return 'text-slate-800';
}
}

View File

@@ -1,17 +1,68 @@
import { SimpleDataTable } from "@/components/custom/simple-data-table";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { formatDateTime } from "@/lib/format.utils";
import { AppExtendedModel } from "@/model/app-extended.model";
import { BuildJobModel } from "@/model/build-job";
import { useEffect, useState } from "react";
import { deleteBuild, getBuildsForApp } from "./actions";
import { set } from "date-fns";
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
import { Item } from "@radix-ui/react-dropdown-menu";
import BuildStatusBadge from "./build-status-badge";
import { Button } from "@/components/ui/button";
import { useConfirmDialog } from "@/lib/zustand.states";
import { Toast } from "@/lib/toast.utils";
export default function BuildsTab({
app,
appBuilds
app
}: {
app: AppExtendedModel;
appBuilds: BuildJobModel[];
}) {
const { openDialog } = useConfirmDialog();
const [appBuilds, setAppBuilds] = useState<BuildJobModel[] | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const updateBuilds = async () => {
setError(undefined);
try {
const response = await getBuildsForApp(app.id);
if (response.status === 'success' && response.data) {
setAppBuilds(response.data);
} else {
console.error(response);
setError(response.message ?? 'An unknown error occurred.');
}
} catch (ex) {
console.error(ex);
setError('An unknown error occurred.');
}
}
const deleteBuildClick = async (buildName: string) => {
const confirm = await openDialog({
title: "Delete Build",
description: "The build will be stopped and removed. Are you sure you want to stop this build?",
yesButton: "Stop & Remove Build"
});
if (confirm) {
await Toast.fromAction(() => deleteBuild(buildName));
await updateBuilds();
}
}
useEffect(() => {
if (app.sourceType === 'container') {
return;
}
updateBuilds();
const intervalId = setInterval(updateBuilds, 10000);
return () => clearInterval(intervalId);
}, [app]);
if (app.sourceType === 'container') {
return <></>;
}
@@ -23,22 +74,26 @@ export default function BuildsTab({
<CardDescription>This is an overview of the last container builds for this App.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<SimpleDataTable columns={[
['name', 'Name', false],
['status', 'Status', true],
["startTime", "Started At", true, (item) => formatDateTime(item.startTime)],
]}
data={appBuilds}
hideSearchBar={true}
actionCol={(item) =>
<>
<div className="flex">
<div className="flex-1"></div>
<div>TODO BUTTON</div>
</div>
</>}
/>
{!appBuilds ? <FullLoadingSpinner /> :
<SimpleDataTable columns={[
['name', 'Name', false],
['status', 'Status', true, (item) => <BuildStatusBadge>{item.status}</BuildStatusBadge>],
["startTime", "Started At", true, (item) => formatDateTime(item.startTime)],
]}
data={appBuilds}
hideSearchBar={true}
actionCol={(item) => {
// todo add: <>{ ?}</>
return <>
<div className="flex gap-4">
<div className="flex-1"></div>
<Button variant="secondary">Show Logs</Button>
{item.status === 'RUNNING' && <Button variant="destructive" onClick={() => deleteBuildClick(item.name)}>Stop Build</Button>}
</div>
</>
}}
/>
}
</CardContent>
</Card >
</>;

View File

@@ -25,7 +25,6 @@ export default async function AppPage({
return <p>Could not find app with id {appId}</p>
}
const app = await appService.getExtendedById(appId);
const builds = await buildService.getBuildsForApp(appId);
return (
<div className="flex-1 space-y-6 p-8 pt-6">
@@ -49,7 +48,7 @@ export default async function AppPage({
subtitle={`App ID: ${app.id}`}>
</PageTitle>
<AppActionButtons app={app} />
<AppTabs app={app} appBuilds={builds} tabName={params.tabName} />
<AppTabs app={app} tabName={params.tabName} />
</div>
)
}

View File

@@ -29,7 +29,7 @@ export function ConfirmDialog() {
</DialogHeader>
<DialogFooter>
<Button onClick={() => closeDialog(true)}>{data.yesButton ?? 'OK'}</Button>
<Button variant="secondary" onClick={() => closeDialog(false)}>{data.yesButton ?? 'Cancel'}</Button>
<Button variant="secondary" onClick={() => closeDialog(false)}>{data.noButton ?? 'Cancel'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,11 +1,14 @@
import { z } from "zod";
export const buildJobStatusEnumZod = z.union([z.literal('UNKNOWN'), z.literal('RUNNING'), z.literal('FAILED'), z.literal('SUCCEEDED')]);
export const buildJobSchemaZod = z.object({
name: z.string(),
startTime: z.date(),
status: z.union([z.literal('UNKNOWN'), z.literal('RUNNING'), z.literal('FAILED'), z.literal('SUCCEEDED')]),
status: buildJobStatusEnumZod
});
export type BuildJobModel = z.infer<typeof buildJobSchemaZod>;
export type BuildJobStatus = z.infer<typeof buildJobStatusEnumZod>;

View File

@@ -5,6 +5,7 @@ import { StringUtils } from "../utils/string.utils";
import { revalidateTag, unstable_cache } from "next/cache";
import { Tags } from "../utils/cache-tag-generator.utils";
import { BuildJobModel } from "@/model/build-job";
import { ServiceException } from "@/model/service.exception.model";
const kanikoImage = "gcr.io/kaniko-project/executor:latest";
export const registryURL = "registry-svc.registry-and-build.svc.cluster.local"
@@ -13,6 +14,12 @@ export const buildNamespace = "registry-and-build";
class BuildService {
async buildApp(app: AppExtendedModel) {
const runningJobsForApp = await this.getBuildsForApp(app.id);
if (runningJobsForApp.some((job) => job.status === 'RUNNING')) {
throw new ServiceException("A build job is already running for this app.");
}
const buildName = StringUtils.addRandomSuffix(StringUtils.toJobName(app.id));
const jobDefinition: V1Job = {
apiVersion: "batch/v1",
@@ -59,7 +66,11 @@ class BuildService {
.then(() => revalidateTag(Tags.appBuilds(app.id)))
.catch((err) => revalidateTag(Tags.appBuilds(app.id)));
}
async deleteBuild(buildName: string) {
await k3s.batch.deleteNamespacedJob(buildName, buildNamespace);
}
async getBuildsForApp(appId: string) {
@@ -67,13 +78,20 @@ class BuildService {
const jobNamePrefix = StringUtils.toJobName(appId);
const jobs = await k3s.batch.listNamespacedJob(buildNamespace);
const jobsOfBuild = jobs.body.items.filter((job) => job.metadata?.name?.startsWith(jobNamePrefix));
return jobsOfBuild.map((job) => {
const builds = jobsOfBuild.map((job) => {
return {
name: job.metadata?.name,
startTime: job.status?.startTime,
status: this.getJobStatusString(job.status),
} as BuildJobModel;
});
builds.sort((a, b) => {
if (a.startTime && b.startTime) {
return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
}
return 0;
});
return builds;
},
[Tags.appBuilds(appId)], {
tags: [Tags.appBuilds(appId)],
@@ -112,9 +130,9 @@ class BuildService {
});
}
async getJobStatus(jobName: string): Promise<'UNKNOWN' | 'RUNNING' | 'FAILED' | 'SUCCEEDED'> {
async getJobStatus(buildName: string): Promise<'UNKNOWN' | 'RUNNING' | 'FAILED' | 'SUCCEEDED'> {
try {
const response = await k3s.batch.readNamespacedJobStatus(jobName, buildNamespace);
const response = await k3s.batch.readNamespacedJobStatus(buildName, buildNamespace);
const status = response.body.status;
return this.getJobStatusString(status);
} catch (err) {