mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-04-27 21:10:35 -05:00
updated build overview to deployment overview
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import appService from "@/server/services/app.service";
|
||||
import deploymentService from "@/server/services/deployment.service";
|
||||
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
|
||||
|
||||
@@ -9,3 +10,10 @@ export const deploy = async (appId: string) =>
|
||||
await getAuthUserSession();
|
||||
await appService.buildAndDeploy(appId);
|
||||
});
|
||||
|
||||
export const test = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
const app = await appService.getExtendedById(appId);
|
||||
await deploymentService.getDeploymentHistory(app.projectId, app.id);
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { deploy } from "./action";
|
||||
import { deploy, test } from "./action";
|
||||
import { AppExtendedModel } from "@/model/app-extended.model";
|
||||
|
||||
export default function AppActionButtons({
|
||||
@@ -14,7 +14,7 @@ export default function AppActionButtons({
|
||||
return <Card>
|
||||
<CardContent className="p-4 flex gap-4">
|
||||
<Button onClick={() => deploy(app.id)}>Deploy</Button>
|
||||
<Button variant="secondary">Start</Button>
|
||||
<Button onClick={() => test(app.id)} variant="secondary">Start</Button>
|
||||
<Button variant="secondary">Rebuild</Button>
|
||||
</CardContent>
|
||||
</Card >;
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
'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 { DeploymentInfoModel } from "@/model/deployment";
|
||||
import { ServerActionResult, SuccessActionResult } from "@/model/server-action-error-return.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import buildService from "@/server/services/build.service";
|
||||
import userService from "@/server/services/user.service";
|
||||
import deploymentService from "@/server/services/deployment.service";
|
||||
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
|
||||
|
||||
export const getBuildsForApp = async (appId: string) =>
|
||||
export const getDeploymentsAndBuildsForApp = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
return await buildService.getBuildsForApp(appId);
|
||||
}) as Promise<ServerActionResult<unknown, BuildJobModel[]>>;
|
||||
const app = await appService.getExtendedById(appId);
|
||||
return await deploymentService.getDeploymentHistory(app.projectId, appId);
|
||||
}) as Promise<ServerActionResult<unknown, DeploymentInfoModel[]>>;
|
||||
|
||||
export const deleteBuild = async (buildName: string) =>
|
||||
simpleAction(async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { deleteBuild, getDeploymentsAndBuildsForApp } from "./actions";
|
||||
import { set } from "date-fns";
|
||||
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
|
||||
import { Item } from "@radix-ui/react-dropdown-menu";
|
||||
@@ -13,6 +13,8 @@ import BuildStatusBadge from "./build-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfirmDialog } from "@/lib/zustand.states";
|
||||
import { Toast } from "@/lib/toast.utils";
|
||||
import { DeploymentInfoModel } from "@/model/deployment";
|
||||
import DeploymentStatusBadge from "./deployment-status-badge";
|
||||
|
||||
export default function BuildsTab({
|
||||
app
|
||||
@@ -21,13 +23,13 @@ export default function BuildsTab({
|
||||
}) {
|
||||
|
||||
const { openDialog } = useConfirmDialog();
|
||||
const [appBuilds, setAppBuilds] = useState<BuildJobModel[] | undefined>(undefined);
|
||||
const [appBuilds, setAppBuilds] = useState<DeploymentInfoModel[] | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const updateBuilds = async () => {
|
||||
setError(undefined);
|
||||
try {
|
||||
const response = await getBuildsForApp(app.id);
|
||||
const response = await getDeploymentsAndBuildsForApp(app.id);
|
||||
if (response.status === 'success' && response.data) {
|
||||
setAppBuilds(response.data);
|
||||
} else {
|
||||
@@ -70,25 +72,25 @@ export default function BuildsTab({
|
||||
return <>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Container Builds</CardTitle>
|
||||
<CardDescription>This is an overview of the last container builds for this App.</CardDescription>
|
||||
<CardTitle>Deployments</CardTitle>
|
||||
<CardDescription>This is an overview of the last deplyoments for this App.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!appBuilds ? <FullLoadingSpinner /> :
|
||||
<SimpleDataTable columns={[
|
||||
['name', 'Name', false],
|
||||
['status', 'Status', true, (item) => <BuildStatusBadge>{item.status}</BuildStatusBadge>],
|
||||
["startTime", "Started At", true, (item) => formatDateTime(item.startTime)],
|
||||
['replicasetName', 'Deployment Name', false],
|
||||
['buildJobName', 'Build Job Name', false],
|
||||
['status', 'Status', true, (item) => <DeploymentStatusBadge>{item.status}</DeploymentStatusBadge>],
|
||||
["startTime", "Started At", true, (item) => formatDateTime(item.createdAt)],
|
||||
]}
|
||||
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>}
|
||||
{item.buildJobName && <Button variant="secondary">Show Logs</Button>}
|
||||
{item.buildJobName && item.status === 'BUILDING' && <Button variant="destructive" onClick={() => deleteBuildClick(item.buildJobName!)}>Stop Build</Button>}
|
||||
</div>
|
||||
</>
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { DeplyomentStatus } from "@/model/deployment";
|
||||
|
||||
|
||||
export default function DeploymentStatusBadge(
|
||||
{
|
||||
children
|
||||
}: {
|
||||
children: DeplyomentStatus
|
||||
}
|
||||
) {
|
||||
|
||||
return (<>
|
||||
<span className={'px-2 py-1 rounded-lg text-sm font-semibold ' + getBackgroundColorForStatus(children) + ' ' + getTextColorForStatus(children)}>{getTextForStatus(children)}</span>
|
||||
</>)
|
||||
}
|
||||
|
||||
function getTextForStatus(status: DeplyomentStatus) {
|
||||
switch (status) {
|
||||
case 'SHUTDOWN':
|
||||
return 'Shutdown';
|
||||
case 'BUILDING':
|
||||
return 'Building';
|
||||
case 'ERROR':
|
||||
return 'Error';
|
||||
case 'DEPLOYING':
|
||||
return 'Deploying';
|
||||
case 'DEPLOYED':
|
||||
return 'Deployed';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getBackgroundColorForStatus(status: DeplyomentStatus) {
|
||||
switch (status) {
|
||||
|
||||
case 'SHUTDOWN':
|
||||
return 'bg-slate-100';
|
||||
case 'ERROR':
|
||||
return 'bg-red-100';
|
||||
case 'BUILDING':
|
||||
return 'bg-blue-100';
|
||||
case 'DEPLOYING':
|
||||
return 'bg-blue-100';
|
||||
case 'DEPLOYED':
|
||||
return 'bg-green-100';
|
||||
default:
|
||||
return 'bg-slate-100';
|
||||
}
|
||||
}
|
||||
|
||||
function getTextColorForStatus(status: DeplyomentStatus) {
|
||||
switch (status) {
|
||||
|
||||
case 'SHUTDOWN':
|
||||
return 'text-slate-800';
|
||||
case 'ERROR':
|
||||
return 'text-red-800';
|
||||
case 'BUILDING':
|
||||
return 'text-blue-800';
|
||||
case 'DEPLOYING':
|
||||
return 'text-blue-800';
|
||||
case 'DEPLOYED':
|
||||
return 'text-green-800';
|
||||
default:
|
||||
return 'text-slate-800';
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,5 @@ import { redirect } from "next/navigation";
|
||||
// redirects to default route "general" for the app
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
redirect(`/project/app/general?appId=${url.searchParams.get("appId")}`);
|
||||
redirect(`/project/app/overview?appId=${url.searchParams.get("appId")}`);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const deploymentStatusEnumZod = z.union([
|
||||
z.literal('UNKNOWN'),
|
||||
z.literal('BUILDING'),
|
||||
z.literal('ERROR'),
|
||||
z.literal('DEPLOYED'),
|
||||
z.literal('DEPLOYING'),
|
||||
z.literal('SHUTDOWN'),
|
||||
]);
|
||||
|
||||
export const deploymentInfoZodModel = z.object({
|
||||
replicasetName: z.string().optional(),
|
||||
buildJobName: z.string().optional(),
|
||||
createdAt: z.date(),
|
||||
status: deploymentStatusEnumZod
|
||||
});
|
||||
|
||||
export type DeploymentInfoModel = z.infer<typeof deploymentInfoZodModel>;
|
||||
export type DeplyomentStatus = z.infer<typeof deploymentStatusEnumZod>;
|
||||
|
||||
|
||||
@@ -15,8 +15,12 @@ class AppService {
|
||||
const app = await this.getExtendedById(appId);
|
||||
if (app.sourceType === 'GIT') {
|
||||
// first make build
|
||||
await deploymentService.createNamespaceIfNotExists(buildNamespace)
|
||||
await buildService.buildApp(app);
|
||||
await deploymentService.createNamespaceIfNotExists(buildNamespace);
|
||||
const [buildJobName, buildPromise] = await buildService.buildApp(app);
|
||||
buildPromise.then(async () => {
|
||||
console.warn('Build job finished, deploying...');
|
||||
await deploymentService.createDeployment(app, buildJobName);
|
||||
});
|
||||
} else {
|
||||
// only deploy
|
||||
await deploymentService.createDeployment(app);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppExtendedModel } from "@/model/app-extended.model";
|
||||
import k3s from "../adapter/kubernetes-api.adapter";
|
||||
import { V1Deployment, V1Job, V1JobStatus } from "@kubernetes/client-node";
|
||||
import { V1Job, V1JobStatus } from "@kubernetes/client-node";
|
||||
import { StringUtils } from "../utils/string.utils";
|
||||
import { revalidateTag, unstable_cache } from "next/cache";
|
||||
import { Tags } from "../utils/cache-tag-generator.utils";
|
||||
@@ -13,7 +13,7 @@ export const buildNamespace = "registry-and-build";
|
||||
|
||||
class BuildService {
|
||||
|
||||
async buildApp(app: AppExtendedModel) {
|
||||
async buildApp(app: AppExtendedModel): Promise<[string, Promise<void>]> {
|
||||
|
||||
const runningJobsForApp = await this.getBuildsForApp(app.id);
|
||||
if (runningJobsForApp.some((job) => job.status === 'RUNNING')) {
|
||||
@@ -38,7 +38,7 @@ class BuildService {
|
||||
image: kanikoImage,
|
||||
args: [`--dockerfile=${app.dockerfilePath}`,
|
||||
`--context=${app.gitUrl!.replace("https://", "git://")}#refs/heads/${app.gitBranch}`,
|
||||
`--destination=${registryURL}/${app.id}`]
|
||||
`--destination=${this.createContainerRegistryUrlForAppId(app.id)}`]
|
||||
},
|
||||
],
|
||||
restartPolicy: "Never",
|
||||
@@ -62,11 +62,17 @@ class BuildService {
|
||||
}
|
||||
await k3s.batch.createNamespacedJob(buildNamespace, jobDefinition);
|
||||
revalidateTag(Tags.appBuilds(app.id));
|
||||
this.waitForJobCompletion(jobDefinition.metadata!.name!)
|
||||
.then(() => revalidateTag(Tags.appBuilds(app.id)))
|
||||
.catch((err) => revalidateTag(Tags.appBuilds(app.id)));
|
||||
|
||||
const buildJobPromise = this.waitForJobCompletion(jobDefinition.metadata!.name!)
|
||||
|
||||
return [buildName, buildJobPromise];
|
||||
}
|
||||
|
||||
createContainerRegistryUrlForAppId(appId?: string) {
|
||||
if (!appId) {
|
||||
return undefined;
|
||||
}
|
||||
return `${registryURL}/${appId}:latest`;
|
||||
}
|
||||
|
||||
async deleteBuild(buildName: string) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { AppExtendedModel } from "@/model/app-extended.model";
|
||||
import k3s from "../adapter/kubernetes-api.adapter";
|
||||
import { V1Deployment } from "@kubernetes/client-node";
|
||||
import buildService from "./build.service";
|
||||
import { ListUtils } from "../utils/list.utils";
|
||||
import { DeploymentInfoModel, DeplyomentStatus } from "@/model/deployment";
|
||||
import { BuildJobStatus } from "@/model/build-job";
|
||||
|
||||
class DeploymentService {
|
||||
|
||||
@@ -81,15 +85,17 @@ class DeploymentService {
|
||||
}
|
||||
}
|
||||
|
||||
async createDeployment(app: AppExtendedModel) {
|
||||
async createDeployment(app: AppExtendedModel, buildJobName?: string) {
|
||||
await this.createNamespaceIfNotExists(app.projectId);
|
||||
|
||||
const existingDeployment = await this.getDeployment(app.projectId, app.id);
|
||||
const body: V1Deployment = {
|
||||
metadata: {
|
||||
name: app.id
|
||||
name: app.id,
|
||||
|
||||
},
|
||||
spec: {
|
||||
// strategy: 'rollingUpdate',
|
||||
replicas: app.replicas,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
@@ -100,13 +106,18 @@ class DeploymentService {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: app.id
|
||||
},
|
||||
annotations: {
|
||||
deploymentTimestamp: new Date().getTime() + "",
|
||||
"kubernetes.io/change-cause": `Deployment ${new Date().toISOString()}`
|
||||
}
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: app.id,
|
||||
image: app.containerImageSource as string,
|
||||
image: !!buildJobName ? buildService.createContainerRegistryUrlForAppId(app.id) : app.containerImageSource as string,
|
||||
imagePullPolicy: 'Always',
|
||||
/*ports: [
|
||||
{
|
||||
containerPort: app.port
|
||||
@@ -118,6 +129,9 @@ class DeploymentService {
|
||||
}
|
||||
}
|
||||
};
|
||||
if (buildJobName) {
|
||||
body.spec!.template!.metadata!.annotations!.buildJobName = buildJobName; // add buildJobName to deployment
|
||||
}
|
||||
if (existingDeployment) {
|
||||
const res = await k3s.apps.replaceNamespacedDeployment(app.id, app.projectId, body);
|
||||
} else {
|
||||
@@ -144,6 +158,80 @@ class DeploymentService {
|
||||
await k3s.core.deleteNamespace(namespace);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Searches for Build Jobs (only for Git Projects) and ReplicaSets (for all projects) and returns a list of DeploymentModel
|
||||
* Build are only included if they are in status RUNNING, FAILED or UNKNOWN. SUCCESSFUL builds are not included because they are already part of the ReplicaSet history.
|
||||
* @param projectId
|
||||
* @param appId
|
||||
* @returns
|
||||
*/
|
||||
async getDeploymentHistory(projectId: string, appId: string): Promise<DeploymentInfoModel[]> {
|
||||
const replicasetRevisions = await this.getReplicasetRevisionHistory(projectId, appId);
|
||||
const builds = await buildService.getBuildsForApp(appId);
|
||||
const runningOrFailedBuilds = builds
|
||||
.filter((build) => ['RUNNING', 'FAILED', 'UNKNOWN'].includes(build.status))
|
||||
.map((build) => {
|
||||
return {
|
||||
replicasetName: undefined,
|
||||
createdAt: build.startTime!,
|
||||
buildJobName: build.name!,
|
||||
status: this.mapBuildStatusToDeploymentStatus(build.status)
|
||||
}
|
||||
});
|
||||
replicasetRevisions.push(...runningOrFailedBuilds);
|
||||
return ListUtils.sortByDate(replicasetRevisions, (i) => i.createdAt!, true);
|
||||
}
|
||||
|
||||
mapBuildStatusToDeploymentStatus(buildJobStatus?: BuildJobStatus) {
|
||||
const map = new Map<BuildJobStatus, DeplyomentStatus>([
|
||||
['UNKNOWN', 'UNKNOWN'],
|
||||
['RUNNING', 'BUILDING'],
|
||||
['FAILED', 'ERROR']
|
||||
]);
|
||||
return map.get(buildJobStatus ?? 'UNKNOWN') ?? 'UNKNOWN';
|
||||
}
|
||||
|
||||
|
||||
async getReplicasetRevisionHistory(projectId: string, appId: string): Promise<DeploymentInfoModel[]> {
|
||||
|
||||
const deployment = await this.getDeployment(projectId, appId);
|
||||
if (!deployment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// List ReplicaSets in the namespace to find those associated with the deployment
|
||||
const replicaSetsForDeployment = await k3s.apps.listNamespacedReplicaSet(projectId, undefined, undefined, undefined, undefined, `app=${appId}`);
|
||||
|
||||
const revisions = replicaSetsForDeployment.body.items.map((rs, index) => {
|
||||
|
||||
let status = 'UNKNOWN' as DeplyomentStatus;
|
||||
if (rs.status?.replicas === 0) {
|
||||
status = 'SHUTDOWN';
|
||||
} else if (rs.status?.replicas === rs.status?.readyReplicas) {
|
||||
status = 'DEPLOYED';
|
||||
} else if (rs.status?.replicas !== rs.status?.readyReplicas) {
|
||||
status = 'DEPLOYING';
|
||||
}
|
||||
/*
|
||||
Fields for Status:
|
||||
availableReplicas: 1,
|
||||
conditions: undefined,
|
||||
fullyLabeledReplicas: 1,
|
||||
observedGeneration: 3,
|
||||
readyReplicas: 1,
|
||||
replicas: 1
|
||||
*/
|
||||
return {
|
||||
replicasetName: rs.metadata?.name!,
|
||||
createdAt: rs.metadata?.creationTimestamp!,
|
||||
buildJobName: rs.metadata?.annotations?.buildJobName!,
|
||||
status: status
|
||||
}
|
||||
});
|
||||
return ListUtils.sortByDate(revisions, (i) => i.createdAt!, true);
|
||||
}
|
||||
}
|
||||
|
||||
const deploymentService = new DeploymentService();
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
export class ListUtils {
|
||||
|
||||
static sortByDate<T>(array: T[], dateSelector: (item: T) => Date, descending = false): T[] {
|
||||
return array.toSorted((a, b) => {
|
||||
const dateA = dateSelector(a);
|
||||
const dateB = dateSelector(b);
|
||||
return descending ? dateB.getTime() - dateA.getTime() : dateA.getTime() - dateB.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
static distinctBy<T, TKey>(array: T[], keySelector: (item: T) => TKey): T[] {
|
||||
const keys = new Set<TKey>();
|
||||
const result = new Array<T>();
|
||||
|
||||
Reference in New Issue
Block a user