mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-06-30 15:01:27 +02:00
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:
+96
-40
@@ -5,7 +5,7 @@ import { api, internal } from './_generated/api'
|
||||
import type { Id } from './_generated/dataModel'
|
||||
import { internalMutation, mutation, query } from './_generated/server'
|
||||
import { generateSignedDownloadUrl } from './lib/r2'
|
||||
import { type BuildFields, buildFields, type ProfileFields } from './schema'
|
||||
import { type BuildConfigFields, type BuildFields, buildFields } from './schema'
|
||||
|
||||
type BuildUpdateData = {
|
||||
status: string
|
||||
@@ -19,13 +19,22 @@ export const get = query({
|
||||
},
|
||||
})
|
||||
|
||||
export const getByHash = query({
|
||||
args: { buildHash: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const build = await ctx.db
|
||||
.query('builds')
|
||||
.filter((q) => q.eq(q.field('buildHash'), args.buildHash))
|
||||
.unique()
|
||||
return build ?? null
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Computes flags string from profile config.
|
||||
* Computes flags string from build config.
|
||||
* Only excludes modules explicitly marked as excluded (config[id] === true).
|
||||
*/
|
||||
export function computeFlagsFromProfile(
|
||||
config: ProfileFields['config']
|
||||
): string {
|
||||
export function computeFlagsFromConfig(config: BuildConfigFields): string {
|
||||
// Sort modules to ensure consistent order
|
||||
return Object.keys(config.modulesExcluded)
|
||||
.sort()
|
||||
@@ -36,9 +45,9 @@ export function computeFlagsFromProfile(
|
||||
|
||||
/**
|
||||
* Computes a stable SHA-256 hash from version, target, and flags.
|
||||
* This hash uniquely identifies a build configuration based on what is actually executed.
|
||||
* Internal helper for hash computation.
|
||||
*/
|
||||
export async function computeBuildHash(
|
||||
async function computeBuildHashInternal(
|
||||
version: string,
|
||||
target: string,
|
||||
flags: string
|
||||
@@ -61,15 +70,18 @@ export async function computeBuildHash(
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes buildHash for a profile and target.
|
||||
* Computes buildHash from build config.
|
||||
* This is the single source of truth for build hash computation.
|
||||
*/
|
||||
export async function computeBuildHashForProfile(
|
||||
profile: ProfileFields,
|
||||
target: string
|
||||
export async function computeBuildHash(
|
||||
config: BuildConfigFields
|
||||
): Promise<{ hash: string; flags: string }> {
|
||||
const flags = computeFlagsFromProfile(profile.config)
|
||||
const hash = await computeBuildHash(profile.version, target, flags)
|
||||
const flags = computeFlagsFromConfig(config)
|
||||
const hash = await computeBuildHashInternal(
|
||||
config.version,
|
||||
config.target,
|
||||
flags
|
||||
)
|
||||
return { hash, flags }
|
||||
}
|
||||
|
||||
@@ -95,7 +107,7 @@ export function getR2ArtifactUrl(
|
||||
// This is the single source of truth for build creation
|
||||
export const upsertBuild = internalMutation({
|
||||
args: {
|
||||
...pick(buildFields, ['buildHash', 'target', 'version', 'profileString']),
|
||||
...pick(buildFields, ['buildHash', 'config']),
|
||||
status: v.optional(v.string()),
|
||||
flags: v.string(),
|
||||
},
|
||||
@@ -107,7 +119,7 @@ export const upsertBuild = internalMutation({
|
||||
.filter((q) => q.eq(q.field('buildHash'), args.buildHash))
|
||||
.unique()
|
||||
|
||||
const { status, buildHash, target, version, profileString, flags } = args
|
||||
const { status, buildHash, config, flags } = args
|
||||
|
||||
if (existingBuild) {
|
||||
await ctx.db.patch(existingBuild._id, {
|
||||
@@ -119,21 +131,19 @@ export const upsertBuild = internalMutation({
|
||||
|
||||
// Create new build
|
||||
const buildId = await ctx.db.insert('builds', {
|
||||
target,
|
||||
status: 'queued',
|
||||
startedAt: Date.now(),
|
||||
buildHash,
|
||||
updatedAt: Date.now(),
|
||||
version,
|
||||
profileString,
|
||||
config,
|
||||
})
|
||||
|
||||
// Dispatch GitHub workflow if needed
|
||||
await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
|
||||
target: config.target,
|
||||
version: config.version,
|
||||
buildId,
|
||||
target,
|
||||
flags,
|
||||
version,
|
||||
buildHash,
|
||||
})
|
||||
|
||||
@@ -141,39 +151,52 @@ export const upsertBuild = internalMutation({
|
||||
},
|
||||
})
|
||||
|
||||
export const ensureBuildForProfileTarget = mutation({
|
||||
export const ensureBuildFromConfig = mutation({
|
||||
args: {
|
||||
profileId: v.id('profiles'),
|
||||
target: v.string(),
|
||||
version: v.string(),
|
||||
modulesExcluded: v.optional(v.record(v.string(), v.boolean())),
|
||||
profileName: v.optional(v.string()),
|
||||
profileDescription: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Id<'builds'>> => {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
const profile = await ctx.db.get(args.profileId)
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found')
|
||||
handler: async (ctx, args) => {
|
||||
// Construct config for the build
|
||||
const config: BuildConfigFields = {
|
||||
version: args.version,
|
||||
modulesExcluded: args.modulesExcluded ?? {},
|
||||
target: args.target,
|
||||
}
|
||||
|
||||
// Compute build hash (single source of truth)
|
||||
const { hash: buildHash, flags: flagsString } =
|
||||
await computeBuildHashForProfile(profile, args.target)
|
||||
const { hash: buildHash, flags } = await computeBuildHash(config)
|
||||
|
||||
const existingBuild = await ctx.db
|
||||
.query('builds')
|
||||
.filter((q) => q.eq(q.field('buildHash'), buildHash))
|
||||
.unique()
|
||||
|
||||
if (existingBuild) {
|
||||
return {
|
||||
buildId: existingBuild._id,
|
||||
existed: true,
|
||||
buildHash,
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert the build (single source of truth)
|
||||
const buildId: Id<'builds'> = await ctx.runMutation(
|
||||
internal.builds.upsertBuild,
|
||||
{
|
||||
buildHash,
|
||||
target: args.target,
|
||||
flags: flagsString,
|
||||
version: profile.version,
|
||||
profileString: JSON.stringify(profile),
|
||||
flags,
|
||||
config,
|
||||
}
|
||||
)
|
||||
|
||||
return buildId
|
||||
return {
|
||||
buildId,
|
||||
existed: false,
|
||||
buildHash,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -294,7 +317,40 @@ export const generateDownloadUrl = mutation({
|
||||
throw new Error('Could not determine file extension from artifact path')
|
||||
}
|
||||
|
||||
const filename = `${slug}-${build.target}.${ext}`
|
||||
const filename = `${slug}-${build.config.target}.${ext}`
|
||||
|
||||
return await generateSignedDownloadUrl(
|
||||
objectKey,
|
||||
filename,
|
||||
'application/octet-stream'
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const generateAnonymousDownloadUrl = mutation({
|
||||
args: {
|
||||
build: v.object(buildFields),
|
||||
slug: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
let objectKey = args.build.artifactPath || ''
|
||||
if (objectKey.startsWith('/')) {
|
||||
objectKey = objectKey.substring(1)
|
||||
}
|
||||
|
||||
const parts = objectKey.split('.')
|
||||
const ext = parts.length > 1 ? parts.pop() : undefined
|
||||
if (!ext) {
|
||||
throw new Error('Could not determine file extension from artifact path')
|
||||
}
|
||||
|
||||
const {
|
||||
buildHash,
|
||||
config: { target, version },
|
||||
} = args.build
|
||||
|
||||
const pfx = args.slug ? `${args.slug}-` : ''
|
||||
const filename = `${pfx}${target}-${version}-${buildHash.substring(0, 4)}.${ext}`
|
||||
|
||||
return await generateSignedDownloadUrl(
|
||||
objectKey,
|
||||
|
||||
+2
-3
@@ -1,6 +1,7 @@
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import { v } from 'convex/values'
|
||||
import { internalMutation, mutation, query } from './_generated/server'
|
||||
import { buildConfigFields } from './schema'
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
@@ -77,7 +78,7 @@ export const upsert = mutation({
|
||||
id: v.optional(v.id('profiles')),
|
||||
name: v.string(),
|
||||
description: v.string(),
|
||||
config: v.any(),
|
||||
config: v.object(buildConfigFields),
|
||||
version: v.string(),
|
||||
isPublic: v.boolean(),
|
||||
},
|
||||
@@ -96,7 +97,6 @@ export const upsert = mutation({
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
config: args.config,
|
||||
version: args.version,
|
||||
isPublic: args.isPublic,
|
||||
flashCount: profile.flashCount ?? 0,
|
||||
updatedAt: Date.now(),
|
||||
@@ -110,7 +110,6 @@ export const upsert = mutation({
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
config: args.config,
|
||||
version: args.version,
|
||||
flashCount: 0,
|
||||
updatedAt: Date.now(),
|
||||
isPublic: args.isPublic ?? true,
|
||||
|
||||
+15
-9
@@ -3,14 +3,17 @@ import { defineSchema, defineTable } from 'convex/server'
|
||||
import { type Infer, v } from 'convex/values'
|
||||
import type { Doc } from './_generated/dataModel'
|
||||
|
||||
export const buildConfigFields = {
|
||||
version: v.string(),
|
||||
modulesExcluded: v.record(v.string(), v.boolean()),
|
||||
target: v.string(),
|
||||
}
|
||||
|
||||
export const profileFields = {
|
||||
userId: v.id('users'),
|
||||
name: v.string(),
|
||||
description: v.string(),
|
||||
version: v.string(),
|
||||
config: v.object({
|
||||
modulesExcluded: v.record(v.string(), v.boolean()),
|
||||
}),
|
||||
config: v.object(buildConfigFields),
|
||||
isPublic: v.boolean(),
|
||||
flashCount: v.number(),
|
||||
updatedAt: v.number(),
|
||||
@@ -18,12 +21,10 @@ export const profileFields = {
|
||||
|
||||
export const buildFields = {
|
||||
buildHash: v.string(),
|
||||
target: v.string(),
|
||||
version: v.string(),
|
||||
status: v.string(),
|
||||
startedAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
profileString: v.string(),
|
||||
config: v.object(buildConfigFields),
|
||||
|
||||
// Optional props
|
||||
completedAt: v.optional(v.number()),
|
||||
@@ -39,7 +40,12 @@ export const schema = defineSchema({
|
||||
|
||||
export type ProfilesDoc = Doc<'profiles'>
|
||||
export type BuildsDoc = Doc<'builds'>
|
||||
export type ProfileFields = Infer<typeof schema.tables.profiles.validator>
|
||||
export type BuildFields = Infer<typeof schema.tables.builds.validator>
|
||||
export const buildsDocValidator = schema.tables.builds.validator
|
||||
export const profilesDocValidator = schema.tables.profiles.validator
|
||||
export type ProfileFields = Infer<typeof profilesDocValidator>
|
||||
export type BuildFields = Infer<typeof buildsDocValidator>
|
||||
|
||||
const buildConfigFieldsValidator = v.object(buildConfigFields)
|
||||
export type BuildConfigFields = Infer<typeof buildConfigFieldsValidator>
|
||||
|
||||
export default schema
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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. We’ll 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user