diff --git a/package.json b/package.json index bbfd127..8ad4852 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "extensionless": "^1.9.6", "framer-motion": "^11.0.3", "ipaddr.js": "^2.1.0", - "jsondiffpatch": "^0.6.0", + "json-diff-ts": "^4.0.1", "lucide-react": "^0.298.0", "next": "14.0.4", "next-nprogress-bar": "^2.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 001d7e1..d2597b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,9 +134,9 @@ dependencies: ipaddr.js: specifier: ^2.1.0 version: 2.1.0 - jsondiffpatch: - specifier: ^0.6.0 - version: 0.6.0 + json-diff-ts: + specifier: ^4.0.1 + version: 4.0.1 lucide-react: specifier: ^0.298.0 version: 0.298.0(react@18.2.0) @@ -2883,10 +2883,6 @@ packages: resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} dev: false - /@types/diff-match-patch@1.0.36: - resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} - dev: false - /@types/docker-modem@3.0.6: resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} dependencies: @@ -4321,10 +4317,6 @@ packages: /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - /diff-match-patch@1.0.5: - resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} - dev: false - /difflib@0.2.4: resolution: {integrity: sha512-9YVwmMb0wQHQNr5J9m6BSj6fk4pfGITGQOOs+D9Fl+INODWFOfvhIU1hNv6GgR1RBoC/9NJcwu77zShxV0kT7w==} dependencies: @@ -6070,6 +6062,12 @@ packages: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} dev: true + /json-diff-ts@4.0.1: + resolution: {integrity: sha512-FEuq+gv4DXI+nL7oF/trBQIBQYDVcJe5Kqe3mYUZAkDtHBY/cdWmVZpV9BLZFp+wThMClajYp0fmNVmB81FsmA==} + dependencies: + lodash: 4.17.21 + dev: false + /json-diff@0.9.0: resolution: {integrity: sha512-cVnggDrVkAAA3OvFfHpFEhOnmcsUpleEKq4d4O8sQWWSH40MBrWstKigVB1kGrgLWzuom+7rRdaCsnBD6VyObQ==} hasBin: true @@ -6119,16 +6117,6 @@ packages: minimist: 1.2.8 dev: true - /jsondiffpatch@0.6.0: - resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - dependencies: - '@types/diff-match-patch': 1.0.36 - chalk: 5.3.0 - diff-match-patch: 1.0.5 - dev: false - /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} diff --git a/src/app/(dashboard)/project/[projectId]/_components/DeployChanges.tsx b/src/app/(dashboard)/project/[projectId]/_components/DeployChanges.tsx index 489cdc0..833b2be 100644 --- a/src/app/(dashboard)/project/[projectId]/_components/DeployChanges.tsx +++ b/src/app/(dashboard)/project/[projectId]/_components/DeployChanges.tsx @@ -5,6 +5,16 @@ import { toast } from "sonner"; import { Button } from "~/components/ui/button"; import { api } from "~/trpc/react"; import { useProject } from "../_context/ProjectContext"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { LoadingSpinner } from "~/components/LoadingSpinner"; +import { ServiceDiff } from "~/components/service/ServiceDiff"; export function DeployChanges() { const project = useProject(); @@ -14,18 +24,39 @@ export function DeployChanges() { }, }); + const diff = api.projects.deployDiff.useQuery({ + projectId: project.id, + }); + return ( - + + + + + + + Deployment Confirmation + + + {diff.isFetching && } + + {diff.data?.map(({ service, diff }) => ( +
+

{service.name}

+
+                
+              
+
+ ))} +
+
+
); } diff --git a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/deployments/_components/DeploymentLogs.tsx b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/deployments/_components/DeploymentLogs.tsx index cd3201a..b7f503d 100644 --- a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/deployments/_components/DeploymentLogs.tsx +++ b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/deployments/_components/DeploymentLogs.tsx @@ -1,14 +1,18 @@ "use client"; import { api } from "~/trpc/react"; -import { RouterOutputs } from "~/trpc/shared"; -import { useService } from "../../_hooks/service"; import { useProject } from "../../../../_context/ProjectContext"; import { useState } from "react"; import { toast } from "sonner"; -import { Drawer, DrawerContent } from "~/components/ui/drawer"; import { LogWindow, type LogLine } from "~/components/LogWindow"; import { StringParam, useQueryParam } from "use-query-params"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; export function DeploymentLogs() { const project = useProject(); @@ -39,15 +43,25 @@ export function DeploymentLogs() { ); return ( - setDeploymentId(null)}> - -
-

Logs

-
+ { + if (!ev) { + setLogs(null); + setDeploymentId(undefined); + } + }} + > + + + Deployment Logs + + +
{logs && }
-
- - + + + ); } diff --git a/src/components/LogWindow.tsx b/src/components/LogWindow.tsx index e525ce0..d08188d 100644 --- a/src/components/LogWindow.tsx +++ b/src/components/LogWindow.tsx @@ -1,6 +1,7 @@ "use client"; import Ansi from "ansi-to-react"; +import { useMemo } from "react"; export enum LogLevel { /** @@ -31,22 +32,41 @@ const LOG_LEVEL_TO_CLASS = { [LogLevel.Notice]: "bg-blue-950/40", }; -export function LogWindow({ logs }: { logs: LogLine[] }) { +export function LogWindow({ + logs, + supressStderr, +}: { + logs: LogLine[]; + supressStderr?: boolean; +}) { + const withTimestamp = useMemo(() => logs.some((log) => log.t), [logs]); + return ( -
- {logs.map((log, i) => ( -
-
- {log.t ? new Date(log.t).toLocaleTimeString() : ""} -
-
- {log.m} -
-
- ))} -
+ + + {logs.map((log, i) => ( + + {withTimestamp && ( + + )} + + { + + } + + ))} + +
+ {log.t ? new Date(log.t).toLocaleTimeString() : ""} + + {log.m} +
); } diff --git a/src/components/service/ServiceDiff.tsx b/src/components/service/ServiceDiff.tsx new file mode 100644 index 0000000..b866136 --- /dev/null +++ b/src/components/service/ServiceDiff.tsx @@ -0,0 +1,44 @@ +// import { type RouterOutputs } from "~/trpc/shared"; +import type { IChange } from "json-diff-ts"; +import { Badge } from "../ui/badge"; +import { ArrowDown } from "lucide-react"; + +function Formatted({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function ServiceDiff({ diff }: { diff: IChange[] | IChange }) { + if (Array.isArray(diff)) { + return ( +
+ {diff.map((change, i) => ( + + ))} +
+ ); + } + + return ( +
+
+ {diff.key} + + + Updated + +
+
+ {diff.oldValue} +

Updated to

+ {diff.value} +
+
+ ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..deb4f64 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "~/utils/utils.ts" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/server/api/routers/projects/deploy.ts b/src/server/api/routers/projects/deploy.ts index 6f886ab..23f3194 100644 --- a/src/server/api/routers/projects/deploy.ts +++ b/src/server/api/routers/projects/deploy.ts @@ -28,3 +28,31 @@ export const deployProject = authenticatedProcedure response, }; }); + +export const getDeployDiff = authenticatedProcedure + .meta({ + openapi: { + method: "GET", + path: "/api/projects/:projectId/deploy/diff", + summary: + "Get changes from deployed generation and current undeployed generation.", + }, + }) + .input( + z.object({ + projectId: z.string(), + }), + ) + .use(projectMiddleware) + .query(async ({ ctx }) => { + const services = await ctx.project.getServicesWithPendingUpdates(); + return await Promise.all( + services.map(async (service) => ({ + service: { + name: service.getData().name, + id: service.getData().id, + }, + diff: await service.buildDeployDiff(), + })), + ); + }); diff --git a/src/server/api/routers/projects/index.ts b/src/server/api/routers/projects/index.ts index 11548e2..7c37425 100644 --- a/src/server/api/routers/projects/index.ts +++ b/src/server/api/routers/projects/index.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { projects } from "~/server/db/schema"; import ProjectManager from "~/server/managers/Project"; import { authenticatedProcedure, createTRPCRouter } from "../../trpc"; -import { deployProject } from "./deploy"; +import { deployProject, getDeployDiff } from "./deploy"; import { getProject } from "./project"; import { serviceRouter } from "./service"; @@ -73,4 +73,5 @@ export const projectRouter = createTRPCRouter({ get: getProject, deploy: deployProject, + deployDiff: getDeployDiff, }); diff --git a/src/server/api/routers/projects/service/logs.ts b/src/server/api/routers/projects/service/logs.ts index a2113d5..7c9a8fb 100644 --- a/src/server/api/routers/projects/service/logs.ts +++ b/src/server/api/routers/projects/service/logs.ts @@ -4,12 +4,9 @@ import { serviceMiddleware } from "~/server/api/middleware/service"; import { authenticatedProcedure } from "~/server/api/trpc"; import { observable } from "@trpc/server/observable"; import assert from "node:assert"; -import { docker404ToNull, streamSort } from "~/server/utils/serverUtils"; -import { PassThrough, Transform } from "node:stream"; -import type DockerModem from "docker-modem"; +import { docker404ToNull } from "~/server/utils/serverUtils"; import { LogLevel } from "~/server/build/utils/BuilderLogger"; import type { LogLine } from "~/components/LogWindow"; -import { Queue } from "datastructures-js"; import { Docker } from "~/server/docker/docker"; // trpc doesn't have stream support yet, so websockets it is @@ -48,6 +45,10 @@ export const getDeploymentLogsSubscription = authenticatedProcedure emit.complete(); }); + logs.on("error", (err) => { + emit.error(err); + }); + return () => { abort.abort(); logs.destroy(); @@ -88,18 +89,10 @@ export const getServiceLogsSubscription = authenticatedProcedure "Unable to retrieve service logs. Maybe the service is not running", ); - const unsorted = logs.pipe(Docker.demuxStream()); - const final = new PassThrough(); - - streamSort(unsorted, final, (a, b) => { - const aTime = JSON.parse(a.toString()).t; - const bTime = JSON.parse(b.toString()).t; - - return aTime - bTime; - }); + const final = logs.pipe(Docker.demuxStream()); function cleanup() { - unsorted.end(); + final.end(); } // tail logs now diff --git a/src/server/build/BuildManager.ts b/src/server/build/BuildManager.ts index 3b89c82..c48d3f4 100644 --- a/src/server/build/BuildManager.ts +++ b/src/server/build/BuildManager.ts @@ -35,10 +35,16 @@ export class BuildManager { }); } + /** + * Runs multiple builds in parallel. + * Returns a list of deployment IDs. + */ public async runBuilds( services: FullServiceGeneration[], projectDeploymentId?: string, - ) { + ): Promise { + const deploymentIds: string[] = []; + await Promise.all( services.map(async (service) => { if (service.source !== ServiceSource.Docker) { @@ -52,6 +58,7 @@ export class BuildManager { .returning(); assert(deployment); + deploymentIds.push(deployment.id); service.finalizedDockerImage = await BuildManager.getInstance().startBuild( @@ -61,6 +68,8 @@ export class BuildManager { } }), ); + + return deploymentIds; } public getTask(deploymentId: string) { diff --git a/src/server/build/BuildTask.ts b/src/server/build/BuildTask.ts index 2a22039..926d567 100644 --- a/src/server/build/BuildTask.ts +++ b/src/server/build/BuildTask.ts @@ -18,6 +18,8 @@ import Nixpacks from "./builders/Nixpacks"; import GitHubSource from "./sources/GitHub"; import BuilderLogger from "./utils/BuilderLogger"; import zlib from "node:zlib"; +import type winston from "winston"; +import mainLogger from "../utils/logger"; export default class BuildTask { static BASE_BUILD_PATH = "/var/tmp"; @@ -31,12 +33,21 @@ export default class BuildTask { // prevents race conditions when updating the status private pendingStatusUpdatePromise: Promise | null = null; + // private logging + private consoleLogger: winston.Logger; + constructor( private readonly serviceId: string, private readonly deploymentId: string, private readonly finishCallback: (imageTag: string) => void, private readonly errorCallback: (error: unknown) => void, ) { + this.consoleLogger = mainLogger.child({ + module: "buildTask", + deploymentId: this.deploymentId, + }); + this.consoleLogger.debug(`Build dispatched for ${this.deploymentId}`); + this.workingDirectory = path.join( BuildTask.BASE_BUILD_PATH, "hostforgebuild-" + this.deploymentId, diff --git a/src/server/build/sources/GitHub.ts b/src/server/build/sources/GitHub.ts index 1b90919..525cfcd 100644 --- a/src/server/build/sources/GitHub.ts +++ b/src/server/build/sources/GitHub.ts @@ -57,6 +57,5 @@ export default class GitHubSource extends BaseSource { // wait for exit await waitForExit(git); - console.log("Downloaded code from GitHub"); } } diff --git a/src/server/build/utils/utils.ts b/src/server/build/utils/utils.ts index 0f919cb..636a7dc 100644 --- a/src/server/build/utils/utils.ts +++ b/src/server/build/utils/utils.ts @@ -19,7 +19,6 @@ export function waitForExit(child: ChildProcess) { return new Promise((resolve, reject) => { child.on("exit", (code) => { if (code === 0) { - console.log("Child process exited successfully"); resolve(); } else { reject(new Error(`Child process exited with code ${code}`)); diff --git a/src/server/docker/docker.ts b/src/server/docker/docker.ts index cc6c3d1..bde7179 100644 --- a/src/server/docker/docker.ts +++ b/src/server/docker/docker.ts @@ -21,7 +21,6 @@ export class Docker extends Dockerode { static demuxStream() { return new Transform({ transform(chunk: Buffer, encoding?: unknown, callback: () => void) { - console.log("chunk", chunk.toString()); if (chunk.length < 8) { this.push(chunk); callback(); diff --git a/src/server/managers/Deployment.ts b/src/server/managers/Deployment.ts index b4c6585..099b425 100644 --- a/src/server/managers/Deployment.ts +++ b/src/server/managers/Deployment.ts @@ -56,6 +56,7 @@ export default class Deployment { const task = BuildManager.getInstance().getTask(this.deploymentData.id); if (!task) { this.logger.error("Task not found for deployment"); + returnStream.destroy(new Error("Task not found for deployment")); return; } diff --git a/src/server/managers/Project.ts b/src/server/managers/Project.ts index 53fd6b8..dc5fbf2 100644 --- a/src/server/managers/Project.ts +++ b/src/server/managers/Project.ts @@ -163,7 +163,7 @@ export default class ProjectManager { // now build the dockerfile const composeStack = await buildDockerStackFile(allServiceData); - return await deployOptions.docker.cli( + const output = await deployOptions.docker.cli( [ "stack", "deploy", @@ -176,6 +176,16 @@ export default class ProjectManager { stdin: JSON.stringify(composeStack), }, ); + + // update all deployment statuses to success + await db + .update(serviceDeployment) + .set({ + status: ServiceDeploymentStatus.Success, + }) + .where(eq(serviceDeployment.projectDeploymentId, deployment.id)); + + return output; } /** @@ -186,9 +196,9 @@ export default class ProjectManager { (await this.getServices()) .map((service) => ({ service, - pendingUpdate: service.buildDeployDiff(), + pendingUpdate: service.hasPendingChanges(), })) - .filter(({ pendingUpdate }) => Object.keys(pendingUpdate).length > 0), + .filter(({ pendingUpdate }) => pendingUpdate), ); return services.map(({ service }) => service); diff --git a/src/server/managers/Service.ts b/src/server/managers/Service.ts index 7a6f5da..6d93b8a 100644 --- a/src/server/managers/Service.ts +++ b/src/server/managers/Service.ts @@ -1,37 +1,19 @@ import { deterministicString } from "deterministic-object-hash"; import { and, eq, or } from "drizzle-orm"; -import { create } from "jsondiffpatch"; import assert from "node:assert"; import { db } from "../db"; import { service, serviceGeneration } from "../db/schema"; import { ServiceSource } from "../db/types"; import logger from "../utils/logger"; import Deployment from "./Deployment"; -import type { Docker } from "../docker/docker"; -import { docker404ToNull } from "../utils/serverUtils"; import type ProjectManager from "./Project"; -import { type paths as DockerAPITypes } from "~/server/docker/types"; +import { diff } from "json-diff-ts"; export default class ServiceManager { private static LOGGER = logger.child({ module: "ServiceManager", }); - private static JSON_DIFF = create({ - objectHash: (obj: unknown) => { - if (typeof obj !== "object" || obj === null) { - return deterministicString(obj); - } - - if ("id" in obj && typeof obj.id === "string") { - return obj.id; - } - - this.LOGGER.warn("Unexpected object in JSON diff.", { obj }); - return deterministicString(obj); - }, - }); - constructor( private serviceData: typeof service.$inferSelect & { latestGeneration?: typeof serviceGeneration.$inferSelect; @@ -78,7 +60,7 @@ export default class ServiceManager { this.serviceData.latestGenerationId === this.serviceData.deployedGenerationId ) { - return {}; + return []; } // fetch the generations @@ -88,7 +70,7 @@ export default class ServiceManager { ]); // compare the two - return ServiceManager.JSON_DIFF.diff(deployed, latest); + return diff(deployed, latest); } /** @@ -96,9 +78,7 @@ export default class ServiceManager { */ public async hasPendingChanges() { const diff = await this.buildDeployDiff(); - - if (typeof diff === "object") return Object.keys(diff).length !== 0; - return true; + return diff.length > 0; } /**