mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-06 08:53:08 +02:00
refactor: rename triggerBuild to triggerFlash, enhance build handling logic, and improve profile target management in the application
This commit is contained in:
+113
-118
@@ -58,17 +58,15 @@ function getR2ArtifactUrl(buildHash: string): string {
|
||||
return `https://${bucketName}.r2.cloudflarestorage.com/${buildHash}.uf2`
|
||||
}
|
||||
|
||||
export const triggerBuild = mutation({
|
||||
export const triggerFlash = mutation({
|
||||
args: {
|
||||
profileId: v.id('profiles'),
|
||||
target: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) throw new Error('Unauthorized')
|
||||
|
||||
const profile = await ctx.db.get(args.profileId)
|
||||
if (!profile || profile.userId !== userId) {
|
||||
throw new Error('Unauthorized')
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found')
|
||||
}
|
||||
|
||||
// Convert config object to flags string
|
||||
@@ -84,128 +82,125 @@ export const triggerBuild = mutation({
|
||||
|
||||
const flagsString = flags.join(' ')
|
||||
|
||||
// Get targets from profileTargets
|
||||
const profileTargets = await ctx.db
|
||||
.query('profileTargets')
|
||||
.withIndex('by_profile', (q) => q.eq('profileId', args.profileId))
|
||||
.collect()
|
||||
// Compute build hash
|
||||
const buildHash = await computeBuildHash(
|
||||
profile.version,
|
||||
args.target,
|
||||
flagsString
|
||||
)
|
||||
|
||||
const targets = profileTargets.map((pt) => pt.target)
|
||||
// Check if build already exists with this hash
|
||||
const existingBuild = await ctx.db
|
||||
.query('builds')
|
||||
.withIndex('by_hash', (q) => q.eq('buildHash', buildHash))
|
||||
.first()
|
||||
|
||||
// Create build records for each target
|
||||
for (const target of targets) {
|
||||
// Compute build hash using the generated flags
|
||||
const buildHash = await computeBuildHash(
|
||||
profile.version,
|
||||
target,
|
||||
flagsString
|
||||
)
|
||||
let buildId: Id<'builds'>
|
||||
let shouldDispatch = false
|
||||
|
||||
console.log(
|
||||
`Computed build hash for ${target}: ${buildHash} (Flags: ${flagsString})`
|
||||
)
|
||||
if (existingBuild) {
|
||||
// Build already exists, use it
|
||||
buildId = existingBuild._id
|
||||
} else {
|
||||
// Check cache for existing build
|
||||
const cached = await ctx.db
|
||||
.query('buildCache')
|
||||
.withIndex('by_hash_target', (q) =>
|
||||
q.eq('buildHash', buildHash).eq('target', args.target)
|
||||
)
|
||||
.first()
|
||||
|
||||
// Mutex logic: Check if build already exists with this hash
|
||||
const build = await ctx.db
|
||||
if (cached) {
|
||||
// Use cached artifact, create build with success status
|
||||
const artifactUrl = getR2ArtifactUrl(buildHash)
|
||||
buildId = await ctx.db.insert('builds', {
|
||||
target: args.target,
|
||||
githubRunId: 0,
|
||||
status: 'success',
|
||||
artifactUrl: artifactUrl,
|
||||
startedAt: Date.now(),
|
||||
completedAt: Date.now(),
|
||||
buildHash: buildHash,
|
||||
})
|
||||
} else {
|
||||
// Not cached, create new build and dispatch workflow
|
||||
buildId = await ctx.db.insert('builds', {
|
||||
target: args.target,
|
||||
githubRunId: 0,
|
||||
status: 'queued',
|
||||
startedAt: Date.now(),
|
||||
buildHash: buildHash,
|
||||
})
|
||||
shouldDispatch = true
|
||||
}
|
||||
|
||||
// Handle race condition
|
||||
const raceCheckBuild = await ctx.db
|
||||
.query('builds')
|
||||
.withIndex('by_hash', (q) => q.eq('buildHash', buildHash))
|
||||
.first()
|
||||
|
||||
let buildId: Id<'builds'>
|
||||
let shouldDispatch = false
|
||||
|
||||
if (build) {
|
||||
// Build already exists, use it
|
||||
buildId = build._id
|
||||
console.log(`Using existing build ${buildId} for hash ${buildHash}`)
|
||||
} else {
|
||||
// Check cache for existing build
|
||||
const cached = await ctx.db
|
||||
.query('buildCache')
|
||||
.withIndex('by_hash_target', (q) =>
|
||||
q.eq('buildHash', buildHash).eq('target', target)
|
||||
)
|
||||
.first()
|
||||
|
||||
if (cached) {
|
||||
// Use cached artifact, create build with success status
|
||||
const artifactUrl = getR2ArtifactUrl(buildHash)
|
||||
buildId = await ctx.db.insert('builds', {
|
||||
target: target,
|
||||
githubRunId: 0,
|
||||
status: 'success',
|
||||
artifactUrl: artifactUrl,
|
||||
startedAt: Date.now(),
|
||||
completedAt: Date.now(),
|
||||
buildHash: buildHash,
|
||||
})
|
||||
console.log(`Created cached build ${buildId} for hash ${buildHash}`)
|
||||
} else {
|
||||
// Not cached, create new build and dispatch workflow
|
||||
buildId = await ctx.db.insert('builds', {
|
||||
target: target,
|
||||
githubRunId: 0,
|
||||
status: 'queued',
|
||||
startedAt: Date.now(),
|
||||
buildHash: buildHash,
|
||||
})
|
||||
shouldDispatch = true
|
||||
console.log(`Created new build ${buildId} for hash ${buildHash}`)
|
||||
}
|
||||
|
||||
// Handle race condition: if another mutation created the build between our check and insert,
|
||||
// query again to get the existing build (there might be duplicates, but we'll use the first one)
|
||||
const existingBuild = await ctx.db
|
||||
.query('builds')
|
||||
.withIndex('by_hash', (q) => q.eq('buildHash', buildHash))
|
||||
.first()
|
||||
|
||||
if (existingBuild && existingBuild._id !== buildId) {
|
||||
// Another mutation created the build first, use that one instead
|
||||
// Delete the duplicate we just created
|
||||
await ctx.db.delete(buildId)
|
||||
buildId = existingBuild._id
|
||||
shouldDispatch = false
|
||||
console.log(
|
||||
`Race condition detected: using existing build ${existingBuild._id} instead of duplicate`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update profileBuild record
|
||||
const existingProfileBuild = await ctx.db
|
||||
.query('profileBuilds')
|
||||
.withIndex('by_profile_target', (q) =>
|
||||
q.eq('profileId', args.profileId).eq('target', target)
|
||||
)
|
||||
.first()
|
||||
|
||||
if (existingProfileBuild) {
|
||||
// Update existing profileBuild to point to the (possibly new) build
|
||||
await ctx.db.patch(existingProfileBuild._id, {
|
||||
buildId: buildId,
|
||||
})
|
||||
} else {
|
||||
// Create new profileBuild record
|
||||
await ctx.db.insert('profileBuilds', {
|
||||
profileId: args.profileId,
|
||||
buildId: buildId,
|
||||
target: target,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Only dispatch GitHub workflow if build was newly created and not cached
|
||||
if (shouldDispatch) {
|
||||
await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
|
||||
buildId: buildId,
|
||||
target: target,
|
||||
flags: flagsString,
|
||||
version: profile.version,
|
||||
buildHash: buildHash,
|
||||
})
|
||||
if (raceCheckBuild && raceCheckBuild._id !== buildId) {
|
||||
await ctx.db.delete(buildId)
|
||||
buildId = raceCheckBuild._id
|
||||
shouldDispatch = false
|
||||
}
|
||||
}
|
||||
|
||||
// Create or get profileTarget
|
||||
let profileTarget = await ctx.db
|
||||
.query('profileTargets')
|
||||
.withIndex('by_profile_target', (q) =>
|
||||
q.eq('profileId', args.profileId).eq('target', args.target)
|
||||
)
|
||||
.first()
|
||||
|
||||
if (!profileTarget) {
|
||||
const newProfileTargetId = await ctx.db.insert('profileTargets', {
|
||||
profileId: args.profileId,
|
||||
target: args.target,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
const retrieved = await ctx.db.get(newProfileTargetId)
|
||||
if (!retrieved) {
|
||||
throw new Error('Failed to create profileTarget')
|
||||
}
|
||||
profileTarget = retrieved
|
||||
}
|
||||
|
||||
// Create or update profileBuild record
|
||||
const existingProfileBuild = await ctx.db
|
||||
.query('profileBuilds')
|
||||
.withIndex('by_profile_target', (q) =>
|
||||
q.eq('profileId', args.profileId).eq('target', args.target)
|
||||
)
|
||||
.first()
|
||||
|
||||
if (existingProfileBuild) {
|
||||
await ctx.db.patch(existingProfileBuild._id, {
|
||||
buildId: buildId,
|
||||
})
|
||||
} else {
|
||||
await ctx.db.insert('profileBuilds', {
|
||||
profileId: args.profileId,
|
||||
buildId: buildId,
|
||||
target: args.target,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Dispatch GitHub workflow if needed
|
||||
if (shouldDispatch) {
|
||||
await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
|
||||
buildId: buildId,
|
||||
target: args.target,
|
||||
flags: flagsString,
|
||||
version: profile.version,
|
||||
buildHash: buildHash,
|
||||
})
|
||||
}
|
||||
|
||||
return profileTarget._id
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -53,6 +53,31 @@ export const getTargets = query({
|
||||
},
|
||||
})
|
||||
|
||||
export const getProfileTarget = query({
|
||||
args: { profileTargetId: v.id('profileTargets') },
|
||||
handler: async (ctx, args) => {
|
||||
const profileTarget = await ctx.db.get(args.profileTargetId)
|
||||
if (!profileTarget) return null
|
||||
|
||||
// Get the associated build via profileBuilds
|
||||
const profileBuild = await ctx.db
|
||||
.query('profileBuilds')
|
||||
.withIndex('by_profile_target', (q) =>
|
||||
q
|
||||
.eq('profileId', profileTarget.profileId)
|
||||
.eq('target', profileTarget.target)
|
||||
)
|
||||
.first()
|
||||
|
||||
const build = profileBuild ? await ctx.db.get(profileBuild.buildId) : null
|
||||
|
||||
return {
|
||||
profileTarget,
|
||||
build,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const getFlashCount = query({
|
||||
args: { profileId: v.id('profiles') },
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
+8
-2
@@ -23,7 +23,10 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/profiles/:id" element={<ProfileDetail />} />
|
||||
<Route path="/profiles/:id/flash" element={<ProfileFlash />} />
|
||||
<Route
|
||||
path="/profiles/:id/flash/:profileTargetId"
|
||||
element={<ProfileFlash />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Unauthenticated>
|
||||
@@ -35,7 +38,10 @@ function App() {
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/builds/:buildId" element={<BuildDetail />} />
|
||||
<Route path="/profiles/:id" element={<ProfileDetail />} />
|
||||
<Route path="/profiles/:id/flash" element={<ProfileFlash />} />
|
||||
<Route
|
||||
path="/profiles/:id/flash/:profileTargetId"
|
||||
element={<ProfileFlash />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Authenticated>
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { Doc, Id } from '../../convex/_generated/dataModel'
|
||||
|
||||
export default function Dashboard() {
|
||||
const profiles = useQuery(api.profiles.list)
|
||||
const triggerBuild = useMutation(api.builds.triggerBuild)
|
||||
const removeProfile = useMutation(api.profiles.remove)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editingProfile, setEditingProfile] = useState<Doc<'profiles'> | null>(
|
||||
@@ -28,19 +27,6 @@ export default function Dashboard() {
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleBuild = async (profileId: Id<'profiles'>) => {
|
||||
try {
|
||||
await triggerBuild({ profileId })
|
||||
toast.success('Build started', {
|
||||
description: 'Check the build status below.',
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('Build failed', {
|
||||
description: String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (
|
||||
profileId: Id<'profiles'>,
|
||||
profileName: string
|
||||
@@ -107,9 +93,6 @@ export default function Dashboard() {
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => handleBuild(profile._id)}>
|
||||
Build
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useQuery } from 'convex/react'
|
||||
import { useMutation, useQuery } from 'convex/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import type { Id } from '../../convex/_generated/dataModel'
|
||||
@@ -11,6 +12,7 @@ import { TARGETS } from '../constants/targets'
|
||||
export default function ProfileDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const triggerFlash = useMutation(api.builds.triggerFlash)
|
||||
const profile = useQuery(
|
||||
api.profiles.get,
|
||||
id ? { id: id as Id<'profiles'> } : 'skip'
|
||||
@@ -75,9 +77,19 @@ export default function ProfileDetail() {
|
||||
(module) => profile.config[module.id] === false
|
||||
)
|
||||
|
||||
const handleFlash = () => {
|
||||
if (selectedTarget) {
|
||||
navigate(`/profiles/${id}/flash`)
|
||||
const handleFlash = async () => {
|
||||
if (!selectedTarget || !id) return
|
||||
|
||||
try {
|
||||
const profileTargetId = await triggerFlash({
|
||||
profileId: id as Id<'profiles'>,
|
||||
target: selectedTarget,
|
||||
})
|
||||
navigate(`/profiles/${id}/flash/${profileTargetId}`)
|
||||
} catch (error) {
|
||||
toast.error('Failed to start flash', {
|
||||
description: String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+124
-5
@@ -1,15 +1,134 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery } from 'convex/react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from 'lucide-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 { Id } from '../../convex/_generated/dataModel'
|
||||
|
||||
export default function ProfileFlash() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { id, profileTargetId } = useParams<{
|
||||
id: string
|
||||
profileTargetId: string
|
||||
}>()
|
||||
|
||||
const data = useQuery(
|
||||
api.profiles.getProfileTarget,
|
||||
profileTargetId
|
||||
? { profileTargetId: profileTargetId as Id<'profileTargets'> }
|
||||
: 'skip'
|
||||
)
|
||||
|
||||
if (data === 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 (data === null || !data.build) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Link
|
||||
to={`/profiles/${id}`}
|
||||
className="inline-flex items-center text-slate-400 hover:text-white mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Profile
|
||||
</Link>
|
||||
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
|
||||
<p className="text-slate-400">Build not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const build = data.build
|
||||
const profileTarget = data.profileTarget
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
if (status === 'success') return 'text-green-400'
|
||||
if (status === 'failure') return 'text-red-400'
|
||||
return 'text-blue-400'
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
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 githubActionUrl =
|
||||
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">
|
||||
<h1 className="text-4xl font-bold mb-8">Flash Firmware</h1>
|
||||
<Link
|
||||
to={`/profiles/${id}`}
|
||||
className="inline-flex items-center text-slate-400 hover:text-white mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Profile
|
||||
</Link>
|
||||
|
||||
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
|
||||
<p className="text-slate-400">Flash functionality coming soon...</p>
|
||||
<p className="text-slate-500 text-sm mt-2">Profile ID: {id}</p>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{getStatusIcon(build.status)}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{profileTarget.target}</h1>
|
||||
<div className="flex items-center gap-2 text-slate-400 mt-1">
|
||||
<span className={getStatusColor(build.status)}>
|
||||
{humanizeStatus(build.status)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{new Date(build.startedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{githubActionUrl && (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={githubActionUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-cyan-400 hover:text-cyan-300"
|
||||
>
|
||||
View on GitHub Actions
|
||||
<ExternalLink className="w-4 h-4 ml-2" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{build.status === 'success' && build.artifactUrl && (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={build.artifactUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button className="bg-cyan-600 hover:bg-cyan-700">
|
||||
Download Firmware
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user