From da2f4f3f91f028d3b8cf7427c4e79246869bd3af Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sun, 23 Nov 2025 17:21:09 -0800 Subject: [PATCH] refactor: enhance build management by improving status handling, adding human-readable status formatting, and optimizing build detail display --- convex/builds.ts | 511 ++++++++++++++++----------------- convex/http.ts | 60 ++-- convex/schema.ts | 54 ++-- src/components/BuildsPanel.tsx | 248 ++++++++-------- src/lib/utils.ts | 58 ++-- src/pages/BuildDetail.tsx | 199 ++++++------- 6 files changed, 545 insertions(+), 585 deletions(-) diff --git a/convex/builds.ts b/convex/builds.ts index 8c62a51..166c0b1 100644 --- a/convex/builds.ts +++ b/convex/builds.ts @@ -4,30 +4,38 @@ import { api } from "./_generated/api"; import { internalMutation, mutation, query } from "./_generated/server"; import modulesData from "./modules.json"; +type BuildUpdateData = { + status: string; + completedAt?: number; + artifactUrl?: string; +}; + /** * Computes a stable SHA-256 hash from version, target, and flags. * This hash uniquely identifies a build configuration based on what is actually executed. */ async function computeBuildHash( - version: string, - target: string, - flags: string, + version: string, + target: string, + flags: string, ): Promise { - // Input is now the exact parameters used for the build - const input = JSON.stringify({ - version, - target, - flags, - }); - - // Use Web Crypto API for SHA-256 hashing - const encoder = new TextEncoder(); - const data = encoder.encode(input); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); - - return hashHex; + // Input is now the exact parameters used for the build + const input = JSON.stringify({ + version, + target, + flags, + }); + + // Use Web Crypto API for SHA-256 hashing + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return hashHex; } /** @@ -36,298 +44,283 @@ async function computeBuildHash( * Or custom domain if R2_PUBLIC_URL is set. */ function getR2ArtifactUrl(buildHash: string): string { - const r2PublicUrl = process.env.R2_PUBLIC_URL; - if (r2PublicUrl) { - // Custom domain configured - return `${r2PublicUrl}/${buildHash}.uf2`; - } - // Default R2 public URL pattern (requires public bucket) - const bucketName = process.env.R2_BUCKET_NAME || "firmware-builds"; - const accountId = process.env.R2_ACCOUNT_ID || ""; - if (accountId) { - return `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${buildHash}.uf2`; - } - // Fallback: assume custom domain or public bucket URL - return `https://${bucketName}.r2.cloudflarestorage.com/${buildHash}.uf2`; + const r2PublicUrl = process.env.R2_PUBLIC_URL; + if (r2PublicUrl) { + // Custom domain configured + return `${r2PublicUrl}/${buildHash}.uf2`; + } + // Default R2 public URL pattern (requires public bucket) + const bucketName = process.env.R2_BUCKET_NAME || "firmware-builds"; + const accountId = process.env.R2_ACCOUNT_ID || ""; + if (accountId) { + return `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${buildHash}.uf2`; + } + // Fallback: assume custom domain or public bucket URL + return `https://${bucketName}.r2.cloudflarestorage.com/${buildHash}.uf2`; } export const triggerBuild = mutation({ - args: { - profileId: v.id("profiles"), - }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Unauthorized"); + args: { + profileId: v.id("profiles"), + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Unauthorized"); - const profile = await ctx.db.get(args.profileId); - if (!profile || profile.userId !== userId) { - throw new Error("Unauthorized"); - } + const profile = await ctx.db.get(args.profileId); + if (!profile || profile.userId !== userId) { + throw new Error("Unauthorized"); + } - // Convert config object to flags string - const flags: string[] = []; + // Convert config object to flags string + const flags: string[] = []; - // Handle Modules (Inverted Logic: Default Excluded) - for (const module of modulesData.modules) { - // If config[id] is NOT false (explicitly included), we exclude it. - if (profile.config[module.id] !== false) { - flags.push(`-D${module.id}=1`); - } - } - - const flagsString = flags.join(" "); + // Handle Modules (Inverted Logic: Default Excluded) + for (const module of modulesData.modules) { + // If config[id] is NOT false (explicitly included), we exclude it. + if (profile.config[module.id] !== false) { + flags.push(`-D${module.id}=1`); + } + } - // Create build records for each target - for (const target of profile.targets) { - // Compute build hash using the generated flags - const buildHash = await computeBuildHash( - profile.version, - target, - flagsString, - ); - - console.log(`Computed build hash for ${target}: ${buildHash} (Flags: ${flagsString})`); + const flagsString = flags.join(" "); - // Check cache for existing build - const cached = await ctx.db - .query("buildCache") - .withIndex("by_hash_target", (q) => - q.eq("buildHash", buildHash).eq("target", target), - ) - .first(); + // Create build records for each target + for (const target of profile.targets) { + // Compute build hash using the generated flags + const buildHash = await computeBuildHash( + profile.version, + target, + flagsString, + ); - if (cached) { - // Use cached artifact, skip GitHub workflow - const artifactUrl = getR2ArtifactUrl(buildHash); - await ctx.db.insert("builds", { - profileId: profile._id, - target: target, - githubRunId: 0, - status: "success", - artifactUrl: artifactUrl, - logs: "Build completed from cache", - startedAt: Date.now(), - completedAt: Date.now(), - buildHash: buildHash, - }); - } else { - // Not cached, proceed with normal build flow - const buildId = await ctx.db.insert("builds", { - profileId: profile._id, - target: target, - githubRunId: 0, - status: "queued", - logs: "Build queued...", - startedAt: Date.now(), - buildHash: buildHash, - }); + console.log( + `Computed build hash for ${target}: ${buildHash} (Flags: ${flagsString})`, + ); - // Schedule the action to dispatch GitHub workflow - await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, { - buildId: buildId, - target: target, - flags: flagsString, - version: profile.version, - buildHash: buildHash, - }); - } - } - }, + // Check cache for existing build + const cached = await ctx.db + .query("buildCache") + .withIndex("by_hash_target", (q) => + q.eq("buildHash", buildHash).eq("target", target), + ) + .first(); + + if (cached) { + // Use cached artifact, skip GitHub workflow + const artifactUrl = getR2ArtifactUrl(buildHash); + await ctx.db.insert("builds", { + profileId: profile._id, + target: target, + githubRunId: 0, + status: "success", + artifactUrl: artifactUrl, + startedAt: Date.now(), + completedAt: Date.now(), + buildHash: buildHash, + }); + } else { + // Not cached, proceed with normal build flow + const buildId = await ctx.db.insert("builds", { + profileId: profile._id, + target: target, + githubRunId: 0, + status: "queued", + startedAt: Date.now(), + buildHash: buildHash, + }); + + // Schedule the action to dispatch GitHub workflow + await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, { + buildId: buildId, + target: target, + flags: flagsString, + version: profile.version, + buildHash: buildHash, + }); + } + } + }, }); export const listByProfile = query({ - args: { profileId: v.id("profiles") }, - handler: async (ctx, args) => { - return await ctx.db - .query("builds") - .withIndex("by_profile", (q) => q.eq("profileId", args.profileId)) - .order("desc") - .take(10); - }, + args: { profileId: v.id("profiles") }, + handler: async (ctx, args) => { + return await ctx.db + .query("builds") + .withIndex("by_profile", (q) => q.eq("profileId", args.profileId)) + .order("desc") + .take(10); + }, }); export const get = query({ - args: { buildId: v.id("builds") }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) return null; + args: { buildId: v.id("builds") }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) return null; - const build = await ctx.db.get(args.buildId); - if (!build) return null; + const build = await ctx.db.get(args.buildId); + if (!build) return null; - const profile = await ctx.db.get(build.profileId); - if (!profile || profile.userId !== userId) return null; + const profile = await ctx.db.get(build.profileId); + if (!profile || profile.userId !== userId) return null; - return build; - }, + return build; + }, }); // Internal query to get build without auth checks (for webhooks) export const getInternal = internalMutation({ - args: { buildId: v.id("builds") }, - handler: async (ctx, args) => { - return await ctx.db.get(args.buildId); - }, + args: { buildId: v.id("builds") }, + handler: async (ctx, args) => { + return await ctx.db.get(args.buildId); + }, }); export const deleteBuild = mutation({ - args: { buildId: v.id("builds") }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Unauthorized"); + args: { buildId: v.id("builds") }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Unauthorized"); - const build = await ctx.db.get(args.buildId); - if (!build) throw new Error("Build not found"); + const build = await ctx.db.get(args.buildId); + if (!build) throw new Error("Build not found"); - const profile = await ctx.db.get(build.profileId); - if (!profile || profile.userId !== userId) { - throw new Error("Unauthorized"); - } + const profile = await ctx.db.get(build.profileId); + if (!profile || profile.userId !== userId) { + throw new Error("Unauthorized"); + } - await ctx.db.delete(args.buildId); - }, + await ctx.db.delete(args.buildId); + }, }); export const retryBuild = mutation({ - args: { buildId: v.id("builds") }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Unauthorized"); + args: { buildId: v.id("builds") }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Unauthorized"); - const build = await ctx.db.get(args.buildId); - if (!build) throw new Error("Build not found"); + const build = await ctx.db.get(args.buildId); + if (!build) throw new Error("Build not found"); - const profile = await ctx.db.get(build.profileId); - if (!profile || profile.userId !== userId) { - throw new Error("Unauthorized"); - } + const profile = await ctx.db.get(build.profileId); + if (!profile || profile.userId !== userId) { + throw new Error("Unauthorized"); + } - // Reset build status - await ctx.db.patch(args.buildId, { - status: "queued", - logs: "Build retry queued...", - startedAt: Date.now(), - completedAt: undefined, - }); + // Reset build status + await ctx.db.patch(args.buildId, { + status: "queued", + startedAt: Date.now(), + completedAt: undefined, + }); - // Convert config object to flags string - const flags: string[] = []; + // Convert config object to flags string + const flags: string[] = []; - // Handle Modules (Inverted Logic: Default Excluded) - for (const module of modulesData.modules) { - // If config[id] is NOT false (explicitly included), we exclude it. - if (profile.config[module.id] !== false) { - flags.push(`-D${module.id}=1`); - } - } - - const flagsString = flags.join(" "); + // Handle Modules (Inverted Logic: Default Excluded) + for (const module of modulesData.modules) { + // If config[id] is NOT false (explicitly included), we exclude it. + if (profile.config[module.id] !== false) { + flags.push(`-D${module.id}=1`); + } + } - // Compute build hash for retry using flags - const buildHash = await computeBuildHash( - profile.version, - build.target, - flagsString, - ); - - console.log(`Computed retry hash: ${buildHash} (Flags: ${flagsString})`); + const flagsString = flags.join(" "); - await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, { - buildId: args.buildId, - target: build.target, - flags: flagsString, - version: profile.version, - buildHash: buildHash, - }); - }, + // Compute build hash for retry using flags + const buildHash = await computeBuildHash( + profile.version, + build.target, + flagsString, + ); + + console.log(`Computed retry hash: ${buildHash} (Flags: ${flagsString})`); + + await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, { + buildId: args.buildId, + target: build.target, + flags: flagsString, + version: profile.version, + buildHash: buildHash, + }); + }, }); // Internal mutation to log errors from actions export const logBuildError = internalMutation({ - args: { - buildId: v.id("builds"), - error: v.string(), - }, - handler: async (ctx, args) => { - await ctx.db.patch(args.buildId, { - status: "failure", - logs: `Error triggering build: ${args.error}`, - completedAt: Date.now(), - }); - }, -}); - -// Internal mutation to append logs -export const appendLogs = internalMutation({ - args: { - buildId: v.id("builds"), - logs: v.string(), - }, - handler: async (ctx, args) => { - const build = await ctx.db.get(args.buildId); - if (!build) return; - - await ctx.db.patch(args.buildId, { - logs: (build.logs || "") + args.logs, - }); - }, + args: { + buildId: v.id("builds"), + error: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.buildId, { + status: "failure", + completedAt: Date.now(), + }); + }, }); // Internal mutation to update build status export const updateBuildStatus = internalMutation({ - args: { - buildId: v.id("builds"), - status: v.union(v.literal("success"), v.literal("failure")), - artifactUrl: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const build = await ctx.db.get(args.buildId); - if (!build) return; + args: { + buildId: v.id("builds"), + status: v.string(), // Accepts any status string value + artifactUrl: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const build = await ctx.db.get(args.buildId); + if (!build) return; - const updateData: any = { - status: args.status, - completedAt: Date.now(), - }; + const updateData: BuildUpdateData = { + status: args.status, + }; - if (args.artifactUrl) { - updateData.artifactUrl = args.artifactUrl; - } + // Only set completedAt for final statuses + if (args.status === "success" || args.status === "failure") { + updateData.completedAt = Date.now(); + } - await ctx.db.patch(args.buildId, updateData); + if (args.artifactUrl) { + updateData.artifactUrl = args.artifactUrl; + } - // If build succeeded and we have buildHash, store in cache with R2 URL - if (args.status === "success" && build.buildHash && build.target) { - // Get version from profile - const profile = await ctx.db.get(build.profileId); - if (profile) { - // Construct R2 URL from hash - const artifactUrl = getR2ArtifactUrl(build.buildHash); - - // Update build with R2 URL if not already set - if (!args.artifactUrl) { - updateData.artifactUrl = artifactUrl; - await ctx.db.patch(args.buildId, { artifactUrl }); - } + await ctx.db.patch(args.buildId, updateData); - // Check if cache entry already exists - const existing = await ctx.db - .query("buildCache") - .withIndex("by_hash_target", (q) => - q.eq("buildHash", build.buildHash!).eq("target", build.target), - ) - .first(); + // If build succeeded, store in cache with R2 URL + if (args.status === "success" && build.buildHash && build.target) { + // Get version from profile + const profile = await ctx.db.get(build.profileId); + if (profile) { + // Construct R2 URL from hash + const artifactUrl = getR2ArtifactUrl(build.buildHash); - if (!existing) { - // Store in cache - await ctx.db.insert("buildCache", { - buildHash: build.buildHash, - target: build.target, - artifactUrl: artifactUrl, - version: profile.version, - createdAt: Date.now(), - }); - } - } - } - }, + // Update build with R2 URL if not already set + if (!args.artifactUrl) { + await ctx.db.patch(args.buildId, { artifactUrl }); + } + + // Check if cache entry already exists + const existing = await ctx.db + .query("buildCache") + .withIndex("by_hash_target", (q) => + q.eq("buildHash", build.buildHash).eq("target", build.target), + ) + .first(); + + if (!existing) { + // Store in cache + await ctx.db.insert("buildCache", { + buildHash: build.buildHash, + target: build.target, + artifactUrl: artifactUrl, + version: profile.version, + createdAt: Date.now(), + }); + } + } + } + }, }); diff --git a/convex/http.ts b/convex/http.ts index 59d08b3..54c8073 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -12,52 +12,32 @@ http.route({ method: "POST", handler: httpAction(async (ctx, request) => { const payload = await request.json(); - + // Verify signature (TODO: Add HMAC verification) - - // Handle build completion from our custom workflow - if (payload.action === "completed" && payload.build_id) { - const status = payload.status === "success" ? "success" : "failure"; - - // Update build status - R2 URL will be constructed automatically from buildHash + + // Validate build_id is present + if (!payload.build_id || !payload.status) { + return new Response("Missing build_id or status", { status: 400 }); + } + + // Verify build exists + const build = await ctx.runMutation(internal.builds.getInternal, { + buildId: payload.build_id, + }); + + if (!build) { + return new Response("Build not found", { status: 404 }); + } + + // Handle status updates (intermediate statuses) and completion (final statuses) + if (payload.action === "status_update" || payload.action === "completed") { await ctx.runMutation(internal.builds.updateBuildStatus, { buildId: payload.build_id, - status, + status: payload.status, }); - + return new Response(null, { status: 200 }); } - - // Legacy handling for GitHub webhook events - if (payload.action === "completed" && payload.workflow_job) { - const runId = payload.workflow_job.run_id; - const status = payload.workflow_job.conclusion; - - // TODO: Update build status in database - // Need to match by profile/target since we don't have runId stored yet - console.log("Build completed:", runId, status); - } - - return new Response(null, { status: 200 }); - }), -}); - -http.route({ - path: "/api/logs", - method: "POST", - handler: httpAction(async (ctx, request) => { - const { buildId, logs } = await request.json(); - - if (!buildId || !logs) { - return new Response("Missing buildId or logs", { status: 400 }); - } - - // TODO: Add some verification (e.g. secret token) - - await ctx.runMutation(internal.builds.appendLogs, { - buildId, - logs, - }); return new Response(null, { status: 200 }); }), diff --git a/convex/schema.ts b/convex/schema.ts index 104e2d7..6e6d089 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -3,33 +3,31 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ - ...authTables, - profiles: defineTable({ - userId: v.id("users"), - name: v.string(), - targets: v.array(v.string()), // e.g. ["tbeam", "rak4631"] - config: v.any(), // JSON object for flags - version: v.string(), - updatedAt: v.number(), - }).index("by_user", ["userId"]), + ...authTables, + profiles: defineTable({ + userId: v.id("users"), + name: v.string(), + targets: v.array(v.string()), // e.g. ["tbeam", "rak4631"] + config: v.any(), // JSON object for flags + version: v.string(), + updatedAt: v.number(), + }).index("by_user", ["userId"]), + builds: defineTable({ + profileId: v.id("profiles"), + target: v.string(), + githubRunId: v.number(), + status: v.string(), // Accepts arbitrary status strings (e.g., "queued", "checking_out", "building", "uploading", "success", "failure") + artifactUrl: v.optional(v.string()), + startedAt: v.number(), + completedAt: v.optional(v.number()), + buildHash: v.string(), + }).index("by_profile", ["profileId"]), - builds: defineTable({ - profileId: v.id("profiles"), - target: v.string(), - githubRunId: v.number(), - status: v.string(), // "queued", "in_progress", "success", "failure" - artifactUrl: v.optional(v.string()), - logs: v.optional(v.string()), - startedAt: v.number(), - completedAt: v.optional(v.number()), - buildHash: v.optional(v.string()), - }).index("by_profile", ["profileId"]), - - buildCache: defineTable({ - buildHash: v.string(), - target: v.string(), - artifactUrl: v.string(), - version: v.string(), - createdAt: v.number(), - }).index("by_hash_target", ["buildHash", "target"]), + buildCache: defineTable({ + buildHash: v.string(), + target: v.string(), + artifactUrl: v.string(), + version: v.string(), + createdAt: v.number(), + }).index("by_hash_target", ["buildHash", "target"]), }); diff --git a/src/components/BuildsPanel.tsx b/src/components/BuildsPanel.tsx index 11f60fd..d00edce 100644 --- a/src/components/BuildsPanel.tsx +++ b/src/components/BuildsPanel.tsx @@ -1,147 +1,143 @@ import { useMutation, useQuery } from "convex/react"; import { - CheckCircle, - Clock, - Loader2, - RotateCw, - Trash2, - XCircle, + CheckCircle, + Clock, + Loader2, + RotateCw, + Trash2, + XCircle, } from "lucide-react"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { timeAgo } from "@/lib/utils"; +import { humanizeStatus, timeAgo } from "@/lib/utils"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; interface BuildsPanelProps { - profileId: Id<"profiles">; + profileId: Id<"profiles">; } export default function BuildsPanel({ profileId }: BuildsPanelProps) { - const builds = useQuery(api.builds.listByProfile, { profileId }); - const deleteBuild = useMutation(api.builds.deleteBuild); - const retryBuild = useMutation(api.builds.retryBuild); + const builds = useQuery(api.builds.listByProfile, { profileId }); + const deleteBuild = useMutation(api.builds.deleteBuild); + const retryBuild = useMutation(api.builds.retryBuild); - const getStatusIcon = (status: string) => { - switch (status) { - case "success": - return ; - case "failure": - return ; - case "in_progress": - return ; - default: - return ; - } - }; + const getStatusIcon = (status: string) => { + if (status === "success") { + return ; + } + if (status === "failure") { + return ; + } + // All other statuses show as in progress + return ; + }; - const getStatusColor = (status: string) => { - switch (status) { - case "success": - return "text-green-400"; - case "failure": - return "text-red-400"; - case "in_progress": - return "text-blue-400"; - default: - return "text-yellow-400"; - } - }; + const getStatusColor = (status: string) => { + if (status === "success") { + return "text-green-400"; + } + if (status === "failure") { + return "text-red-400"; + } + // All other statuses show as in progress + return "text-blue-400"; + }; - const handleDelete = async (buildId: Id<"builds">) => { - try { - await deleteBuild({ buildId }); - toast.success("Build deleted", { - description: "Build record has been removed.", - }); - } catch (error) { - toast.error("Delete failed", { - description: String(error), - }); - } - }; + const handleDelete = async (buildId: Id<"builds">) => { + try { + await deleteBuild({ buildId }); + toast.success("Build deleted", { + description: "Build record has been removed.", + }); + } catch (error) { + toast.error("Delete failed", { + description: String(error), + }); + } + }; - const handleRetry = async (buildId: Id<"builds">) => { - try { - await retryBuild({ buildId }); - toast.success("Build retrying", { - description: "Build has been queued again.", - }); - } catch (error) { - toast.error("Retry failed", { - description: String(error), - }); - } - }; + const handleRetry = async (buildId: Id<"builds">) => { + try { + await retryBuild({ buildId }); + toast.success("Build retrying", { + description: "Build has been queued again.", + }); + } catch (error) { + toast.error("Retry failed", { + description: String(error), + }); + } + }; - if (!builds || builds.length === 0) { - return ( -
- No builds yet. Click "Build" to start. -
- ); - } + if (!builds || builds.length === 0) { + return ( +
+ No builds yet. Click "Build" to start. +
+ ); + } - return ( -
-

Build Status

-
- {builds.map((build) => ( -
- - {getStatusIcon(build.status)} - - {build.target} - - - {build.status} - - - {timeAgo(build.startedAt)} - - + return ( +
+

Build Status

+
+ {builds.map((build) => ( +
+ + {getStatusIcon(build.status)} + + {build.target} + + + {humanizeStatus(build.status)} + + + {timeAgo(build.startedAt)} + + -
- {build.status === "failure" && ( - - )} - -
-
- ))} -
-
- ); +
+ {build.status === "failure" && ( + + )} + +
+
+ ))} +
+
+ ); } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ecff906..6616148 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,31 +2,45 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); } export function timeAgo(date: number | string | Date): string { - const now = new Date(); - const past = new Date(date); - const msPerMinute = 60 * 1000; - const msPerHour = msPerMinute * 60; - const msPerDay = msPerHour * 24; - const msPerMonth = msPerDay * 30; - const msPerYear = msPerDay * 365; + const now = new Date(); + const past = new Date(date); + const msPerMinute = 60 * 1000; + const msPerHour = msPerMinute * 60; + const msPerDay = msPerHour * 24; + const msPerMonth = msPerDay * 30; + const msPerYear = msPerDay * 365; - const elapsed = now.getTime() - past.getTime(); + const elapsed = now.getTime() - past.getTime(); - if (elapsed < msPerMinute) { - return `${Math.round(elapsed / 1000)}s ago`; - } else if (elapsed < msPerHour) { - return `${Math.round(elapsed / msPerMinute)}m ago`; - } else if (elapsed < msPerDay) { - return `${Math.round(elapsed / msPerHour)}h ago`; - } else if (elapsed < msPerMonth) { - return `${Math.round(elapsed / msPerDay)}d ago`; - } else if (elapsed < msPerYear) { - return `${Math.round(elapsed / msPerMonth)}mo ago`; - } else { - return `${Math.round(elapsed / msPerYear)}y ago`; - } + if (elapsed < msPerMinute) { + return `${Math.round(elapsed / 1000)}s ago`; + } else if (elapsed < msPerHour) { + return `${Math.round(elapsed / msPerMinute)}m ago`; + } else if (elapsed < msPerDay) { + return `${Math.round(elapsed / msPerHour)}h ago`; + } else if (elapsed < msPerMonth) { + return `${Math.round(elapsed / msPerDay)}d ago`; + } else if (elapsed < msPerYear) { + return `${Math.round(elapsed / msPerMonth)}mo ago`; + } else { + return `${Math.round(elapsed / msPerYear)}y ago`; + } +} + +export function humanizeStatus(status: string): string { + // Handle special statuses + if (status === "success") return "Success"; + if (status === "failure") return "Failure"; + if (status === "queued") return "Queued"; + if (status === "in_progress") return "In Progress"; + + // Convert snake_case/underscore_separated to Title Case + return status + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); } diff --git a/src/pages/BuildDetail.tsx b/src/pages/BuildDetail.tsx index 735abe6..ff69a4b 100644 --- a/src/pages/BuildDetail.tsx +++ b/src/pages/BuildDetail.tsx @@ -1,127 +1,106 @@ import { useQuery } from "convex/react"; import { - ArrowLeft, - CheckCircle, - Clock, - Download, - Loader2, - Terminal, - XCircle, + ArrowLeft, + CheckCircle, + Download, + Loader2, + XCircle, } from "lucide-react"; import { Link, useParams } from "react-router-dom"; import { Button } from "@/components/ui/button"; +import { humanizeStatus } from "@/lib/utils"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; export default function BuildDetail() { - const { buildId } = useParams<{ buildId: string }>(); - const build = useQuery(api.builds.get, { - buildId: buildId as Id<"builds">, - }); + const { buildId } = useParams<{ buildId: string }>(); + const build = useQuery(api.builds.get, { + buildId: buildId as Id<"builds">, + }); - if (build === undefined) { - return ( -
- -
- ); - } + if (build === undefined) { + return ( +
+ +
+ ); + } - if (build === null) { - return ( -
-

Build Not Found

- - - -
- ); - } + if (build === null) { + return ( +
+

Build Not Found

+ + + +
+ ); + } - const getStatusColor = (status: string) => { - switch (status) { - case "success": - return "text-green-400"; - case "failure": - return "text-red-400"; - case "in_progress": - return "text-blue-400"; - default: - return "text-yellow-400"; - } - }; + const getStatusColor = (status: string) => { + if (status === "success") { + return "text-green-400"; + } + if (status === "failure") { + return "text-red-400"; + } + // All other statuses show as in progress + return "text-blue-400"; + }; - const getStatusIcon = (status: string) => { - switch (status) { - case "success": - return ; - case "failure": - return ; - case "in_progress": - return ; - default: - return ; - } - }; + const getStatusIcon = (status: string) => { + if (status === "success") { + return ; + } + if (status === "failure") { + return ; + } + // All other statuses show as in progress + return ; + }; - return ( -
-
-
- - Back to Dashboard - + return ( +
+
+
+ + Back to Dashboard + -
-
- {getStatusIcon(build.status)} -
-

{build.target}

-
- Build ID: {build._id} - - - {build.status.toUpperCase()} - - - {new Date(build.startedAt).toLocaleString()} -
-
-
+
+
+ {getStatusIcon(build.status)} +
+

{build.target}

+
+ Build ID: {build._id} + + + {humanizeStatus(build.status)} + + + {new Date(build.startedAt).toLocaleString()} +
+
+
- {build.artifactUrl && ( - - - - )} -
-
- -
-
-
- - - Build Logs - -
-
-
-								{build.logs || "No logs available..."}
-							
-
-
-
-
-
- ); + {build.status === "success" && build.artifactUrl && ( + + + + )} +
+ +
+ + ); }