mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-17 10:19:18 -06:00
build tab updates periodically
This commit is contained in:
@@ -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} />
|
||||
|
||||
26
src/app/project/app/[tabName]/overview/actions.ts
Normal file
26
src/app/project/app/[tabName]/overview/actions.ts
Normal 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>>;
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 >
|
||||
</>;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user