-
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() : ""}
-
-
-
- ))}
-
+
+
+ {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;
}
/**