feat: refactor build and profile management by introducing build configuration schema, updating related functions, and enhancing routing for build creation and progress tracking

This commit is contained in:
Ben Allfree
2025-11-26 05:24:26 -08:00
parent 273fac6652
commit 4a8625f69b
9 changed files with 680 additions and 74 deletions
+6
View File
@@ -9,6 +9,8 @@ import {
} from 'react-router-dom'
import { Toaster } from '@/components/ui/sonner'
import Navbar from './components/Navbar'
import BuildNew from './pages/BuildNew'
import BuildProgress from './pages/BuildProgress'
import Dashboard from './pages/Dashboard'
import LandingPage from './pages/LandingPage'
import ProfileDetail from './pages/ProfileDetail'
@@ -36,6 +38,8 @@ function App() {
<ConditionalNavbar />
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/builds/new" element={<BuildNew />} />
<Route path="/builds/:buildHash" element={<BuildProgress />} />
<Route path="/profiles/:id" element={<ProfileDetail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -46,6 +50,8 @@ function App() {
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/builds/new" element={<BuildNew />} />
<Route path="/builds/:buildHash" element={<BuildProgress />} />
<Route
path="/dashboard/profiles/:id"
element={<ProfileEditorPage />}
+316
View File
@@ -0,0 +1,316 @@
import { useMutation } from 'convex/react'
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { ModuleToggle } from '@/components/ModuleToggle'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import modulesData from '../../convex/modules.json'
import { TARGETS } from '../constants/targets'
import { VERSIONS } from '../constants/versions'
type TargetGroup = (typeof TARGETS)[string] & { id: string }
const GROUPED_TARGETS = Object.entries(TARGETS).reduce(
(acc, [id, meta]) => {
const category = meta.category || 'Other'
if (!acc[category]) acc[category] = []
acc[category].push({ id, ...meta })
return acc
},
{} as Record<string, TargetGroup[]>
)
const TARGET_CATEGORIES = Object.keys(GROUPED_TARGETS).sort((a, b) =>
a.localeCompare(b)
)
const DEFAULT_TARGET =
TARGET_CATEGORIES.length > 0 && GROUPED_TARGETS[TARGET_CATEGORIES[0]]?.length
? GROUPED_TARGETS[TARGET_CATEGORIES[0]][0].id
: ''
export default function BuildNew() {
const navigate = useNavigate()
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const STORAGE_KEY = 'quick_build_target'
const persistTargetSelection = (targetId: string) => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(STORAGE_KEY, targetId)
} catch (error) {
console.error('Failed to persist target selection', error)
}
}
const [activeCategory, setActiveCategory] = useState<string>(
TARGET_CATEGORIES[0] ?? ''
)
const [selectedTarget, setSelectedTarget] = useState<string>(DEFAULT_TARGET)
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
const [isFlashing, setIsFlashing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [showModuleOverrides, setShowModuleOverrides] = useState(false)
useEffect(() => {
if (!activeCategory && TARGET_CATEGORIES.length > 0) {
setActiveCategory(TARGET_CATEGORIES[0])
}
}, [activeCategory])
useEffect(() => {
if (!selectedTarget && activeCategory) {
const first = GROUPED_TARGETS[activeCategory]?.[0]?.id
if (first) {
setSelectedTarget(first)
}
}
}, [selectedTarget, activeCategory])
useEffect(() => {
if (typeof window === 'undefined') return
try {
const savedTarget = localStorage.getItem(STORAGE_KEY)
if (savedTarget && TARGETS[savedTarget]) {
setSelectedTarget(savedTarget)
const category = TARGETS[savedTarget].category || 'Other'
if (TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
} catch (error) {
console.error('Failed to read saved target', error)
}
}, [])
const handleSelectTarget = (targetId: string) => {
setSelectedTarget(targetId)
persistTargetSelection(targetId)
const category = TARGETS[targetId]?.category || 'Other'
if (category && TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
useEffect(() => {
if (typeof window === 'undefined' || !selectedTarget) return
try {
if (!window.localStorage.getItem(STORAGE_KEY)) {
window.localStorage.setItem(STORAGE_KEY, selectedTarget)
}
} catch (error) {
console.error('Failed to initialize target storage', error)
}
}, [selectedTarget])
const moduleCount = Object.keys(moduleConfig).length
const selectedTargetLabel =
(selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
const handleToggleModule = (id: string, excluded: boolean) => {
setModuleConfig((prev) => {
const next = { ...prev }
if (excluded) {
next[id] = true
} else {
delete next[id]
}
return next
})
}
const handleFlash = async () => {
if (!selectedTarget) return
setIsFlashing(true)
setErrorMessage(null)
try {
const result = await ensureBuildFromConfig({
target: selectedTarget,
version: selectedVersion,
modulesExcluded: moduleConfig,
})
navigate(`/builds/${result.buildHash}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setErrorMessage('Failed to start build. Please try again.')
toast.error('Failed to start build', {
description: message,
})
} finally {
setIsFlashing(false)
}
}
const isFlashDisabled = !selectedTarget || isFlashing
return (
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10">
<div className="max-w-6xl mx-auto space-y-8">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<p className="text-sm uppercase tracking-wider text-slate-500">
Quick build
</p>
<h1 className="text-4xl font-bold mt-1">
Flash a custom firmware version
</h1>
<p className="text-slate-400 mt-2 max-w-2xl">
Choose your Meshtastic target, adjust optional modules, and queue
a new build instantly. Well send you to the build status page as
soon as it starts.
</p>
</div>
<Link
to="/"
className="text-slate-400 hover:text-white transition-colors text-sm"
>
Back to landing page
</Link>
</div>
<div className="space-y-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div>
<label
htmlFor="build-version"
className="block text-sm font-medium mb-2"
>
Firmware version
</label>
<select
id="build-version"
value={selectedVersion}
onChange={(event) => setSelectedVersion(event.target.value)}
className="w-full h-10 px-3 rounded-md border border-slate-800 bg-slate-950 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
>
{VERSIONS.map((version) => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
</div>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{TARGET_CATEGORIES.map((category) => {
const isActive = activeCategory === category
return (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
}`}
>
{category}
</button>
)
})}
</div>
<div className="bg-slate-950/60 p-4 rounded-lg border border-slate-800/60">
<div className="flex flex-wrap gap-2">
{(activeCategory ? GROUPED_TARGETS[activeCategory] : [])?.map(
(target) => {
const isSelected = selectedTarget === target.id
return (
<button
key={target.id}
type="button"
onClick={() => handleSelectTarget(target.id)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isSelected
? 'bg-cyan-600 text-white'
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
}`}
>
{target.name}
</button>
)
}
)}
</div>
</div>
</div>
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button
type="button"
onClick={() => setShowModuleOverrides((prev) => !prev)}
className="w-full flex items-center justify-between text-left"
>
<div>
<p className="text-sm font-medium">Module overrides</p>
<p className="text-xs text-slate-400">
{moduleCount === 0
? 'Using default modules for this target.'
: `${moduleCount} module${moduleCount === 1 ? '' : 's'} excluded.`}
</p>
</div>
{showModuleOverrides ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</button>
{showModuleOverrides && (
<div className="space-y-2 pr-1">
<div className="flex justify-end">
<button
type="button"
className="text-xs text-slate-400 hover:text-white underline"
onClick={() => setModuleConfig({})}
disabled={moduleCount === 0}
>
Reset overrides
</button>
</div>
<div className="grid gap-2 md:grid-cols-2">
{modulesData.modules.map((module) => (
<ModuleToggle
key={module.id}
id={module.id}
name={module.name}
description={module.description}
isExcluded={moduleConfig[module.id] === true}
onToggle={(excluded) =>
handleToggleModule(module.id, excluded)
}
/>
))}
</div>
</div>
)}
</div>
<div className="space-y-2">
<Button
onClick={handleFlash}
disabled={isFlashDisabled}
className="w-full bg-cyan-600 hover:bg-cyan-700"
>
{isFlashing ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Queuing build...
</span>
) : (
`Flash ${selectedTargetLabel || ''}`.trim() || 'Flash'
)}
</Button>
{errorMessage && (
<p className="text-sm text-red-400">{errorMessage}</p>
)}
</div>
</div>
</div>
</div>
)
}
+203
View File
@@ -0,0 +1,203 @@
import { useMutation, useQuery } from 'convex/react'
import { pick } from 'convex-helpers'
import { ArrowLeft, CheckCircle, Loader2, XCircle } from 'lucide-react'
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { humanizeStatus } from '@/lib/utils'
import { api } from '../../convex/_generated/api'
import { type BuildFields, buildFields } from '../../convex/schema'
import { TARGETS } from '../constants/targets'
export default function BuildProgress() {
const { buildHash } = useParams<{ buildHash: string }>()
const build = useQuery(
api.builds.getByHash,
buildHash ? { buildHash } : 'skip'
)
const generateDownloadUrl = useMutation(
api.builds.generateAnonymousDownloadUrl
)
const [downloadError, setDownloadError] = useState<string | null>(null)
if (!buildHash) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<p className="text-slate-300">
Build hash missing.{' '}
<Link to="/builds/new" className="text-cyan-400">
Start a new build
</Link>
.
</p>
</div>
</div>
)
}
if (build === undefined) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
</div>
)
}
if (!build) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto space-y-4">
<Link
to="/builds/new"
className="inline-flex items-center text-slate-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Quick Build
</Link>
<div className="bg-slate-900/60 border border-slate-800 rounded-lg p-6">
<p className="text-slate-300">
No build found for hash{' '}
<span className="font-mono">{buildHash}</span>
</p>
</div>
</div>
</div>
)
}
const targetMeta = build.config.target
? TARGETS[build.config.target]
: undefined
const targetLabel = targetMeta?.name ?? build.config.target
const status = build.status || 'queued'
const handleDownload = async () => {
setDownloadError(null)
try {
const url = await generateDownloadUrl({
build: pick(build, Object.keys(buildFields) as (keyof BuildFields)[]),
slug: `quick-build`,
})
window.location.href = url
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setDownloadError('Failed to generate download link.')
console.error('Download error', message)
}
}
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/configurable-web-flasher/actions/runs/${build.githubRunId}`
: null
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<Link
to="/builds/new"
className="inline-flex items-center text-slate-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Quick Build
</Link>
<p className="text-sm text-slate-500 font-mono break-all">
Hash: {build.buildHash}
</p>
</div>
<div className="bg-slate-900/60 rounded-lg border border-slate-800 p-6 space-y-4">
<div className="flex items-center gap-4">
{getStatusIcon()}
<div>
<p className="text-sm uppercase tracking-wide text-slate-500">
Target
</p>
<h2 className="text-2xl font-semibold">{targetLabel}</h2>
<div className="flex items-center gap-2 text-slate-400 mt-1 text-sm">
<span className={getStatusColor()}>
{humanizeStatus(status)}
</span>
<span></span>
<span>{new Date(build.updatedAt).toLocaleString()}</span>
{githubActionUrl && (
<>
<span></span>
<a
href={githubActionUrl}
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-slate-300"
>
View run
</a>
</>
)}
</div>
</div>
</div>
{status !== 'success' && status !== 'failure' && (
<div className="rounded-lg border border-slate-800/70 bg-slate-950/60 p-4">
<p className="text-sm text-slate-400">
Builds run in GitHub Actions. When the status is
<span className="text-green-400 font-medium"> success</span>,
your firmware artifact will be ready to download.
</p>
</div>
)}
{status === 'success' && build.artifactPath && (
<div className="space-y-2">
<Button
onClick={handleDownload}
className="w-full bg-cyan-600 hover:bg-cyan-700"
>
Download firmware
</Button>
{downloadError && (
<p className="text-sm text-red-400">{downloadError}</p>
)}
</div>
)}
{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>
)}
</div>
</div>
</div>
)
}
+30 -5
View File
@@ -8,6 +8,23 @@ import {
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
function QuickBuildIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
role="img"
aria-label="Quick build"
{...props}
>
<title>Quick build</title>
<path fill="currentColor" d="M11 15H6l7-14v8h5l-7 14z" />
</svg>
)
}
export default function LandingPage() {
const navigate = useNavigate()
const { signIn } = useAuthActions()
@@ -16,7 +33,6 @@ export default function LandingPage() {
return (
<div className="min-h-screen bg-slate-950 text-white">
<div className="max-w-7xl mx-auto">
{/* Hero Section */}
<div className="text-center py-20 px-8">
<h1 className="text-6xl md:text-7xl font-bold mb-6 leading-[1.1]">
<span className="bg-gradient-to-r from-cyan-400 to-blue-600 bg-clip-text text-transparent inline-block pb-2">
@@ -27,7 +43,7 @@ export default function LandingPage() {
Create custom profiles, build firmware in the cloud, and flash
directly from your browser.
</p>
<div className="flex justify-center">
<div className="flex flex-wrap items-center justify-center gap-3">
<Authenticated>
<Button
onClick={() => navigate('/dashboard')}
@@ -48,6 +64,15 @@ export default function LandingPage() {
Sign in
</Button>
</Unauthenticated>
<Button
onClick={() => navigate('/builds/new')}
size="lg"
variant="outline"
className="border-cyan-500/50 text-white hover:bg-slate-900/60"
>
<QuickBuildIcon className="mr-2 h-5 w-5" />
Quick Build
</Button>
</div>
</div>
@@ -67,9 +92,9 @@ export default function LandingPage() {
key={profile._id}
type="button"
onClick={() => navigate(`/profiles/${profile._id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
navigate(`/profiles/${profile._id}`)
}
}}
+4 -8
View File
@@ -16,9 +16,7 @@ export default function ProfileDetail() {
const navigate = useNavigate()
const { isAuthenticated } = useConvexAuth()
const { signIn } = useAuthActions()
const ensureBuildForProfileTarget = useMutation(
api.builds.ensureBuildForProfileTarget
)
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const profile = useQuery(
api.profiles.get,
id ? { id: id as Id<'profiles'> } : 'skip'
@@ -107,10 +105,8 @@ export default function ProfileDetail() {
}
try {
await ensureBuildForProfileTarget({
profileId: id as Id<'profiles'>,
target: selectedTarget,
})
if (!profile) return
await ensureBuildFromConfig(profile.config)
navigate(`/profiles/${id}/flash/${selectedTarget}`)
} catch (error) {
toast.error('Failed to start flash', {
@@ -132,7 +128,7 @@ export default function ProfileDetail() {
</p>
</div>
<ProfileStatisticPills
version={profile.version}
version={profile.config.version}
flashCount={totalFlashes}
/>
</div>
+8 -9
View File
@@ -16,9 +16,7 @@ export default function ProfileFlash() {
target: string
}>()
const ensureBuildForProfileTarget = useMutation(
api.builds.ensureBuildForProfileTarget
)
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const [buildId, setBuildId] = useState<Id<'builds'> | null>(null)
@@ -34,12 +32,12 @@ export default function ProfileFlash() {
const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl)
useEffect(() => {
if (id && target) {
ensureBuildForProfileTarget({ profileId: id as Id<'profiles'>, target })
.then(setBuildId)
if (id && target && profile) {
ensureBuildFromConfig(profile.config)
.then((result) => setBuildId(result.buildId))
.catch(() => setBuildId(null))
}
}, [id, target, ensureBuildForProfileTarget])
}, [id, target, profile, ensureBuildFromConfig])
if (build === undefined || profile === undefined) {
return (
@@ -144,14 +142,15 @@ export default function ProfileFlash() {
</p>
<h1 className="text-3xl font-bold mt-1">{profile.name}</h1>
<p className="text-slate-400 text-sm mt-2">
Version: <span className="text-slate-200">{profile.version}</span>
Version:{' '}
<span className="text-slate-200">{profile.config.version}</span>
</p>
</div>
<p className="text-slate-200 leading-relaxed">
{profile.description}
</p>
<ProfileStatisticPills
version={profile.version}
version={profile.config.version}
flashCount={totalFlashes}
/>
</div>