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 ( - <> +
+ - + + } /> + } /> + + - + + } /> + } /> + } /> + - + +
); } diff --git a/src/components/BuildsPanel.tsx b/src/components/BuildsPanel.tsx new file mode 100644 index 0000000..910bf16 --- /dev/null +++ b/src/components/BuildsPanel.tsx @@ -0,0 +1,160 @@ +import { useMutation, useQuery } from "convex/react"; +import { + Clock, + CheckCircle, + XCircle, + Loader2, + Trash2, + RotateCw, + ExternalLink, +} from "lucide-react"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { api } from "../../convex/_generated/api"; +import type { Id } from "../../convex/_generated/dataModel"; + +interface BuildsPanelProps { + 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 getStatusIcon = (status: string) => { + switch (status) { + case "success": + return ; + case "failure": + return ; + case "in_progress": + return ; + default: + 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 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), + }); + } + }; + + if (!builds || builds.length === 0) { + return ( +
+ No builds yet. Click "Build" to start. +
+ ); + } + + return ( +
+

Build History

+ {builds.map((build) => ( +
+
+ + {getStatusIcon(build.status)} + + {build.target} + + + {build.status} + + +
+ {build.status === "failure" && ( + + )} + +
+
+ + {build.logs && ( +
+							{build.logs.split("\n").slice(-5).join("\n")}
+						
+ )} + +
+ + View Details + + + {build.artifactUrl && ( + + Download Artifact → + + )} +
+ +
+ Started: {new Date(build.startedAt).toLocaleString()} +
+
+ ))} +
+ ); +} diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..1128edf --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,29 @@ +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/src/pages/BuildDetail.tsx b/src/pages/BuildDetail.tsx new file mode 100644 index 0000000..735abe6 --- /dev/null +++ b/src/pages/BuildDetail.tsx @@ -0,0 +1,127 @@ +import { useQuery } from "convex/react"; +import { + ArrowLeft, + CheckCircle, + Clock, + Download, + Loader2, + Terminal, + XCircle, +} from "lucide-react"; +import { Link, useParams } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +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">, + }); + + if (build === undefined) { + return ( +
+ +
+ ); + } + + 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 getStatusIcon = (status: string) => { + switch (status) { + case "success": + return ; + case "failure": + return ; + case "in_progress": + return ; + default: + return ; + } + }; + + return ( +
+
+
+ + Back to Dashboard + + +
+
+ {getStatusIcon(build.status)} +
+

{build.target}

+
+ Build ID: {build._id} + + + {build.status.toUpperCase()} + + + {new Date(build.startedAt).toLocaleString()} +
+
+
+ + {build.artifactUrl && ( + + + + )} +
+
+ +
+
+
+ + + Build Logs + +
+
+
+								{build.logs || "No logs available..."}
+							
+
+
+
+
+
+ ); +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index f2832ef..e28d35d 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,14 +1,18 @@ import { useAuthActions } from "@convex-dev/auth/react"; -import { useQuery } from "convex/react"; +import { useMutation, useQuery } from "convex/react"; import { Plus } from "lucide-react"; import { useState } from "react"; +import { toast } from "sonner"; +import BuildsPanel from "@/components/BuildsPanel"; import ProfileEditor from "@/components/ProfileEditor"; import { Button } from "@/components/ui/button"; import { api } from "../../convex/_generated/api"; +import type { Id } from "../../convex/_generated/dataModel"; export default function Dashboard() { const { signOut } = useAuthActions(); const profiles = useQuery(api.profiles.list); + const triggerBuild = useMutation(api.builds.triggerBuild); const [isEditing, setIsEditing] = useState(false); const [editingProfile, setEditingProfile] = useState(null); @@ -22,6 +26,19 @@ export default function Dashboard() { setIsEditing(true); }; + const handleBuild = async (profileId: Id<"profiles">) => { + try { + await triggerBuild({ profileId }); + toast.success("Build started", { + description: "Check the build status below.", + }); + } catch (error) { + toast.error("Build failed", { + description: String(error), + }); + } + }; + return (
@@ -65,7 +82,13 @@ export default function Dashboard() { > Edit - + +
+ +
+
))}