forked from iarv/mesh-forge
feat: enhance plugin configuration with diagnostics options and refactor build hash computation
This commit is contained in:
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { TARGETS } from "@/constants/targets"
|
import { TARGETS } from "@/constants/targets"
|
||||||
import type { Doc } from "@/convex/_generated/dataModel"
|
import type { Doc } from "@/convex/_generated/dataModel"
|
||||||
import { ArtifactType, getArtifactFilenameBase } from "@/convex/lib/filename"
|
import { ArtifactType, getArtifactFilenameBase } from "@/convex/lib/filename"
|
||||||
|
import { computeFlagsFromConfig } from "@/convex/lib/flags"
|
||||||
import modulesData from "@/convex/modules.json"
|
import modulesData from "@/convex/modules.json"
|
||||||
import { getImplicitDependencies, humanizeStatus } from "@/lib/utils"
|
import { getImplicitDependencies, humanizeStatus } from "@/lib/utils"
|
||||||
import registryData from "@/public/registry.json"
|
import registryData from "@/public/registry.json"
|
||||||
@@ -48,7 +49,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
|||||||
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
|
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const shareUrl = `${window.location.origin}/builds?clone=${build.buildHash}`
|
const shareUrl = `${window.location.origin}/builds?hash=${build.buildHash}`
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +65,10 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
|||||||
}
|
}
|
||||||
|
|
||||||
const generateBashCommand = (): string => {
|
const generateBashCommand = (): string => {
|
||||||
const flags = computeFlagsFromConfig(build.config)
|
const flags = computeFlagsFromConfig(
|
||||||
|
build.config,
|
||||||
|
registryData as Record<string, { configOptions?: Record<string, { define: string }> }>
|
||||||
|
)
|
||||||
const target = build.config.target
|
const target = build.config.target
|
||||||
const version = build.config.version
|
const version = build.config.version
|
||||||
const plugins = build.config.pluginsEnabled || []
|
const plugins = build.config.pluginsEnabled || []
|
||||||
@@ -102,9 +106,8 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set build flags and build
|
// Set build flags and build
|
||||||
if (flags) {
|
// Always export PLATFORMIO_BUILD_FLAGS (even if empty) so users can see what was used
|
||||||
commands.push(`export PLATFORMIO_BUILD_FLAGS="${flags}"`)
|
commands.push(`export PLATFORMIO_BUILD_FLAGS="${flags || ""}"`)
|
||||||
}
|
|
||||||
commands.push(`pio run -e ${target}`)
|
commands.push(`pio run -e ${target}`)
|
||||||
|
|
||||||
return commands.join("\n")
|
return commands.join("\n")
|
||||||
@@ -129,15 +132,6 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRetry = async () => {
|
const handleRetry = async () => {
|
||||||
if (!build?._id || !onRetry) return
|
if (!build?._id || !onRetry) return
|
||||||
try {
|
try {
|
||||||
@@ -216,10 +210,10 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
|||||||
<h2 className="text-2xl font-semibold mb-2 flex items-center gap-2">
|
<h2 className="text-2xl font-semibold mb-2 flex items-center gap-2">
|
||||||
{getStatusIcon()}
|
{getStatusIcon()}
|
||||||
<a
|
<a
|
||||||
href={`/builds?id=${build.buildHash}`}
|
href={`/builds?hash=${build.buildHash}`}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
navigate(`/builds?id=${build.buildHash}`)
|
navigate(`/builds?hash=${build.buildHash}`)
|
||||||
}}
|
}}
|
||||||
className="hover:text-cyan-400 transition-colors"
|
className="hover:text-cyan-400 transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
|
|||||||
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
|
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
|
||||||
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
|
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
|
||||||
const [pluginConfig, setPluginConfig] = useState<Record<string, boolean>>({})
|
const [pluginConfig, setPluginConfig] = useState<Record<string, boolean>>({})
|
||||||
|
const [pluginOptionsConfig, setPluginOptionsConfig] = useState<Record<string, Record<string, boolean>>>({})
|
||||||
const [isFlashing, setIsFlashing] = useState(false)
|
const [isFlashing, setIsFlashing] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
const [showModuleOverrides, setShowModuleOverrides] = useState(false)
|
const [showModuleOverrides, setShowModuleOverrides] = useState(false)
|
||||||
@@ -49,6 +50,12 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
|
|||||||
// Get all enabled plugins
|
// Get all enabled plugins
|
||||||
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
|
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
|
||||||
|
|
||||||
|
// Compute all enabled plugins (explicit + implicit)
|
||||||
|
const allEnabledPlugins = getDependedPlugins(
|
||||||
|
enabledPlugins,
|
||||||
|
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||||
|
)
|
||||||
|
|
||||||
// Calculate plugin compatibility
|
// Calculate plugin compatibility
|
||||||
const { compatibleTargets, filteredGroupedTargets, filteredTargetCategories } = usePluginCompatibility(
|
const { compatibleTargets, filteredGroupedTargets, filteredTargetCategories } = usePluginCompatibility(
|
||||||
enabledPlugins,
|
enabledPlugins,
|
||||||
@@ -132,6 +139,10 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
|
|||||||
setPluginConfig(pluginObj)
|
setPluginConfig(pluginObj)
|
||||||
setShowPlugins(true)
|
setShowPlugins(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.pluginConfigs) {
|
||||||
|
setPluginOptionsConfig(config.pluginConfigs)
|
||||||
|
}
|
||||||
}, [cloneHash, sharedBuild, handleSelectTarget, setActiveCategory, TARGET_CATEGORIES])
|
}, [cloneHash, sharedBuild, handleSelectTarget, setActiveCategory, TARGET_CATEGORIES])
|
||||||
|
|
||||||
const selectedTargetLabel = (selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
|
const selectedTargetLabel = (selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
|
||||||
@@ -197,6 +208,27 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTogglePluginOption = (pluginId: string, optionKey: string, enabled: boolean) => {
|
||||||
|
setPluginOptionsConfig(prev => {
|
||||||
|
const next = { ...prev }
|
||||||
|
if (!next[pluginId]) {
|
||||||
|
next[pluginId] = {}
|
||||||
|
}
|
||||||
|
const pluginOptions = { ...next[pluginId] }
|
||||||
|
if (enabled) {
|
||||||
|
pluginOptions[optionKey] = true
|
||||||
|
} else {
|
||||||
|
delete pluginOptions[optionKey]
|
||||||
|
}
|
||||||
|
if (Object.keys(pluginOptions).length === 0) {
|
||||||
|
delete next[pluginId]
|
||||||
|
} else {
|
||||||
|
next[pluginId] = pluginOptions
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleFlash = async () => {
|
const handleFlash = async () => {
|
||||||
if (!selectedTarget) return
|
if (!selectedTarget) return
|
||||||
setIsFlashing(true)
|
setIsFlashing(true)
|
||||||
@@ -214,13 +246,27 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
|
|||||||
const plugin = (registryData as Record<string, { version: string }>)[slug]
|
const plugin = (registryData as Record<string, { version: string }>)[slug]
|
||||||
return `${slug}@${plugin.version}`
|
return `${slug}@${plugin.version}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Filter plugin config to only include enabled plugins
|
||||||
|
const filteredPluginConfig = Object.keys(pluginOptionsConfig).reduce(
|
||||||
|
(acc, pluginId) => {
|
||||||
|
if (allEnabledPlugins.includes(pluginId)) {
|
||||||
|
acc[pluginId] = pluginOptionsConfig[pluginId]
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, Record<string, boolean>>
|
||||||
|
)
|
||||||
|
|
||||||
const result = await ensureBuildFromConfig({
|
const result = await ensureBuildFromConfig({
|
||||||
target: selectedTarget,
|
target: selectedTarget,
|
||||||
version: selectedVersion,
|
version: selectedVersion,
|
||||||
modulesExcluded: moduleConfig,
|
modulesExcluded: moduleConfig,
|
||||||
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined,
|
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined,
|
||||||
|
pluginConfigs: Object.keys(filteredPluginConfig).length > 0 ? filteredPluginConfig : undefined,
|
||||||
|
registryData: registryData,
|
||||||
})
|
})
|
||||||
navigate(`/builds?id=${result.buildHash}`)
|
navigate(`/builds?hash=${result.buildHash}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
setErrorMessage("Failed to start build. Please try again.")
|
setErrorMessage("Failed to start build. Please try again.")
|
||||||
@@ -273,13 +319,18 @@ export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
|
|||||||
|
|
||||||
<PluginConfig
|
<PluginConfig
|
||||||
pluginConfig={pluginConfig}
|
pluginConfig={pluginConfig}
|
||||||
|
pluginOptionsConfig={pluginOptionsConfig}
|
||||||
selectedTarget={selectedTarget}
|
selectedTarget={selectedTarget}
|
||||||
pluginParam={pluginParam}
|
pluginParam={pluginParam}
|
||||||
pluginFlashCounts={pluginFlashCounts}
|
pluginFlashCounts={pluginFlashCounts}
|
||||||
showPlugins={showPlugins}
|
showPlugins={showPlugins}
|
||||||
onToggleShow={() => setShowPlugins(prev => !prev)}
|
onToggleShow={() => setShowPlugins(prev => !prev)}
|
||||||
onTogglePlugin={handleTogglePlugin}
|
onTogglePlugin={handleTogglePlugin}
|
||||||
onReset={() => setPluginConfig({})}
|
onTogglePluginOption={handleTogglePluginOption}
|
||||||
|
onReset={() => {
|
||||||
|
setPluginConfig({})
|
||||||
|
setPluginOptionsConfig({})
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BuildActions
|
<BuildActions
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Download, Star, Zap } from "lucide-react"
|
import { Download, Star, Zap } from "lucide-react"
|
||||||
import { navigate } from "vike/client/router"
|
import { navigate } from "vike/client/router"
|
||||||
@@ -55,6 +56,10 @@ interface PluginCardLinkToggleProps extends PluginCardBaseProps {
|
|||||||
onToggle: (enabled: boolean) => void
|
onToggle: (enabled: boolean) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
enabledLabel?: string
|
enabledLabel?: string
|
||||||
|
diagnostics?: {
|
||||||
|
checked: boolean
|
||||||
|
onCheckedChange: (checked: boolean) => void
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginCardProps = PluginCardToggleProps | PluginCardLinkProps | PluginCardLinkToggleProps
|
type PluginCardProps = PluginCardToggleProps | PluginCardLinkProps | PluginCardLinkToggleProps
|
||||||
@@ -176,49 +181,35 @@ export function PluginCard(props: PluginCardProps) {
|
|||||||
{isIncompatible && incompatibleReason && (
|
{isIncompatible && incompatibleReason && (
|
||||||
<p className="text-xs text-red-400 mt-1 font-medium">{incompatibleReason}</p>
|
<p className="text-xs text-red-400 mt-1 font-medium">{incompatibleReason}</p>
|
||||||
)}
|
)}
|
||||||
|
{/* Diagnostics checkbox - only show for link-toggle variant when enabled */}
|
||||||
|
{isLinkToggle && props.isEnabled && props.diagnostics && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer group" htmlFor={`${id}-diagnostics`}>
|
||||||
|
<Checkbox
|
||||||
|
id={`${id}-diagnostics`}
|
||||||
|
checked={props.diagnostics.checked}
|
||||||
|
onCheckedChange={props.diagnostics.onCheckedChange}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-medium text-slate-300 group-hover:text-white transition-colors">
|
||||||
|
Include Diagnostics
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">Enable diagnostic logging for this plugin</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata row */}
|
{/* Footer with metadata and toggle */}
|
||||||
<div className="flex items-center gap-3 flex-wrap text-xs text-slate-400">
|
<div className="flex items-center justify-between gap-3 mt-auto pt-2 border-t border-slate-700/50">
|
||||||
{version && <span className="text-slate-500">v{version}</span>}
|
{/* Metadata row */}
|
||||||
{isLinkToggle && flashCount !== undefined && (
|
<div className="flex items-center gap-3 flex-wrap text-xs text-slate-400">
|
||||||
<div className="flex items-center gap-1">
|
{version && <span className="text-slate-500">v{version}</span>}
|
||||||
<svg
|
{isLinkToggle && flashCount !== undefined && (
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<div className="flex items-center gap-1">
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className="text-slate-400"
|
|
||||||
fill="currentColor"
|
|
||||||
role="img"
|
|
||||||
aria-label="Download"
|
|
||||||
>
|
|
||||||
<path d="m14 2l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm4 18V9h-5V4H6v16zm-6-1l-4-4h2.5v-3h3v3H16z" />
|
|
||||||
</svg>
|
|
||||||
<span>{flashCount}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isLink && downloads !== undefined && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Download className="w-3.5 h-3.5" />
|
|
||||||
<span>{downloads.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{homepage &&
|
|
||||||
homepage !== repo &&
|
|
||||||
(isLink || isLinkToggle) &&
|
|
||||||
(isLink ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={e => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
window.open(homepage, "_blank", "noopener,noreferrer")
|
|
||||||
}}
|
|
||||||
className="hover:opacity-80 transition-opacity"
|
|
||||||
aria-label="Homepage"
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="16"
|
width="16"
|
||||||
@@ -227,103 +218,131 @@ export function PluginCard(props: PluginCardProps) {
|
|||||||
className="text-slate-400"
|
className="text-slate-400"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
role="img"
|
role="img"
|
||||||
|
aria-label="Download"
|
||||||
|
>
|
||||||
|
<path d="m14 2l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm4 18V9h-5V4H6v16zm-6-1l-4-4h2.5v-3h3v3H16z" />
|
||||||
|
</svg>
|
||||||
|
<span>{flashCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLink && downloads !== undefined && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
<span>{downloads.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{homepage &&
|
||||||
|
homepage !== repo &&
|
||||||
|
(isLink || isLinkToggle) &&
|
||||||
|
(isLink ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
window.open(homepage, "_blank", "noopener,noreferrer")
|
||||||
|
}}
|
||||||
|
className="hover:opacity-80 transition-opacity"
|
||||||
aria-label="Homepage"
|
aria-label="Homepage"
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="text-slate-400"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
|
role="img"
|
||||||
/>
|
aria-label="Homepage"
|
||||||
</svg>
|
>
|
||||||
</button>
|
<path
|
||||||
) : (
|
fill="currentColor"
|
||||||
<a
|
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
|
||||||
href={homepage}
|
/>
|
||||||
target="_blank"
|
</svg>
|
||||||
rel="noopener noreferrer"
|
</button>
|
||||||
onClick={e => e.stopPropagation()}
|
) : (
|
||||||
className="hover:opacity-80 transition-opacity"
|
<a
|
||||||
>
|
href={homepage}
|
||||||
<svg
|
target="_blank"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
rel="noopener noreferrer"
|
||||||
width="16"
|
onClick={e => e.stopPropagation()}
|
||||||
height="16"
|
className="hover:opacity-80 transition-opacity"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className="text-slate-400"
|
|
||||||
fill="currentColor"
|
|
||||||
role="img"
|
|
||||||
aria-label="Homepage"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="text-slate-400"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
|
role="img"
|
||||||
/>
|
aria-label="Homepage"
|
||||||
</svg>
|
>
|
||||||
</a>
|
<path
|
||||||
))}
|
fill="currentColor"
|
||||||
{starsBadgeUrl &&
|
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
|
||||||
repo &&
|
/>
|
||||||
(isLink ? (
|
</svg>
|
||||||
<button
|
</a>
|
||||||
type="button"
|
))}
|
||||||
onClick={e => {
|
{starsBadgeUrl &&
|
||||||
e.preventDefault()
|
repo &&
|
||||||
e.stopPropagation()
|
(isLink ? (
|
||||||
window.open(repo, "_blank", "noopener,noreferrer")
|
<button
|
||||||
}}
|
type="button"
|
||||||
className="hover:opacity-80 transition-opacity"
|
onClick={e => {
|
||||||
aria-label="GitHub repository"
|
e.preventDefault()
|
||||||
>
|
e.stopPropagation()
|
||||||
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
window.open(repo, "_blank", "noopener,noreferrer")
|
||||||
</button>
|
}}
|
||||||
) : (
|
className="hover:opacity-80 transition-opacity"
|
||||||
<a
|
aria-label="GitHub repository"
|
||||||
href={repo}
|
>
|
||||||
target="_blank"
|
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
||||||
rel="noopener noreferrer"
|
</button>
|
||||||
onClick={e => e.stopPropagation()}
|
) : (
|
||||||
className="hover:opacity-80 transition-opacity"
|
<a
|
||||||
>
|
href={repo}
|
||||||
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
target="_blank"
|
||||||
</a>
|
rel="noopener noreferrer"
|
||||||
))}
|
onClick={e => e.stopPropagation()}
|
||||||
</div>
|
className="hover:opacity-80 transition-opacity"
|
||||||
{/* Build Now button - absolutely positioned in lower right */}
|
>
|
||||||
{isLink && (
|
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
||||||
<div className="absolute bottom-4 right-4 z-10">
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Toggle switch or Build Now button */}
|
||||||
|
{isLinkToggle ? (
|
||||||
|
<Switch
|
||||||
|
checked={props.isEnabled}
|
||||||
|
onCheckedChange={props.onToggle}
|
||||||
|
disabled={props.disabled}
|
||||||
|
labelLeft="Skip"
|
||||||
|
labelRight={props.enabledLabel || "Add"}
|
||||||
|
className={props.isEnabled ? "bg-green-600" : "bg-slate-600"}
|
||||||
|
/>
|
||||||
|
) : isLink ? (
|
||||||
<button
|
<button
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
navigate(`/builds?plugin=${id}`)
|
navigate(`/builds?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 cursor-pointer"
|
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 shrink-0"
|
||||||
>
|
>
|
||||||
<Zap className="w-3 h-3" />
|
<Zap className="w-3 h-3" />
|
||||||
Build Now
|
Build Now
|
||||||
</button>
|
</button>
|
||||||
</div>
|
) : null}
|
||||||
)}
|
</div>
|
||||||
{/* Toggle switch - absolutely positioned in lower right */}
|
|
||||||
{isLinkToggle && (
|
|
||||||
<div className="absolute bottom-4 right-4 z-10">
|
|
||||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
|
||||||
<Switch
|
|
||||||
checked={props.isEnabled}
|
|
||||||
onCheckedChange={props.onToggle}
|
|
||||||
disabled={props.disabled}
|
|
||||||
labelLeft="Skip"
|
|
||||||
labelRight={props.enabledLabel || "Add"}
|
|
||||||
className={props.isEnabled ? "bg-green-600" : "bg-slate-600"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
const baseClassName = `relative flex ${isToggle ? "items-start gap-4" : "flex-col gap-3"} p-4 rounded-lg border-2 transition-colors h-full ${
|
const baseClassName = `relative flex ${isToggle ? "items-start gap-4" : "flex-col"} p-4 rounded-lg border-2 transition-colors h-full ${
|
||||||
isIncompatible
|
isIncompatible
|
||||||
? "border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed"
|
? "border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed"
|
||||||
: prominent
|
: prominent
|
||||||
|
|||||||
@@ -10,23 +10,27 @@ import { ChevronDown, ChevronRight } from "lucide-react"
|
|||||||
|
|
||||||
interface PluginConfigProps {
|
interface PluginConfigProps {
|
||||||
pluginConfig: Record<string, boolean>
|
pluginConfig: Record<string, boolean>
|
||||||
|
pluginOptionsConfig: Record<string, Record<string, boolean>>
|
||||||
selectedTarget: string
|
selectedTarget: string
|
||||||
pluginParam?: string
|
pluginParam?: string
|
||||||
pluginFlashCounts: Record<string, number>
|
pluginFlashCounts: Record<string, number>
|
||||||
showPlugins: boolean
|
showPlugins: boolean
|
||||||
onToggleShow: () => void
|
onToggleShow: () => void
|
||||||
onTogglePlugin: (id: string, enabled: boolean) => void
|
onTogglePlugin: (id: string, enabled: boolean) => void
|
||||||
|
onTogglePluginOption: (pluginId: string, optionKey: string, enabled: boolean) => void
|
||||||
onReset: () => void
|
onReset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PluginConfig({
|
export function PluginConfig({
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
|
pluginOptionsConfig,
|
||||||
selectedTarget,
|
selectedTarget,
|
||||||
pluginParam,
|
pluginParam,
|
||||||
pluginFlashCounts,
|
pluginFlashCounts,
|
||||||
showPlugins,
|
showPlugins,
|
||||||
onToggleShow,
|
onToggleShow,
|
||||||
onTogglePlugin,
|
onTogglePlugin,
|
||||||
|
onTogglePluginOption,
|
||||||
onReset,
|
onReset,
|
||||||
}: PluginConfigProps) {
|
}: PluginConfigProps) {
|
||||||
const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length
|
const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length
|
||||||
@@ -86,13 +90,13 @@ export function PluginConfig({
|
|||||||
{Object.entries(registryData)
|
{Object.entries(registryData)
|
||||||
.sort(([, pluginA], [, pluginB]) => {
|
.sort(([, pluginA], [, pluginB]) => {
|
||||||
// Featured plugins first
|
// Featured plugins first
|
||||||
const featuredA = pluginA.featured ?? false
|
const featuredA = (pluginA as { featured?: boolean }).featured ?? false
|
||||||
const featuredB = pluginB.featured ?? false
|
const featuredB = (pluginB as { featured?: boolean }).featured ?? false
|
||||||
if (featuredA !== featuredB) {
|
if (featuredA !== featuredB) {
|
||||||
return featuredA ? -1 : 1
|
return featuredA ? -1 : 1
|
||||||
}
|
}
|
||||||
// Then alphabetical by name
|
// Then alphabetical by name
|
||||||
return pluginA.name.localeCompare(pluginB.name)
|
return (pluginA as { name: string }).name.localeCompare((pluginB as { name: string }).name)
|
||||||
})
|
})
|
||||||
.map(([slug, plugin]) => {
|
.map(([slug, plugin]) => {
|
||||||
// Check if plugin is required by another explicitly selected plugin
|
// Check if plugin is required by another explicitly selected plugin
|
||||||
@@ -129,6 +133,12 @@ export function PluginConfig({
|
|||||||
// Check if this is the preselected plugin from URL
|
// Check if this is the preselected plugin from URL
|
||||||
const isPreselected = pluginParam === slug
|
const isPreselected = pluginParam === slug
|
||||||
|
|
||||||
|
const pluginRegistry = plugin as {
|
||||||
|
featured?: boolean
|
||||||
|
}
|
||||||
|
const isPluginEnabled = allEnabledPlugins.includes(slug)
|
||||||
|
const pluginOptions = pluginOptionsConfig[slug] ?? {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PluginCard
|
<PluginCard
|
||||||
key={`${slug}-${selectedTarget}`}
|
key={`${slug}-${selectedTarget}`}
|
||||||
@@ -137,16 +147,24 @@ export function PluginConfig({
|
|||||||
name={plugin.name}
|
name={plugin.name}
|
||||||
description={plugin.description}
|
description={plugin.description}
|
||||||
imageUrl={plugin.imageUrl}
|
imageUrl={plugin.imageUrl}
|
||||||
isEnabled={allEnabledPlugins.includes(slug)}
|
isEnabled={isPluginEnabled}
|
||||||
onToggle={enabled => onTogglePlugin(slug, enabled)}
|
onToggle={enabled => onTogglePlugin(slug, enabled)}
|
||||||
disabled={isImplicit || isIncompatible || isPreselected}
|
disabled={isImplicit || isIncompatible || isPreselected}
|
||||||
enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"}
|
enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"}
|
||||||
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
|
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
|
||||||
featured={plugin.featured ?? false}
|
featured={pluginRegistry.featured ?? false}
|
||||||
flashCount={pluginFlashCounts[slug] ?? 0}
|
flashCount={pluginFlashCounts[slug] ?? 0}
|
||||||
homepage={plugin.homepage}
|
homepage={plugin.homepage}
|
||||||
version={plugin.version}
|
version={plugin.version}
|
||||||
repo={plugin.repo}
|
repo={plugin.repo}
|
||||||
|
diagnostics={
|
||||||
|
isPluginEnabled
|
||||||
|
? {
|
||||||
|
checked: pluginOptions.diagnostics ?? false,
|
||||||
|
onCheckedChange: checked => onTogglePluginOption(slug, "diagnostics", checked === true),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -15,6 +15,7 @@ import type * as builds from "../builds.js";
|
|||||||
import type * as helpers from "../helpers.js";
|
import type * as helpers from "../helpers.js";
|
||||||
import type * as http from "../http.js";
|
import type * as http from "../http.js";
|
||||||
import type * as lib_filename from "../lib/filename.js";
|
import type * as lib_filename from "../lib/filename.js";
|
||||||
|
import type * as lib_flags from "../lib/flags.js";
|
||||||
import type * as lib_r2 from "../lib/r2.js";
|
import type * as lib_r2 from "../lib/r2.js";
|
||||||
import type * as plugins from "../plugins.js";
|
import type * as plugins from "../plugins.js";
|
||||||
import type * as profiles from "../profiles.js";
|
import type * as profiles from "../profiles.js";
|
||||||
@@ -33,6 +34,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
helpers: typeof helpers;
|
helpers: typeof helpers;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
"lib/filename": typeof lib_filename;
|
"lib/filename": typeof lib_filename;
|
||||||
|
"lib/flags": typeof lib_flags;
|
||||||
"lib/r2": typeof lib_r2;
|
"lib/r2": typeof lib_r2;
|
||||||
plugins: typeof plugins;
|
plugins: typeof plugins;
|
||||||
profiles: typeof profiles;
|
profiles: typeof profiles;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api, internal } from "./_generated/api"
|
|||||||
import type { Doc, Id } from "./_generated/dataModel"
|
import type { Doc, Id } from "./_generated/dataModel"
|
||||||
import { internalMutation, mutation, query } from "./_generated/server"
|
import { internalMutation, mutation, query } from "./_generated/server"
|
||||||
import { ArtifactType, getArtifactFilenameBase } from "./lib/filename"
|
import { ArtifactType, getArtifactFilenameBase } from "./lib/filename"
|
||||||
|
import { computeFlagsFromConfig } from "./lib/flags"
|
||||||
import { generateSignedDownloadUrl } from "./lib/r2"
|
import { generateSignedDownloadUrl } from "./lib/r2"
|
||||||
import { buildFields } from "./schema"
|
import { buildFields } from "./schema"
|
||||||
|
|
||||||
@@ -31,18 +32,8 @@ export const getByHash = query({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
// Re-export for backward compatibility
|
||||||
* Computes flags string from build config.
|
export { computeFlagsFromConfig } from "./lib/flags"
|
||||||
* Only excludes modules explicitly marked as excluded (config[id] === true).
|
|
||||||
*/
|
|
||||||
export function computeFlagsFromConfig(config: Doc<"builds">["config"]): string {
|
|
||||||
// Sort modules to ensure consistent order
|
|
||||||
return Object.keys(config.modulesExcluded)
|
|
||||||
.sort()
|
|
||||||
.filter(module => config.modulesExcluded[module])
|
|
||||||
.map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`)
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes a byte array to base62 string.
|
* Encodes a byte array to base62 string.
|
||||||
@@ -77,16 +68,39 @@ async function computeBuildHashInternal(
|
|||||||
version: string,
|
version: string,
|
||||||
target: string,
|
target: string,
|
||||||
flags: string,
|
flags: string,
|
||||||
plugins: string[]
|
plugins: string[],
|
||||||
|
pluginConfig?: Record<string, Record<string, boolean>>
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Input is now the exact parameters used for the build
|
// Input is now the exact parameters used for the build
|
||||||
// Sort plugins array for consistent hashing
|
// Sort plugins array for consistent hashing
|
||||||
const sortedPlugins = [...plugins].sort()
|
const sortedPlugins = [...plugins].sort()
|
||||||
|
// Sort plugin config for consistent hashing
|
||||||
|
const sortedPluginConfig = pluginConfig
|
||||||
|
? Object.keys(pluginConfig)
|
||||||
|
.sort()
|
||||||
|
.reduce(
|
||||||
|
(acc, pluginSlug) => {
|
||||||
|
const sortedOptions = Object.keys(pluginConfig[pluginSlug])
|
||||||
|
.sort()
|
||||||
|
.reduce(
|
||||||
|
(opts, optKey) => {
|
||||||
|
opts[optKey] = pluginConfig[pluginSlug][optKey]
|
||||||
|
return opts
|
||||||
|
},
|
||||||
|
{} as Record<string, boolean>
|
||||||
|
)
|
||||||
|
acc[pluginSlug] = sortedOptions
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, Record<string, boolean>>
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
const input = JSON.stringify({
|
const input = JSON.stringify({
|
||||||
version,
|
version,
|
||||||
target,
|
target,
|
||||||
flags,
|
flags,
|
||||||
plugins: sortedPlugins,
|
plugins: sortedPlugins,
|
||||||
|
pluginConfig: sortedPluginConfig,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use Web Crypto API for SHA-256 hashing
|
// Use Web Crypto API for SHA-256 hashing
|
||||||
@@ -103,10 +117,13 @@ async function computeBuildHashInternal(
|
|||||||
* Computes buildHash from build config.
|
* Computes buildHash from build config.
|
||||||
* This is the single source of truth for build hash computation.
|
* This is the single source of truth for build hash computation.
|
||||||
*/
|
*/
|
||||||
export async function computeBuildHash(config: Doc<"builds">["config"]): Promise<{ hash: string; flags: string }> {
|
export async function computeBuildHash(
|
||||||
const flags = computeFlagsFromConfig(config)
|
config: Doc<"builds">["config"],
|
||||||
|
registryData?: Record<string, { configOptions?: Record<string, { define: string }> }>
|
||||||
|
): Promise<{ hash: string; flags: string }> {
|
||||||
|
const flags = computeFlagsFromConfig(config, registryData)
|
||||||
const plugins = config.pluginsEnabled ?? []
|
const plugins = config.pluginsEnabled ?? []
|
||||||
const hash = await computeBuildHashInternal(config.version, config.target, flags, plugins)
|
const hash = await computeBuildHashInternal(config.version, config.target, flags, plugins, config.pluginConfigs)
|
||||||
return { hash, flags }
|
return { hash, flags }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +202,8 @@ export const ensureBuildFromConfig = mutation({
|
|||||||
version: v.string(),
|
version: v.string(),
|
||||||
modulesExcluded: v.optional(v.record(v.string(), v.boolean())),
|
modulesExcluded: v.optional(v.record(v.string(), v.boolean())),
|
||||||
pluginsEnabled: v.optional(v.array(v.string())),
|
pluginsEnabled: v.optional(v.array(v.string())),
|
||||||
|
pluginConfigs: v.optional(v.record(v.string(), v.record(v.string(), v.boolean()))),
|
||||||
|
registryData: v.optional(v.any()),
|
||||||
profileName: v.optional(v.string()),
|
profileName: v.optional(v.string()),
|
||||||
profileDescription: v.optional(v.string()),
|
profileDescription: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
@@ -195,10 +214,15 @@ export const ensureBuildFromConfig = mutation({
|
|||||||
modulesExcluded: args.modulesExcluded ?? {},
|
modulesExcluded: args.modulesExcluded ?? {},
|
||||||
target: args.target,
|
target: args.target,
|
||||||
pluginsEnabled: args.pluginsEnabled,
|
pluginsEnabled: args.pluginsEnabled,
|
||||||
|
pluginConfigs: args.pluginConfigs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute build hash (single source of truth)
|
// Compute build hash (single source of truth)
|
||||||
const { hash: buildHash, flags } = await computeBuildHash(config)
|
// Registry data is optional - diagnostics works for all plugins without registry lookup
|
||||||
|
const registryData = args.registryData as
|
||||||
|
| Record<string, { configOptions?: Record<string, { define: string }> }>
|
||||||
|
| undefined
|
||||||
|
const { hash: buildHash, flags } = await computeBuildHash(config, registryData)
|
||||||
|
|
||||||
const existingBuild = await ctx.db
|
const existingBuild = await ctx.db
|
||||||
.query("builds")
|
.query("builds")
|
||||||
|
|||||||
52
convex/lib/flags.ts
Normal file
52
convex/lib/flags.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Doc } from "../_generated/dataModel"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes flags string from build config.
|
||||||
|
* Only excludes modules explicitly marked as excluded (config[id] === true).
|
||||||
|
* Also includes plugin config options (e.g., diagnostics).
|
||||||
|
*
|
||||||
|
* @param config - Build configuration with modulesExcluded and pluginConfigs
|
||||||
|
* @param registryData - Optional registry data for custom config options (not needed for diagnostics)
|
||||||
|
*/
|
||||||
|
export function computeFlagsFromConfig(
|
||||||
|
config: Doc<"builds">["config"],
|
||||||
|
registryData?: Record<string, { configOptions?: Record<string, { define: string }> }>
|
||||||
|
): string {
|
||||||
|
const flags: string[] = []
|
||||||
|
|
||||||
|
// Sort modules to ensure consistent order
|
||||||
|
const moduleFlags = Object.keys(config.modulesExcluded)
|
||||||
|
.sort()
|
||||||
|
.filter(module => config.modulesExcluded[module])
|
||||||
|
.map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`)
|
||||||
|
flags.push(...moduleFlags)
|
||||||
|
|
||||||
|
// Add plugin config options (diagnostics is available for all plugins)
|
||||||
|
if (config.pluginConfigs) {
|
||||||
|
for (const [pluginSlug, pluginOptions] of Object.entries(config.pluginConfigs)) {
|
||||||
|
// Handle diagnostics option (available for all plugins)
|
||||||
|
if (pluginOptions.diagnostics) {
|
||||||
|
// Convert plugin slug to uppercase define name (e.g., "lofs" -> "LOFS_PLUGIN_DIAGNOSTICS")
|
||||||
|
const defineName = `${pluginSlug.toUpperCase().replace(/-/g, "_")}_PLUGIN_DIAGNOSTICS`
|
||||||
|
flags.push(`-D${defineName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other custom config options from registry (if any)
|
||||||
|
if (registryData) {
|
||||||
|
const plugin = registryData[pluginSlug]
|
||||||
|
if (plugin?.configOptions) {
|
||||||
|
for (const [optionKey, enabled] of Object.entries(pluginOptions)) {
|
||||||
|
if (optionKey !== "diagnostics" && enabled) {
|
||||||
|
const option = plugin.configOptions[optionKey]
|
||||||
|
if (option?.define) {
|
||||||
|
flags.push(`-D${option.define}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags.join(" ")
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export const buildConfigFields = {
|
|||||||
modulesExcluded: v.record(v.string(), v.boolean()),
|
modulesExcluded: v.record(v.string(), v.boolean()),
|
||||||
target: v.string(),
|
target: v.string(),
|
||||||
pluginsEnabled: v.optional(v.array(v.string())),
|
pluginsEnabled: v.optional(v.array(v.string())),
|
||||||
|
pluginConfigs: v.optional(v.record(v.string(), v.record(v.string(), v.boolean()))),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const profileFields = {
|
export const profileFields = {
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ export default function BuildsPage() {
|
|||||||
const pageContext = usePageContext()
|
const pageContext = usePageContext()
|
||||||
const urlSearchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null
|
const urlSearchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null
|
||||||
const cloneHash = urlSearchParams?.get("clone")
|
const cloneHash = urlSearchParams?.get("clone")
|
||||||
const buildId = urlSearchParams?.get("id")
|
const buildHash = urlSearchParams?.get("hash")
|
||||||
const pluginParam = urlSearchParams?.get("plugin")
|
const pluginParam = urlSearchParams?.get("plugin")
|
||||||
|
|
||||||
// If we have a build ID, show the build progress page
|
// If we have a build hash, show the build progress page
|
||||||
if (buildId) {
|
if (buildHash) {
|
||||||
return <BuildViewPage buildHash={buildId} />
|
return <BuildViewPage buildHash={buildHash} />
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, show the builder (handles clone and plugin params)
|
// Otherwise, show the builder (handles clone and plugin params)
|
||||||
|
|||||||
Reference in New Issue
Block a user