mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-03-28 17:42:55 +01:00
486 lines
19 KiB
TypeScript
486 lines
19 KiB
TypeScript
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, getArtifactFilenameBase } from "@/convex/lib/filename"
|
|
import { computeFlagsFromConfig } from "@/convex/lib/flags"
|
|
import modulesData from "@/convex/modules.json"
|
|
import { getImplicitDependencies, humanizeStatus } from "@/lib/utils"
|
|
import registryData from "@/public/registry.json"
|
|
import { 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<void>
|
|
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 <CheckCircle className="w-6 h-6 text-green-500" />
|
|
}
|
|
if (status === "failure") {
|
|
return <XCircle className="w-6 h-6 text-red-500" />
|
|
}
|
|
return <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
|
|
}
|
|
|
|
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?hash=${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,
|
|
registryData as Record<string, { configOptions?: Record<string, { define: string }> }>
|
|
)
|
|
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
|
|
// Always export PLATFORMIO_BUILD_FLAGS (even if empty) so users can see what was used
|
|
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",
|
|
})
|
|
}
|
|
}
|
|
|
|
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<string, { dependencies?: Record<string, string> }>
|
|
)
|
|
|
|
// 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<string, { name: string; description: string; version: string }>)[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<string, { name: string; description: string; version: string }>)[slug]
|
|
if (pluginData) {
|
|
implicitPlugins.push({
|
|
id: slug,
|
|
name: pluginData.name || slug,
|
|
description: pluginData.description || "",
|
|
version: pluginData.version || "",
|
|
})
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="bg-slate-900/60 rounded-lg border border-slate-800 p-6 space-y-6">
|
|
{/* Header Section */}
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex-1">
|
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold mb-2 flex items-center gap-2">
|
|
{getStatusIcon()}
|
|
<a
|
|
href={`/builds?hash=${build.buildHash}`}
|
|
onClick={e => {
|
|
e.preventDefault()
|
|
navigate(`/builds?hash=${build.buildHash}`)
|
|
}}
|
|
className="hover:text-cyan-400 transition-colors"
|
|
>
|
|
{targetLabel}
|
|
</a>
|
|
{status !== "success" && status !== "failure" && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (githubActionUrl) {
|
|
window.open(githubActionUrl, "_blank", "noopener,noreferrer")
|
|
}
|
|
}}
|
|
disabled={!githubActionUrl}
|
|
className={`inline-flex items-center gap-1 px-3 py-0.5 rounded-full text-xs font-semibold ${getStatusColor()} bg-slate-800 border border-slate-700 hover:bg-slate-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
style={{ cursor: githubActionUrl ? "pointer" : "not-allowed" }}
|
|
title={githubActionUrl ? "View GitHub Actions run" : "GitHub run not available"}
|
|
>
|
|
{humanizeStatus(status)}
|
|
</button>
|
|
)}
|
|
</h2>
|
|
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
|
<span className="font-mono">{build.config.target}</span>
|
|
<span>•</span>
|
|
<span>v{build.config.version}</span>
|
|
<span>•</span>
|
|
<span>
|
|
{build.completedAt
|
|
? new Date(build.completedAt).toLocaleString()
|
|
: new Date(build.updatedAt).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{showActions && (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={() => navigate(`/builds?clone=${build.buildHash}`)}
|
|
variant="outline"
|
|
className="border-slate-600 hover:bg-slate-800"
|
|
aria-label="Clone"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 512 512"
|
|
className="w-4 h-4"
|
|
fill="currentColor"
|
|
>
|
|
<path d="M472 16H160a24.027 24.027 0 0 0-24 24v312a24.027 24.027 0 0 0 24 24h312a24.027 24.027 0 0 0 24-24V40a24.027 24.027 0 0 0-24-24m-8 328H168V48h296Z" />
|
|
<path d="M344 464H48V168h56v-32H40a24.027 24.027 0 0 0-24 24v312a24.027 24.027 0 0 0 24 24h312a24.027 24.027 0 0 0 24-24v-64h-32Z" />
|
|
</svg>
|
|
</Button>
|
|
<Button
|
|
onClick={handleShare}
|
|
variant="outline"
|
|
className="border-slate-600 hover:bg-slate-800"
|
|
aria-label={shareUrlCopied ? "Copied!" : "Share Build"}
|
|
>
|
|
<Share2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{excludedModules.length > 0 && (
|
|
<div className="flex flex-wrap items-center gap-2 text-sm mt-2">
|
|
<span className="text-slate-500">Excluded Modules:</span>
|
|
{excludedModules.map((module, index) => (
|
|
<span key={module.id}>
|
|
<span className="text-slate-300">{module.name}</span>
|
|
{index < excludedModules.length - 1 && <span className="text-slate-500">,</span>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{(explicitPlugins.length > 0 || implicitPlugins.length > 0) && (
|
|
<div className="flex flex-wrap items-center gap-2 text-sm mt-2">
|
|
<span className="text-slate-500">Plugins:</span>
|
|
{explicitPlugins.map((plugin, index) => (
|
|
<span key={plugin.id}>
|
|
<a href={`/plugins/${plugin.id}`} className="text-cyan-400 hover:text-cyan-300 underline">
|
|
{plugin.name}
|
|
</a>
|
|
{(index < explicitPlugins.length - 1 || implicitPlugins.length > 0) && (
|
|
<span className="text-slate-500">,</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
{implicitPlugins.map((plugin, index) => (
|
|
<span key={plugin.id}>
|
|
<a href={`/plugins/${plugin.id}`} className="text-cyan-400 hover:text-cyan-300 underline">
|
|
{plugin.name}
|
|
</a>
|
|
{index < implicitPlugins.length - 1 && <span className="text-slate-500">,</span>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{build && (build.githubRunId || (build.githubRunIdHistory?.length ?? 0) > 0) && (
|
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
|
|
<span className="text-slate-500">
|
|
Run History
|
|
{(build.githubRunIdHistory?.length ?? 0) > 0 &&
|
|
` (${(build.githubRunIdHistory?.length ?? 0) + (build.githubRunId ? 1 : 0)} total)`}
|
|
</span>
|
|
{build.githubRunId && (
|
|
<a
|
|
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-cyan-400 hover:text-cyan-300 underline font-semibold"
|
|
title="Current run"
|
|
>
|
|
{build.githubRunId}
|
|
</a>
|
|
)}
|
|
{build.githubRunIdHistory?.map(id => (
|
|
<a
|
|
key={id}
|
|
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${id}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-cyan-400 hover:text-cyan-300 underline"
|
|
>
|
|
{id}
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* All Actions */}
|
|
{showActions && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{status === "success" && build.buildHash && (
|
|
<BuildDownloadButton
|
|
build={build}
|
|
type={ArtifactType.Firmware}
|
|
className="bg-cyan-600 hover:bg-cyan-700"
|
|
compact={true}
|
|
/>
|
|
)}
|
|
{build.buildHash && (
|
|
<>
|
|
<BuildDownloadButton
|
|
build={build}
|
|
type={ArtifactType.Source}
|
|
variant="outline"
|
|
className="bg-slate-700 hover:bg-slate-600"
|
|
compact={true}
|
|
/>
|
|
<Button onClick={handleOpenBashModal} variant="outline" className="bg-slate-700 hover:bg-slate-600">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
className="w-4 h-4 mr-2"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.5"
|
|
>
|
|
<path d="m6.75 7.5l3 2.25l-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25" />
|
|
</svg>
|
|
Local Build Script (Bash)
|
|
</Button>
|
|
</>
|
|
)}
|
|
{isAdmin && build && onRetry && (
|
|
<Button onClick={handleRetry} className="bg-cyan-600 hover:bg-cyan-700">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
className="w-4 h-4 mr-2"
|
|
fill="currentColor"
|
|
>
|
|
<path d="M11.896 18a.75.75 0 0 1-.75.75c-3.792 0-6.896-3.005-6.896-6.75s3.104-6.75 6.896-6.75c3.105 0 5.749 2.015 6.605 4.801l.603-1.02a.75.75 0 0 1 1.292.763l-1.63 2.755a.75.75 0 0 1-1.014.272L14.18 11.23a.75.75 0 1 1 .737-1.307l1.472.83c-.574-2.288-2.691-4.003-5.242-4.003C8.149 6.75 5.75 9.117 5.75 12s2.399 5.25 5.396 5.25a.75.75 0 0 1 .75.75" />
|
|
</svg>
|
|
Retry Build
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Status Messages */}
|
|
{status === "failure" && (
|
|
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-4 text-sm text-red-100">
|
|
<p className="font-medium text-red-200">
|
|
Build failed. Please try tweaking your configuration or re-running the build.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{status !== "success" && status !== "failure" && (
|
|
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 text-sm text-blue-100">
|
|
<p className="font-medium text-blue-200">
|
|
This build is still running. Leave this tab open or come back later using the URL above.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bash Script Modal */}
|
|
{showBashModal && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
onClick={() => {
|
|
setShowBashModal(false)
|
|
setBashCopied(false)
|
|
}}
|
|
>
|
|
<div
|
|
className="bg-slate-900 border border-slate-700 rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[80vh] flex flex-col"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between p-4 border-b border-slate-800">
|
|
<h3 className="text-lg font-semibold text-white">Build Script</h3>
|
|
<button
|
|
onClick={() => {
|
|
setShowBashModal(false)
|
|
setBashCopied(false)
|
|
}}
|
|
className="text-slate-400 hover:text-white transition-colors"
|
|
aria-label="Close"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="p-4 overflow-auto flex-1">
|
|
<pre className="bg-slate-950 border border-slate-800 rounded p-4 text-sm text-slate-300 font-mono overflow-x-auto">
|
|
<code>{generateBashCommand()}</code>
|
|
</pre>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 p-4 border-t border-slate-800">
|
|
<Button
|
|
onClick={() => {
|
|
setShowBashModal(false)
|
|
setBashCopied(false)
|
|
}}
|
|
variant="outline"
|
|
className="border-slate-600 hover:bg-slate-800"
|
|
>
|
|
Close
|
|
</Button>
|
|
<Button onClick={handleCopyBashFromModal} className="bg-cyan-600 hover:bg-cyan-700">
|
|
{bashCopied ? (
|
|
<>
|
|
<CheckCircle className="w-4 h-4 mr-2" />
|
|
Copied!
|
|
</>
|
|
) : (
|
|
<>
|
|
<Copy className="w-4 h-4 mr-2" />
|
|
Copy Script
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|