From 78fa319ff92ec867dc4d925ed171ddf0d9635d42 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Wed, 10 Dec 2025 04:23:32 -0800 Subject: [PATCH] feat: add BuildProgress component and enhance BuildDownloadButton with compact mode --- components/BuildDownloadButton.tsx | 52 ++- components/BuildProgress.tsx | 541 +++++++++++++++++++++++++++++ components/PluginCard.tsx | 2 +- convex/_generated/api.d.ts | 2 + convex/admin.ts | 4 +- convex/builds.ts | 25 +- convex/lib/filename.ts | 29 ++ convex/schema.ts | 6 +- pages/admin/+Page.tsx | 150 ++------ pages/builds/@buildHash/+Page.tsx | 457 +----------------------- pages/index/+Page.tsx | 56 ++- pages/plugins/+Page.tsx | 52 ++- pages/plugins/@slug/+Page.tsx | 173 +++++++++ pages/plugins/@slug/+config.ts | 4 + vendor/meshscript | 2 +- 15 files changed, 953 insertions(+), 602 deletions(-) create mode 100644 components/BuildProgress.tsx create mode 100644 convex/lib/filename.ts create mode 100644 pages/plugins/@slug/+Page.tsx create mode 100644 pages/plugins/@slug/+config.ts diff --git a/components/BuildDownloadButton.tsx b/components/BuildDownloadButton.tsx index babd8d7..42ca066 100644 --- a/components/BuildDownloadButton.tsx +++ b/components/BuildDownloadButton.tsx @@ -12,9 +12,10 @@ interface BuildDownloadButtonProps { type: ArtifactType variant?: "default" | "outline" className?: string + compact?: boolean } -export function BuildDownloadButton({ build, type, variant, className }: BuildDownloadButtonProps) { +export function BuildDownloadButton({ build, type, variant, className, compact = false }: BuildDownloadButtonProps) { const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) @@ -50,11 +51,54 @@ export function BuildDownloadButton({ build, type, variant, className }: BuildDo if (type === ArtifactType.Firmware && !build.buildHash) return null + const buttonElement = ( + + ) + + if (compact) { + return buttonElement + } + const button = (
- + {buttonElement} {type === ArtifactType.Firmware && (

Need help flashing?{" "} diff --git a/components/BuildProgress.tsx b/components/BuildProgress.tsx new file mode 100644 index 0000000..600937f --- /dev/null +++ b/components/BuildProgress.tsx @@ -0,0 +1,541 @@ +import { BuildDownloadButton } from "@/components/BuildDownloadButton" +import { Button } from "@/components/ui/button" +import { TARGETS } from "@/constants/targets" +import type { Doc } from "@/convex/_generated/dataModel" +import { ArtifactType } from "@/convex/builds" +import { getArtifactFilenameBase } from "@/convex/lib/filename" +import modulesData from "@/convex/modules.json" +import { getImplicitDependencies, humanizeStatus } from "@/lib/utils" +import registryData from "@/public/registry.json" +import { AlertCircle, CheckCircle, Copy, Loader2, Share2, X, XCircle } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { navigate } from "vike/client/router" + +interface BuildProgressProps { + build: Doc<"builds"> + isAdmin?: boolean + onRetry?: (buildId: Doc<"builds">["_id"]) => Promise + showActions?: boolean +} + +export function BuildProgress({ build, isAdmin = false, onRetry, showActions = true }: BuildProgressProps) { + const [shareUrlCopied, setShareUrlCopied] = useState(false) + const [bashCopied, setBashCopied] = useState(false) + const [showBashModal, setShowBashModal] = useState(false) + + const targetMeta = build.config.target ? TARGETS[build.config.target] : undefined + const targetLabel = targetMeta?.name ?? build.config.target + const status = build.status || "queued" + + const getStatusIcon = () => { + if (status === "success") { + return + } + if (status === "failure") { + return + } + return + } + + const getStatusColor = () => { + if (status === "success") return "text-green-400" + if (status === "failure") return "text-red-400" + return "text-blue-400" + } + + const githubActionUrl = + build.githubRunId && build.githubRunId > 0 + ? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}` + : null + + const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}` + + const handleShare = async () => { + try { + await navigator.clipboard.writeText(shareUrl) + setShareUrlCopied(true) + toast.success("Share link copied to clipboard") + setTimeout(() => setShareUrlCopied(false), 2000) + } catch { + toast.error("Failed to copy link", { + description: "Please copy the URL manually", + }) + } + } + + const generateBashCommand = (): string => { + const flags = computeFlagsFromConfig(build.config) + const target = build.config.target + const version = build.config.version + const plugins = build.config.pluginsEnabled || [] + const commands = [] + + // Generate directory name matching the download filename format (without .tar.gz extension) + const dirName = getArtifactFilenameBase(version, target, build.buildHash, build.githubRunId, "source") + + // Clone firmware repository into named directory + commands.push(`git clone --recursive https://github.com/meshtastic/firmware.git ${dirName}`) + commands.push(`cd ${dirName}`) + + // Checkout the specific version + commands.push(`git checkout ${version}`) + + // Update submodules after checkout + commands.push(`git submodule update --init --recursive`) + + // Install PlatformIO if not already installed + commands.push(`pip install platformio`) + + // Install mesh-plugin-manager + commands.push(`pip install mesh-plugin-manager`) + + // Initialize mpm + commands.push(`mpm init`) + + // Install plugins if any + if (plugins.length > 0) { + const pluginSlugs = plugins.map(plugin => { + // Extract slug from "slug@version" format if present + return plugin.includes("@") ? plugin.split("@")[0] : plugin + }) + commands.push(`mpm install ${pluginSlugs.join(" ")}`) + } + + // Set build flags and build + if (flags) { + commands.push(`export PLATFORMIO_BUILD_FLAGS="${flags}"`) + } + commands.push(`pio run -e ${target}`) + + return commands.join("\n") + } + + const handleOpenBashModal = () => { + setBashCopied(false) + setShowBashModal(true) + } + + const handleCopyBashFromModal = async () => { + try { + const bashCommand = generateBashCommand() + await navigator.clipboard.writeText(bashCommand) + setBashCopied(true) + toast.success("Bash command copied to clipboard") + setTimeout(() => setBashCopied(false), 2000) + } catch { + toast.error("Failed to copy command", { + description: "Please copy the command manually", + }) + } + } + + // Compute build flags from config (same logic as computeFlagsFromConfig in convex/builds.ts) + const computeFlagsFromConfig = (config: typeof build.config): string => { + return Object.keys(config.modulesExcluded) + .sort() + .filter(module => config.modulesExcluded[module]) + .map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`) + .join(" ") + } + + // Generate GitHub discussion URL with prefilled body + const generateDiscussionUrl = (): string => { + const flags = computeFlagsFromConfig(build.config) + const plugins = build.config.pluginsEnabled?.join(", ") || "(none)" + const timestamp = new Date(build.startedAt).toISOString() + const githubRunLink = githubActionUrl ? `[View run](${githubActionUrl})` : "(not available)" + const buildPageUrl = `${window.location.origin}/builds/${build.buildHash}` + + // Format plugins as +plugin@version + const formattedPlugins = + build.config.pluginsEnabled + ?.map(plugin => { + // Plugin might be "slug@version" or just "slug" + return plugin.includes("@") ? `+${plugin}` : `+${plugin}` + }) + .join(" ") || "" + + const bracketContent = [ + build.config.target, + `v${build.config.version}`, + ...(formattedPlugins ? [formattedPlugins] : []), + ].join(" ") + + const discussionTitle = `Build ${build.status === "failure" ? "Failed" : "Issue"}: ${targetLabel} [${bracketContent}]` + + const discussionBody = `## Build ${build.status === "failure" ? "Failed" : "Information"} + +**Build Hash**: \`${build.buildHash}\` +**Target Board**: ${build.config.target} +**Firmware Version**: ${build.config.version} +**Build Flags**: ${flags || "(none)"} +**Plugins**: ${plugins} +**Build Timestamp**: ${timestamp} + +**Build Page**: [View build page](${buildPageUrl}) +**GitHub Run**: ${githubRunLink} + +## Additional Information +(Please add any additional details about the issue here)` + + const baseUrl = "https://github.com/MeshEnvy/mesh-forge/discussions/new" + const params = new URLSearchParams({ + category: "q-a", + title: discussionTitle, + body: discussionBody, + }) + + return `${baseUrl}?${params.toString()}` + } + + const handleReportIssue = () => { + window.open(generateDiscussionUrl(), "_blank", "noopener,noreferrer") + } + + const handleRetry = async () => { + if (!build?._id || !onRetry) return + try { + await onRetry(build._id) + } catch (error) { + toast.error("Failed to retry build", { + description: String(error), + }) + } + } + + // Get excluded modules + const excludedModules = modulesData.modules.filter(module => build.config.modulesExcluded[module.id] === true) + + // Get explicitly selected plugins from stored config + // The stored config only contains explicitly selected plugins (not resolved dependencies) + const explicitPluginSlugs = (build.config.pluginsEnabled || []).map(pluginId => { + // Extract slug from "slug@version" format if present + return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId + }) + + // Get implicit dependencies (dependencies that are not explicitly selected) + const implicitDeps = getImplicitDependencies( + explicitPluginSlugs, + registryData as Record }> + ) + + // Separate explicit and implicit plugins + const explicitPlugins: Array<{ + id: string + name: string + description: string + version: string + }> = [] + const implicitPlugins: Array<{ + id: string + name: string + description: string + version: string + }> = [] + + // Process explicitly selected plugins + ;(build.config.pluginsEnabled || []).forEach(pluginId => { + // Extract slug from "slug@version" format if present + const slug = pluginId.includes("@") ? pluginId.split("@")[0] : pluginId + const pluginData = (registryData as Record)[slug] + const pluginInfo = { + id: slug, + name: pluginData?.name || slug, + description: pluginData?.description || "", + version: pluginId.includes("@") ? pluginId.split("@")[1] : pluginData?.version || "", + } + explicitPlugins.push(pluginInfo) + }) + + // Process implicit dependencies (resolved but not in stored config) + for (const slug of implicitDeps) { + const pluginData = (registryData as Record)[slug] + if (pluginData) { + implicitPlugins.push({ + id: slug, + name: pluginData.name || slug, + description: pluginData.description || "", + version: pluginData.version || "", + }) + } + } + + return ( +

+ {/* Header Section */} +
+
+
+
+

+ {getStatusIcon()} + {targetLabel} + {status !== "success" && status !== "failure" && ( + + )} +

+
+ {build.config.target} + + v{build.config.version} + + + {build.completedAt + ? new Date(build.completedAt).toLocaleString() + : new Date(build.updatedAt).toLocaleString()} + +
+
+ {showActions && ( +
+ + + +
+ )} +
+ {excludedModules.length > 0 && ( +
+ Excluded Modules: + {excludedModules.map((module, index) => ( + + {module.name} + {index < excludedModules.length - 1 && ,} + + ))} +
+ )} + {(explicitPlugins.length > 0 || implicitPlugins.length > 0) && ( +
+ Plugins: + {explicitPlugins.map((plugin, index) => ( + + + {plugin.name} + + {(index < explicitPlugins.length - 1 || implicitPlugins.length > 0) && ( + , + )} + + ))} + {implicitPlugins.map((plugin, index) => ( + + + {plugin.name} + + {index < implicitPlugins.length - 1 && ,} + + ))} +
+ )} + {build && (build.githubRunId || (build.githubRunIdHistory?.length ?? 0) > 0) && ( +
+ + Run History + {(build.githubRunIdHistory?.length ?? 0) > 0 && + ` (${(build.githubRunIdHistory?.length ?? 0) + (build.githubRunId ? 1 : 0)} total)`} + + {build.githubRunId && ( + + {build.githubRunId} + + )} + {build.githubRunIdHistory?.map(id => ( + + {id} + + ))} +
+ )} +
+
+ + {/* All Actions */} + {showActions && ( +
+ {status === "success" && build.buildHash && ( + + )} + {build.buildHash && ( + <> + + + + )} + {isAdmin && build && onRetry && ( + + )} +
+ )} + + {/* Status Messages */} + {status === "failure" && ( +
+

+ Build failed. Please try tweaking your configuration or re-running the build. +

+
+ )} + + {status !== "success" && status !== "failure" && ( +
+

+ This build is still running. Leave this tab open or come back later using the URL above. +

+
+ )} + + {/* Bash Script Modal */} + {showBashModal && ( +
{ + setShowBashModal(false) + setBashCopied(false) + }} + > +
e.stopPropagation()} + > +
+

Build Script

+ +
+
+
+                {generateBashCommand()}
+              
+
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/components/PluginCard.tsx b/components/PluginCard.tsx index 622b4bc..7e26fea 100644 --- a/components/PluginCard.tsx +++ b/components/PluginCard.tsx @@ -251,7 +251,7 @@ export function PluginCard(props: PluginCardProps) { e.stopPropagation() navigate(`/builds/new?plugin=${id}`) }} - className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors" + className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer" > Build Now diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 63db51c..a358b64 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -14,6 +14,7 @@ import type * as auth from "../auth.js"; import type * as builds from "../builds.js"; import type * as helpers from "../helpers.js"; import type * as http from "../http.js"; +import type * as lib_filename from "../lib/filename.js"; import type * as lib_r2 from "../lib/r2.js"; import type * as plugins from "../plugins.js"; import type * as profiles from "../profiles.js"; @@ -31,6 +32,7 @@ declare const fullApi: ApiFromModules<{ builds: typeof builds; helpers: typeof helpers; http: typeof http; + "lib/filename": typeof lib_filename; "lib/r2": typeof lib_r2; plugins: typeof plugins; profiles: typeof profiles; diff --git a/convex/admin.ts b/convex/admin.ts index e783ae3..7ef251f 100644 --- a/convex/admin.ts +++ b/convex/admin.ts @@ -25,7 +25,7 @@ export const listFailedBuilds = adminQuery({ handler: async ctx => { const failedBuilds = await ctx.db .query("builds") - .withIndex("by_status", q => q.eq("status", "failure")) + .withIndex("by_status_updatedAt", q => q.eq("status", "failure")) .order("desc") .collect() @@ -36,7 +36,7 @@ export const listFailedBuilds = adminQuery({ export const listAllBuilds = adminQuery({ args: {}, handler: async ctx => { - const allBuilds = await ctx.db.query("builds").order("desc").collect() + const allBuilds = await ctx.db.query("builds").withIndex("by_updatedAt").order("desc").collect() return allBuilds }, diff --git a/convex/builds.ts b/convex/builds.ts index f0bf69c..4fa84eb 100644 --- a/convex/builds.ts +++ b/convex/builds.ts @@ -3,6 +3,7 @@ import { v } from "convex/values" import { api, internal } from "./_generated/api" import type { Doc, Id } from "./_generated/dataModel" import { internalMutation, mutation, query } from "./_generated/server" +import { getArtifactFilenameBase } from "./lib/filename" import { generateSignedDownloadUrl } from "./lib/r2" import { buildFields } from "./schema" @@ -14,6 +15,7 @@ export enum ArtifactType { type BuildUpdateData = { status: string completedAt?: number + updatedAt?: number } export const get = query({ @@ -269,6 +271,7 @@ export const updateBuildStatus = internalMutation({ sourcePath?: string } = { status: args.status, + updatedAt: Date.now(), } // Only set completedAt for final statuses @@ -374,17 +377,23 @@ export const generateDownloadUrl = mutation({ }) } - const last4Hash = build.buildHash.slice(-4) - const os = "meshtastic" // OS/platform identifier - const version = build.config.version - const target = build.config.target - const jobId = build.githubRunId + // Generate base filename using shared utility function + const filenameBase = getArtifactFilenameBase( + build.config.version, + build.config.target, + build.buildHash, + build.githubRunId, + artifactTypeStr as "source" | "firmware" + ) + // Add profile slug if present (inserted between version and target) // Format: {os}-{version}-{profileSlug}-{target}-{last4hash}-{jobId}-{assetType}.tar.gz - // If no profile, omit profileSlug and its trailing dash const filename = profileSlug - ? `${os}-${version}-${profileSlug}-${target}-${last4Hash}-${jobId}-${artifactTypeStr}.tar.gz` - : `${os}-${version}-${target}-${last4Hash}-${jobId}-${artifactTypeStr}.tar.gz` + ? filenameBase.replace( + `meshtastic-${build.config.version}-`, + `meshtastic-${build.config.version}-${profileSlug}-` + ) + ".tar.gz" + : filenameBase + ".tar.gz" return await generateSignedDownloadUrl(objectKey, filename, contentType) }, diff --git a/convex/lib/filename.ts b/convex/lib/filename.ts new file mode 100644 index 0000000..4adb987 --- /dev/null +++ b/convex/lib/filename.ts @@ -0,0 +1,29 @@ +/** + * Generates the artifact filename base (without extension) matching the download filename format. + * Format: meshtastic-{version}-{target}-{last4hash}-{jobId}-{artifactType} + * This is the canonical implementation used by both Convex backend and frontend. + * + * @param version - The firmware version + * @param target - The target board name + * @param buildHash - The build hash (used to get last 4 characters) + * @param githubRunId - The GitHub Actions run ID (optional, but required for actual downloads) + * @param artifactType - Either "source" or "firmware" + * @returns The filename base without extension (e.g., "meshtastic-v2.7.16-tbeam-a1b2-1234567890-source") + */ +export function getArtifactFilenameBase( + version: string, + target: string, + buildHash: string, + githubRunId: number | undefined, + artifactType: "source" | "firmware" +): string { + const os = "meshtastic" + const last4Hash = buildHash.slice(-4) + + if (!githubRunId) { + // If no githubRunId, fallback format (for builds that haven't completed yet) + return `${os}-${version}-${target}-${last4Hash}-${artifactType}` + } + + return `${os}-${version}-${target}-${last4Hash}-${githubRunId}-${artifactType}` +} diff --git a/convex/schema.ts b/convex/schema.ts index 5afb3da..bb683a2 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -50,7 +50,11 @@ export const userSettingsFields = { export const schema = defineSchema({ ...authTables, profiles: defineTable(profileFields).index("by_userId", ["userId"]).index("by_isPublic", ["isPublic"]), - builds: defineTable(buildFields).index("by_buildHash", ["buildHash"]).index("by_status", ["status"]), + builds: defineTable(buildFields) + .index("by_buildHash", ["buildHash"]) + .index("by_status", ["status"]) + .index("by_updatedAt", ["updatedAt"]) + .index("by_status_updatedAt", ["status", "updatedAt"]), plugins: defineTable(pluginFields).index("by_slug", ["slug"]), userSettings: defineTable(userSettingsFields).index("by_user", ["userId"]), }) diff --git a/pages/admin/+Page.tsx b/pages/admin/+Page.tsx index d725a5c..9ae56cf 100644 --- a/pages/admin/+Page.tsx +++ b/pages/admin/+Page.tsx @@ -1,17 +1,30 @@ -import { BuildDownloadButton } from "@/components/BuildDownloadButton" +import { BuildProgress } from "@/components/BuildProgress" import { Button } from "@/components/ui/button" import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" -import { ArtifactType } from "@/convex/builds" import { useMutation, useQuery } from "convex/react" -import { useState } from "react" +import { useEffect, useState } from "react" import { toast } from "sonner" import { navigate } from "vike/client/router" type FilterType = "all" | "failed" +const FILTER_STORAGE_KEY = "admin-build-filter" + export default function Admin() { - const [filter, setFilter] = useState("failed") + const [filter, setFilter] = useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem(FILTER_STORAGE_KEY) + if (saved === "all" || saved === "failed") { + return saved + } + } + return "failed" + }) + + useEffect(() => { + localStorage.setItem(FILTER_STORAGE_KEY, filter) + }, [filter]) const isAdmin = useQuery(api.admin.isAdmin) const failedBuilds = useQuery(api.admin.listFailedBuilds) const allBuilds = useQuery(api.admin.listAllBuilds) @@ -51,35 +64,10 @@ export default function Admin() { toast.error("Failed to retry build", { description: String(error), }) + throw error } } - const formatDate = (timestamp: number) => { - return new Date(timestamp).toLocaleString() - } - - const getStatusBadge = (status: string) => { - const statusConfig = { - success: { - bg: "bg-green-500/20", - text: "text-green-400", - label: "Success", - }, - failure: { bg: "bg-red-500/20", text: "text-red-400", label: "Failed" }, - queued: { - bg: "bg-yellow-500/20", - text: "text-yellow-400", - label: "Queued", - }, - } - const config = statusConfig[status as keyof typeof statusConfig] || { - bg: "bg-slate-500/20", - text: "text-slate-400", - label: status, - } - return {config.label} - } - return (
@@ -113,106 +101,8 @@ export default function Admin() { ) : (
{builds.map(build => ( -
- {/* Header Section */} -
-
- - {build.buildHash.substring(0, 8)} - - {getStatusBadge(build.status)} -
-
- - - -
-
- - {/* Build Configuration Section */} -
-
-
- Target -
{build.config.target}
-
-
- Version -
{build.config.version}
-
-
-
-
- {build.completedAt ? "Completed" : "Started"} -
- {build.completedAt - ? formatDate(build.completedAt) - : build.startedAt - ? formatDate(build.startedAt) - : "Unknown"} -
-
-
-
- - {/* Run History Section */} - {(build.githubRunId || (build.githubRunIdHistory?.length ?? 0) > 0) && ( -
- - Run History - {(build.githubRunIdHistory?.length ?? 0) > 0 && - ` (${(build.githubRunIdHistory?.length ?? 0) + (build.githubRunId ? 1 : 0)} total)`} - -
- {build.githubRunId && ( - - {build.githubRunId} - - )} - {build.githubRunIdHistory?.map(id => ( - - {id} - - ))} -
-
- )} - - {/* Download Actions */} - {build.buildHash && ( -
- {build.status === "success" && } - -
- )} +
+
))}
diff --git a/pages/builds/@buildHash/+Page.tsx b/pages/builds/@buildHash/+Page.tsx index 99c110e..7a0c5c7 100644 --- a/pages/builds/@buildHash/+Page.tsx +++ b/pages/builds/@buildHash/+Page.tsx @@ -1,25 +1,16 @@ -import { BuildDownloadButton } from "@/components/BuildDownloadButton" -import { Button } from "@/components/ui/button" -import { TARGETS } from "@/constants/targets" +import { BuildProgress } from "@/components/BuildProgress" import { api } from "@/convex/_generated/api" -import { ArtifactType } from "@/convex/builds" -import modulesData from "@/convex/modules.json" -import { getImplicitDependencies, humanizeStatus } from "@/lib/utils" -import registryData from "@/public/registry.json" import { useMutation, useQuery } from "convex/react" -import { AlertCircle, ArrowLeft, CheckCircle, Loader2, Share2, XCircle } from "lucide-react" -import { useState } from "react" +import { Loader2 } from "lucide-react" import { toast } from "sonner" import { usePageContext } from "vike-react/usePageContext" -import { navigate } from "vike/client/router" -export default function BuildProgress() { +export default function BuildProgressPage() { const pageContext = usePageContext() const buildHash = pageContext.routeParams?.buildHash as string | undefined const build = useQuery(api.builds.getByHash, buildHash ? { buildHash } : "skip") const isAdmin = useQuery(api.admin.isAdmin) const retryBuild = useMutation(api.admin.retryBuild) - const [shareUrlCopied, setShareUrlCopied] = useState(false) if (!buildHash) { return ( @@ -49,10 +40,6 @@ export default function BuildProgress() { return (
- - - Back to Quick Build -

No build found for hash {buildHash} @@ -63,113 +50,9 @@ export default function BuildProgress() { ) } - const targetMeta = build.config.target ? TARGETS[build.config.target] : undefined - const targetLabel = targetMeta?.name ?? build.config.target - const status = build.status || "queued" - - const getStatusIcon = () => { - if (status === "success") { - return - } - if (status === "failure") { - return - } - return - } - - const getStatusColor = () => { - if (status === "success") return "text-green-400" - if (status === "failure") return "text-red-400" - return "text-blue-400" - } - - const githubActionUrl = - build.githubRunId && build.githubRunId > 0 - ? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}` - : null - - const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}` - - const handleShare = async () => { + const handleRetry = async (buildId: typeof build._id) => { try { - await navigator.clipboard.writeText(shareUrl) - setShareUrlCopied(true) - toast.success("Share link copied to clipboard") - setTimeout(() => setShareUrlCopied(false), 2000) - } catch { - toast.error("Failed to copy link", { - description: "Please copy the URL manually", - }) - } - } - - // Compute build flags from config (same logic as computeFlagsFromConfig in convex/builds.ts) - const computeFlagsFromConfig = (config: typeof build.config): string => { - return Object.keys(config.modulesExcluded) - .sort() - .filter(module => config.modulesExcluded[module]) - .map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`) - .join(" ") - } - - // Generate GitHub discussion URL with prefilled body - const generateDiscussionUrl = (): string => { - const flags = computeFlagsFromConfig(build.config) - const plugins = build.config.pluginsEnabled?.join(", ") || "(none)" - const timestamp = new Date(build.startedAt).toISOString() - const githubRunLink = githubActionUrl ? `[View run](${githubActionUrl})` : "(not available)" - const buildPageUrl = `${window.location.origin}/builds/${build.buildHash}` - - // Format plugins as +plugin@version - const formattedPlugins = - build.config.pluginsEnabled - ?.map(plugin => { - // Plugin might be "slug@version" or just "slug" - return plugin.includes("@") ? `+${plugin}` : `+${plugin}` - }) - .join(" ") || "" - - const bracketContent = [ - build.config.target, - `v${build.config.version}`, - ...(formattedPlugins ? [formattedPlugins] : []), - ].join(" ") - - const discussionTitle = `Build ${build.status === "failure" ? "Failed" : "Issue"}: ${targetLabel} [${bracketContent}]` - - const discussionBody = `## Build ${build.status === "failure" ? "Failed" : "Information"} - -**Build Hash**: \`${build.buildHash}\` -**Target Board**: ${build.config.target} -**Firmware Version**: ${build.config.version} -**Build Flags**: ${flags || "(none)"} -**Plugins**: ${plugins} -**Build Timestamp**: ${timestamp} - -**Build Page**: [View build page](${buildPageUrl}) -**GitHub Run**: ${githubRunLink} - -## Additional Information -(Please add any additional details about the issue here)` - - const baseUrl = "https://github.com/MeshEnvy/mesh-forge/discussions/new" - const params = new URLSearchParams({ - category: "q-a", - title: discussionTitle, - body: discussionBody, - }) - - return `${baseUrl}?${params.toString()}` - } - - const handleReportIssue = () => { - window.open(generateDiscussionUrl(), "_blank", "noopener,noreferrer") - } - - const handleRetry = async () => { - if (!build?._id) return - try { - await retryBuild({ buildId: build._id }) + await retryBuild({ buildId }) toast.success("Build retry initiated", { description: "The build has been queued with the latest YAML.", }) @@ -177,340 +60,14 @@ export default function BuildProgress() { toast.error("Failed to retry build", { description: String(error), }) - } - } - - const getStatusBadge = (status: string) => { - const statusConfig = { - success: { - bg: "bg-green-500/20", - text: "text-green-400", - label: "Success", - }, - failure: { bg: "bg-red-500/20", text: "text-red-400", label: "Failed" }, - queued: { - bg: "bg-yellow-500/20", - text: "text-yellow-400", - label: "Queued", - }, - } - const config = statusConfig[status as keyof typeof statusConfig] || { - bg: "bg-slate-500/20", - text: "text-slate-400", - label: status, - } - return {config.label} - } - - const formatDate = (timestamp: number) => { - return new Date(timestamp).toLocaleString() - } - - // Get excluded modules - const excludedModules = modulesData.modules.filter(module => build.config.modulesExcluded[module.id] === true) - - // Get explicitly selected plugins from stored config - // The stored config only contains explicitly selected plugins (not resolved dependencies) - const explicitPluginSlugs = (build.config.pluginsEnabled || []).map(pluginId => { - // Extract slug from "slug@version" format if present - return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId - }) - - // Get implicit dependencies (dependencies that are not explicitly selected) - const implicitDeps = getImplicitDependencies( - explicitPluginSlugs, - registryData as Record }> - ) - - // Separate explicit and implicit plugins - const explicitPlugins: Array<{ - id: string - name: string - description: string - version: string - }> = [] - const implicitPlugins: Array<{ - id: string - name: string - description: string - version: string - }> = [] - - // Process explicitly selected plugins - ;(build.config.pluginsEnabled || []).forEach(pluginId => { - // Extract slug from "slug@version" format if present - const slug = pluginId.includes("@") ? pluginId.split("@")[0] : pluginId - const pluginData = (registryData as Record)[slug] - const pluginInfo = { - id: slug, - name: pluginData?.name || slug, - description: pluginData?.description || "", - version: pluginId.includes("@") ? pluginId.split("@")[1] : pluginData?.version || "", - } - explicitPlugins.push(pluginInfo) - }) - - // Process implicit dependencies (resolved but not in stored config) - for (const slug of implicitDeps) { - const pluginData = (registryData as Record)[slug] - if (pluginData) { - implicitPlugins.push({ - id: slug, - name: pluginData.name || slug, - description: pluginData.description || "", - version: pluginData.version || "", - }) + throw error } } return (

- - -
-
-
- {getStatusIcon()} -
-

Target

-

{targetLabel}

-
- {humanizeStatus(status)} - - {new Date(build.updatedAt).toLocaleString()} - {githubActionUrl && ( - <> - - - View run - - - )} -
-
-
-
- - -
-
- - {/* Admin Controls Section */} - {isAdmin === true && build && ( -
-
-
-

Admin Controls

-
- - {build.buildHash.substring(0, 8)} - - {getStatusBadge(build.status)} -
-
-
- - -
-
- - {/* Build Configuration Details */} -
-
-
- Target -
{build.config.target}
-
-
- Version -
{build.config.version}
-
-
-
-
- {build.completedAt ? "Completed" : "Started"} -
- {build.completedAt - ? formatDate(build.completedAt) - : build.startedAt - ? formatDate(build.startedAt) - : "Unknown"} -
-
-
-
- - {/* Run History Section */} - {(build.githubRunId || (build.githubRunIdHistory?.length ?? 0) > 0) && ( -
- - Run History - {(build.githubRunIdHistory?.length ?? 0) > 0 && - ` (${(build.githubRunIdHistory?.length ?? 0) + (build.githubRunId ? 1 : 0)} total)`} - -
- {build.githubRunId && ( - - {build.githubRunId} - - )} - {build.githubRunIdHistory?.map(id => ( - - {id} - - ))} -
-
- )} -
- )} - - {status !== "success" && status !== "failure" && ( -
-

- Builds run in GitHub Actions. When the status is - success, your firmware artifact will be ready to - download. -

-
- )} - - {/* Build Configuration Summary */} - {(excludedModules.length > 0 || explicitPlugins.length > 0 || implicitPlugins.length > 0) && ( -
- {/* Excluded Modules */} - {excludedModules.length > 0 && ( -
-

Excluded Modules

-
-
- {excludedModules.map(module => ( -
-

{module.name}

-

{module.description}

-
- ))} -
-
-
- )} - - {/* Enabled Plugins */} - {explicitPlugins.length > 0 && ( -
-

Enabled Plugins

-
-
- {explicitPlugins.map(plugin => ( -
-
-

{plugin.name}

- {plugin.version && v{plugin.version}} -
- {plugin.description &&

{plugin.description}

} -
- ))} -
-
-
- )} - - {/* Required Plugins (Implicit Dependencies) */} - {implicitPlugins.length > 0 && ( -
-

Required Plugins

-
-
- {implicitPlugins.map(plugin => ( -
-
-

{plugin.name}

- {plugin.version && v{plugin.version}} -
- {plugin.description &&

{plugin.description}

} -
- ))} -
-
-
- )} -
- )} - - {status === "success" && build.buildHash && ( - - )} - - {build.buildHash && ( - - )} - - {status === "failure" && ( -
-

- Build failed. Please try tweaking your configuration or re-running the build. -

-
- )} - - {status !== "success" && status !== "failure" && ( -
-

- This build is still running. Leave this tab open or come back later using the URL above. -

-
- )} -
+
) diff --git a/pages/index/+Page.tsx b/pages/index/+Page.tsx index 2aa19ce..42e2a60 100644 --- a/pages/index/+Page.tsx +++ b/pages/index/+Page.tsx @@ -2,9 +2,28 @@ import { DiscordButton } from "@/components/DiscordButton" import { PluginCard } from "@/components/PluginCard" import { RedditButton } from "@/components/RedditButton" import { Button } from "@/components/ui/button" +import { api } from "@/convex/_generated/api" import registryData from "@/public/registry.json" +import { useQuery } from "convex/react" +import { useEffect, useState } from "react" import { navigate } from "vike/client/router" +function getGitHubOwnerRepo(repoUrl?: string): { owner: string; repo: string } | null { + if (!repoUrl) return null + try { + const url = new URL(repoUrl) + if (url.hostname === "github.com" || url.hostname === "www.github.com") { + const pathParts = url.pathname.split("/").filter(Boolean) + if (pathParts.length >= 2) { + return { owner: pathParts[0], repo: pathParts[1] } + } + } + } catch { + // Invalid URL + } + return null +} + function QuickBuildIcon(props: React.SVGProps) { return ( ) { } export default function LandingPage() { + const flashCounts = useQuery(api.plugins.getAll) + const [githubStars, setGithubStars] = useState>({}) + const featuredPlugins = Object.entries(registryData) .filter(([, plugin]) => plugin.featured === true) .sort(([, pluginA], [, pluginB]) => pluginA.name.localeCompare(pluginB.name)) + useEffect(() => { + // Fetch GitHub stars for featured plugins + const fetchStars = async () => { + const stars: Record = {} + const promises = featuredPlugins.map(async ([slug, plugin]) => { + const ownerRepo = getGitHubOwnerRepo(plugin.repo) + if (!ownerRepo) return + + try { + const res = await fetch(`https://api.github.com/repos/${ownerRepo.owner}/${ownerRepo.repo}`) + const data = await res.json() + if (data.stargazers_count !== undefined) { + stars[slug] = data.stargazers_count + } + } catch { + // Silently fail if GitHub API is unavailable + } + }) + + await Promise.all(promises) + setGithubStars(stars) + } + + if (featuredPlugins.length > 0) { + fetchStars() + } + }, [featuredPlugins.length]) + const customBuildPlugin = { id: "custom-build", name: "Build your own", @@ -73,7 +123,7 @@ export default function LandingPage() {

Popular Builds

-
+
{featuredPlugins.map(([slug, plugin]) => (
))} diff --git a/pages/plugins/+Page.tsx b/pages/plugins/+Page.tsx index cea884f..a7a3b23 100644 --- a/pages/plugins/+Page.tsx +++ b/pages/plugins/+Page.tsx @@ -1,8 +1,30 @@ import { PluginCard } from "@/components/PluginCard" +import { api } from "@/convex/_generated/api" import registryData from "@/public/registry.json" import { PluginDisplay } from "@/types" +import { useQuery } from "convex/react" +import { useEffect, useState } from "react" + +function getGitHubOwnerRepo(repoUrl?: string): { owner: string; repo: string } | null { + if (!repoUrl) return null + try { + const url = new URL(repoUrl) + if (url.hostname === "github.com" || url.hostname === "www.github.com") { + const pathParts = url.pathname.split("/").filter(Boolean) + if (pathParts.length >= 2) { + return { owner: pathParts[0], repo: pathParts[1] } + } + } + } catch { + // Invalid URL + } + return null +} export default function PluginsPage() { + const flashCounts = useQuery(api.plugins.getAll) + const [githubStars, setGithubStars] = useState>({}) + const plugins = Object.entries(registryData).sort(([, pluginA], [, pluginB]) => { // Featured plugins first const featuredA = pluginA.featured ?? false @@ -14,6 +36,32 @@ export default function PluginsPage() { return pluginA.name.localeCompare(pluginB.name) }) + useEffect(() => { + // Fetch GitHub stars for all plugins + const fetchStars = async () => { + const stars: Record = {} + const promises = plugins.map(async ([slug, plugin]) => { + const ownerRepo = getGitHubOwnerRepo(plugin.repo) + if (!ownerRepo) return + + try { + const res = await fetch(`https://api.github.com/repos/${ownerRepo.owner}/${ownerRepo.repo}`) + const data = await res.json() + if (data.stargazers_count !== undefined) { + stars[slug] = data.stargazers_count + } + } catch { + // Silently fail if GitHub API is unavailable + } + }) + + await Promise.all(promises) + setGithubStars(stars) + } + + fetchStars() + }, []) + return (
@@ -40,8 +88,8 @@ export default function PluginsPage() { repo={pluginDisplay.repo} homepage={pluginDisplay.homepage} version={pluginDisplay.version} - downloads={pluginDisplay.downloads} - stars={pluginDisplay.stars} + downloads={flashCounts?.[slug]} + stars={githubStars[slug]} /> ) })} diff --git a/pages/plugins/@slug/+Page.tsx b/pages/plugins/@slug/+Page.tsx new file mode 100644 index 0000000..d33d00a --- /dev/null +++ b/pages/plugins/@slug/+Page.tsx @@ -0,0 +1,173 @@ +import { Button } from "@/components/ui/button" +import { api } from "@/convex/_generated/api" +import registryData from "@/public/registry.json" +import { PluginDisplay } from "@/types" +import { useQuery } from "convex/react" +import { Download, Github, Home, Star } from "lucide-react" +import { usePageContext } from "vike-react/usePageContext" +import { navigate } from "vike/client/router" + +function getGitHubStarsBadgeUrl(repoUrl?: string): string | null { + if (!repoUrl) return null + try { + const url = new URL(repoUrl) + if (url.hostname === "github.com" || url.hostname === "www.github.com") { + const pathParts = url.pathname.split("/").filter(Boolean) + if (pathParts.length >= 2) { + const owner = pathParts[0] + const repo = pathParts[1] + return `https://img.shields.io/github/stars/${owner}/${repo}?style=flat&logo=github&logoColor=white&labelColor=rgb(0,0,0,0)&color=rgb(30,30,30)&label=★` + } + } + } catch { + // Invalid URL + } + return null +} + +export default function PluginPage() { + const pageContext = usePageContext() + const slug = pageContext.routeParams?.slug as string | undefined + const pluginStats = useQuery(api.plugins.get, slug ? { slug } : "skip") + + if (!slug) { + return ( +
+
+

Plugin slug missing.

+
+
+ ) + } + + const plugin = (registryData as Record)[slug] + const starsBadgeUrl = getGitHubStarsBadgeUrl(plugin?.repo) + + if (!plugin) { + return ( +
+
+
+

+ Plugin {slug} not found. +

+
+
+
+ ) + } + + return ( +
+
+
+
+ {plugin.imageUrl && ( + {`${plugin.name} + )} +
+
+

{plugin.name}

+ {plugin.featured && ( + + + Featured + + )} +
+

{plugin.description}

+
+ {plugin.version && ( +
+ Version: + v{plugin.version} +
+ )} + {plugin.author && ( +
+ Author: + {plugin.author} +
+ )} +
+ + {(pluginStats?.flashCount ?? 0).toLocaleString()} +
+ {starsBadgeUrl && plugin.repo && ( + + GitHub stars + + )} +
+
+
+ +
+ {plugin.repo && ( + + )} + {plugin.homepage && plugin.homepage !== plugin.repo && ( + + )} + +
+ + {plugin.dependencies && Object.keys(plugin.dependencies).length > 0 && ( +
+

Dependencies

+
+ {Object.entries(plugin.dependencies).map(([depName, depVersion]) => ( +
+ {depName}: + {depVersion} +
+ ))} +
+
+ )} + + {plugin.includes && plugin.includes.length > 0 && ( +
+

Supported Platforms

+
+ {plugin.includes.map(platform => ( + + {platform} + + ))} +
+
+ )} +
+
+
+ ) +} diff --git a/pages/plugins/@slug/+config.ts b/pages/plugins/@slug/+config.ts new file mode 100644 index 0000000..a66cffe --- /dev/null +++ b/pages/plugins/@slug/+config.ts @@ -0,0 +1,4 @@ +export default { + prerender: false, +} + diff --git a/vendor/meshscript b/vendor/meshscript index 3948dbb..ecd4331 160000 --- a/vendor/meshscript +++ b/vendor/meshscript @@ -1 +1 @@ -Subproject commit 3948dbba5e3d5f410d4a57d60dfba825a450c5a7 +Subproject commit ecd4331e66ee09ef1fd59fa2b34517e9b6ad3fbe