From 45aaecc5344d1b009304ac926461b47163afb64e Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Thu, 31 Oct 2024 16:32:54 +0000 Subject: [PATCH] build tab updates periodically --- src/app/project/app/[tabName]/app-tabs.tsx | 6 +- .../project/app/[tabName]/overview/actions.ts | 26 ++++++ .../[tabName]/overview/build-status-badge.tsx | 75 +++++++++++++++ .../app/[tabName]/overview/builds-tab.tsx | 93 +++++++++++++++---- src/app/project/app/[tabName]/page.tsx | 3 +- src/components/custom/confirm-dialog.tsx | 2 +- src/model/build-job.ts | 5 +- src/server/services/build.service.ts | 26 +++++- 8 files changed, 205 insertions(+), 31 deletions(-) create mode 100644 src/app/project/app/[tabName]/overview/actions.ts create mode 100644 src/app/project/app/[tabName]/overview/build-status-badge.tsx diff --git a/src/app/project/app/[tabName]/app-tabs.tsx b/src/app/project/app/[tabName]/app-tabs.tsx index d34959d..b9ec836 100644 --- a/src/app/project/app/[tabName]/app-tabs.tsx +++ b/src/app/project/app/[tabName]/app-tabs.tsx @@ -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({ Storage - + diff --git a/src/app/project/app/[tabName]/overview/actions.ts b/src/app/project/app/[tabName]/overview/actions.ts new file mode 100644 index 0000000..888ae3f --- /dev/null +++ b/src/app/project/app/[tabName]/overview/actions.ts @@ -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>; + +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>; \ No newline at end of file diff --git a/src/app/project/app/[tabName]/overview/build-status-badge.tsx b/src/app/project/app/[tabName]/overview/build-status-badge.tsx new file mode 100644 index 0000000..d796968 --- /dev/null +++ b/src/app/project/app/[tabName]/overview/build-status-badge.tsx @@ -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 (<> + {getTextForStatus(children)} + + ) +} + +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'; + } +} \ No newline at end of file diff --git a/src/app/project/app/[tabName]/overview/builds-tab.tsx b/src/app/project/app/[tabName]/overview/builds-tab.tsx index 961aeb7..1494473 100644 --- a/src/app/project/app/[tabName]/overview/builds-tab.tsx +++ b/src/app/project/app/[tabName]/overview/builds-tab.tsx @@ -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(undefined); + const [error, setError] = useState(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({ This is an overview of the last container builds for this App. - formatDateTime(item.startTime)], - ]} - data={appBuilds} - hideSearchBar={true} - actionCol={(item) => - <> -
-
-
TODO BUTTON
-
- } - /> - + {!appBuilds ? : + {item.status}], + ["startTime", "Started At", true, (item) => formatDateTime(item.startTime)], + ]} + data={appBuilds} + hideSearchBar={true} + actionCol={(item) => { + // todo add: <>{ ?} + return <> +
+
+ + {item.status === 'RUNNING' && } +
+ + }} + /> + }
; diff --git a/src/app/project/app/[tabName]/page.tsx b/src/app/project/app/[tabName]/page.tsx index 35cb8ee..9233db9 100644 --- a/src/app/project/app/[tabName]/page.tsx +++ b/src/app/project/app/[tabName]/page.tsx @@ -25,7 +25,6 @@ export default async function AppPage({ return

Could not find app with id {appId}

} const app = await appService.getExtendedById(appId); - const builds = await buildService.getBuildsForApp(appId); return (
@@ -49,7 +48,7 @@ export default async function AppPage({ subtitle={`App ID: ${app.id}`}> - +
) } diff --git a/src/components/custom/confirm-dialog.tsx b/src/components/custom/confirm-dialog.tsx index 0224f76..8f2b34e 100644 --- a/src/components/custom/confirm-dialog.tsx +++ b/src/components/custom/confirm-dialog.tsx @@ -29,7 +29,7 @@ export function ConfirmDialog() { - + diff --git a/src/model/build-job.ts b/src/model/build-job.ts index 96915d3..544ad66 100644 --- a/src/model/build-job.ts +++ b/src/model/build-job.ts @@ -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; +export type BuildJobStatus = z.infer; diff --git a/src/server/services/build.service.ts b/src/server/services/build.service.ts index d68f836..cff2aad 100644 --- a/src/server/services/build.service.ts +++ b/src/server/services/build.service.ts @@ -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) {