+ {build.logs || "No logs available..."}
+
+ diff --git a/bun.lock b/bun.lock
index 2d4f420..9f96510 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,9 +11,12 @@
"clsx": "^2.1.1",
"convex": "^1.29.3",
"lucide-react": "^0.554.0",
+ "next-themes": "^0.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.66.1",
+ "react-router-dom": "^7.9.6",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
},
@@ -518,6 +521,8 @@
"natural-compare-lite": ["natural-compare-lite@1.4.0", "", {}, "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g=="],
+ "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
+
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="],
@@ -572,6 +577,10 @@
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
+ "react-router": ["react-router@7.9.6", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA=="],
+
+ "react-router-dom": ["react-router-dom@7.9.6", "", { "dependencies": { "react-router": "7.9.6" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA=="],
+
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
@@ -588,12 +597,16 @@
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
+ "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
+
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
+ "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
+
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
diff --git a/convex/actions.ts b/convex/actions.ts
index 29ac7b1..b299b22 100644
--- a/convex/actions.ts
+++ b/convex/actions.ts
@@ -1,43 +1,54 @@
import { v } from "convex/values";
+import { internal } from "./_generated/api";
import { action } from "./_generated/server";
export const dispatchGithubBuild = action({
args: {
+ buildId: v.id("builds"),
target: v.string(),
flags: v.string(),
},
- handler: async (_ctx, args) => {
- const githubToken = process.env.GITHUB_TOKEN;
- if (!githubToken) {
- throw new Error("GITHUB_TOKEN is not defined");
- }
+ handler: async (ctx, args) => {
+ try {
+ const githubToken = process.env.GITHUB_TOKEN;
+ if (!githubToken) {
+ throw new Error("GITHUB_TOKEN is not defined");
+ }
- const response = await fetch(
- "https://api.github.com/repos/meshtastic/firmware/actions/workflows/custom_build.yml/dispatches",
- {
- method: "POST",
- headers: {
- Authorization: `Bearer ${githubToken}`,
- Accept: "application/vnd.github.v3+json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- ref: "master", // or make this configurable
- inputs: {
- target: args.target,
- flags: args.flags,
+ const response = await fetch(
+ "https://api.github.com/repos/meshtastic/firmware/actions/workflows/custom_build.yml/dispatches",
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${githubToken}`,
+ Accept: "application/vnd.github.v3+json",
+ "Content-Type": "application/json",
},
- }),
- },
- );
+ body: JSON.stringify({
+ ref: "master", // or make this configurable
+ inputs: {
+ target: args.target,
+ flags: args.flags,
+ },
+ }),
+ },
+ );
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`GitHub API failed: ${response.status} ${errorText}`);
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`GitHub API failed: ${response.status} ${errorText}`);
+ }
+
+ // Note: GitHub dispatch API doesn't return the run ID immediately.
+ // We rely on the webhook to link the run back to our build record.
+ // Alternatively, we could poll for the most recent run, but that's race-condition prone.
+ } catch (error) {
+ await ctx.runMutation(internal.builds.logBuildError, {
+ buildId: args.buildId,
+ error: String(error),
+ });
+ // Re-throw so it shows up in Convex logs too
+ throw error;
}
-
- // Note: GitHub dispatch API doesn't return the run ID immediately.
- // We rely on the webhook to link the run back to our build record.
- // Alternatively, we could poll for the most recent run, but that's race-condition prone.
},
});
diff --git a/convex/builds.ts b/convex/builds.ts
index b372410..2f250a5 100644
--- a/convex/builds.ts
+++ b/convex/builds.ts
@@ -1,7 +1,7 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { v } from "convex/values";
-import { api } from "./_generated/api";
-import { mutation, query } from "./_generated/server";
+import { api, } from "./_generated/api";
+import { internalMutation, mutation, query } from "./_generated/server";
export const triggerBuild = mutation({
args: {
@@ -17,7 +17,6 @@ export const triggerBuild = mutation({
}
// Convert config object to flags string
- // e.g. { "NO_MQTT": true } -> "-DNO_MQTT"
const flags = Object.entries(profile.config)
.filter(([_, value]) => value === true)
.map(([key, _]) => `-D${key}`)
@@ -25,16 +24,18 @@ export const triggerBuild = mutation({
// Create build records for each target
for (const target of profile.targets) {
- await ctx.db.insert("builds", {
+ const buildId = await ctx.db.insert("builds", {
profileId: profile._id,
target: target,
- githubRunId: 0, // Placeholder, updated via webhook
+ githubRunId: 0,
status: "queued",
+ logs: "Build queued...",
startedAt: Date.now(),
});
// Schedule the action to dispatch GitHub workflow
await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
+ buildId: buildId,
target: target,
flags: flags,
});
@@ -52,3 +53,88 @@ export const listByProfile = query({
.take(10);
},
});
+
+export const get = query({
+ 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 profile = await ctx.db.get(build.profileId);
+ if (!profile || profile.userId !== userId) return null;
+
+ return build;
+ },
+});
+
+export const deleteBuild = mutation({
+ 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 profile = await ctx.db.get(build.profileId);
+ if (!profile || profile.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ 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");
+
+ 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");
+ }
+
+ // Reset build status
+ await ctx.db.patch(args.buildId, {
+ status: "queued",
+ logs: "Build retry queued...",
+ startedAt: Date.now(),
+ completedAt: undefined,
+ });
+
+ // Retry the build
+ const flags = Object.entries(profile.config)
+ .filter(([_, value]) => value === true)
+ .map(([key, _]) => `-D${key}`)
+ .join(" ");
+
+ await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
+ buildId: args.buildId,
+ target: build.target,
+ flags: flags,
+ });
+ },
+});
+
+// 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(),
+ });
+ },
+});
diff --git a/convex/schema.ts b/convex/schema.ts
index a5e1423..2915c3e 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -18,6 +18,7 @@ export default defineSchema({
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()),
}).index("by_profile", ["profileId"]),
diff --git a/package.json b/package.json
index 0ff7298..3322dad 100644
--- a/package.json
+++ b/package.json
@@ -17,9 +17,12 @@
"clsx": "^2.1.1",
"convex": "^1.29.3",
"lucide-react": "^0.554.0",
+ "next-themes": "^0.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.66.1",
+ "react-router-dom": "^7.9.6",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
diff --git a/src/App.tsx b/src/App.tsx
index e5b2407..50e6e8b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,27 +1,36 @@
-import {
- Authenticated,
- AuthLoading,
- Unauthenticated,
-} from "convex/react";
+import { Authenticated, AuthLoading, Unauthenticated } from "convex/react";
import { Loader2 } from "lucide-react";
+import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
+import { Toaster } from "@/components/ui/sonner";
+import BuildDetail from "./pages/BuildDetail";
import Dashboard from "./pages/Dashboard";
import LandingPage from "./pages/LandingPage";
function App() {
return (
- <>
+
+ {build.logs.split("\n").slice(-5).join("\n")}
+
+ )}
+
+
+ {build.logs || "No logs available..."}
+
+