diff --git a/convex/builds.ts b/convex/builds.ts index e3b909d..4fb3ad3 100644 --- a/convex/builds.ts +++ b/convex/builds.ts @@ -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> => { - 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, diff --git a/convex/profiles.ts b/convex/profiles.ts index 8b3395f..2653872 100644 --- a/convex/profiles.ts +++ b/convex/profiles.ts @@ -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, diff --git a/convex/schema.ts b/convex/schema.ts index 99c3d8a..d5900f5 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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 -export type BuildFields = Infer +export const buildsDocValidator = schema.tables.builds.validator +export const profilesDocValidator = schema.tables.profiles.validator +export type ProfileFields = Infer +export type BuildFields = Infer + +const buildConfigFieldsValidator = v.object(buildConfigFields) +export type BuildConfigFields = Infer export default schema diff --git a/src/App.tsx b/src/App.tsx index e076988..10d8ebe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> + } /> + } /> } /> } /> @@ -46,6 +50,8 @@ function App() { } /> } /> + } /> + } /> } diff --git a/src/pages/BuildNew.tsx b/src/pages/BuildNew.tsx new file mode 100644 index 0000000..2ba2f8f --- /dev/null +++ b/src/pages/BuildNew.tsx @@ -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 +) + +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( + TARGET_CATEGORIES[0] ?? '' + ) + const [selectedTarget, setSelectedTarget] = useState(DEFAULT_TARGET) + const [selectedVersion, setSelectedVersion] = useState(VERSIONS[0]) + const [moduleConfig, setModuleConfig] = useState>({}) + const [isFlashing, setIsFlashing] = useState(false) + const [errorMessage, setErrorMessage] = useState(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 ( +
+
+
+
+

+ Quick build +

+

+ Flash a custom firmware version +

+

+ 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. +

+
+ + ← Back to landing page + +
+ +
+
+ + +
+ +
+
+ {TARGET_CATEGORIES.map((category) => { + const isActive = activeCategory === category + return ( + + ) + })} +
+ +
+
+ {(activeCategory ? GROUPED_TARGETS[activeCategory] : [])?.map( + (target) => { + const isSelected = selectedTarget === target.id + return ( + + ) + } + )} +
+
+
+ +
+ + + {showModuleOverrides && ( +
+
+ +
+
+ {modulesData.modules.map((module) => ( + + handleToggleModule(module.id, excluded) + } + /> + ))} +
+
+ )} +
+ +
+ + {errorMessage && ( +

{errorMessage}

+ )} +
+
+
+
+ ) +} diff --git a/src/pages/BuildProgress.tsx b/src/pages/BuildProgress.tsx new file mode 100644 index 0000000..dbcb280 --- /dev/null +++ b/src/pages/BuildProgress.tsx @@ -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(null) + + if (!buildHash) { + return ( +
+
+

+ Build hash missing.{' '} + + Start a new build + + . +

+
+
+ ) + } + + if (build === undefined) { + return ( +
+ +
+ ) + } + + if (!build) { + return ( +
+
+ + + Back to Quick Build + +
+

+ No build found for hash{' '} + {buildHash} +

+
+
+
+ ) + } + + 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 + } + if (status === 'failure') { + return + } + return + } + + 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 ( +
+
+
+ + + Back to Quick Build + +

+ Hash: {build.buildHash} +

+
+ +
+
+ {getStatusIcon()} +
+

+ Target +

+

{targetLabel}

+
+ + {humanizeStatus(status)} + + + {new Date(build.updatedAt).toLocaleString()} + {githubActionUrl && ( + <> + + + View run + + + )} +
+
+
+ + {status !== 'success' && status !== 'failure' && ( +
+

+ Builds run in GitHub Actions. When the status is + success, + your firmware artifact will be ready to download. +

+
+ )} + + {status === 'success' && build.artifactPath && ( +
+ + {downloadError && ( +

{downloadError}

+ )} +
+ )} + + {status === 'failure' && ( +
+

+ Build failed. Please try tweaking your configuration or + re-running the build. +

+
+ )} + + {status !== 'success' && status !== 'failure' && ( +
+

+ This build is still running. Leave this tab open or come back + later using the URL above. +

+
+ )} +
+
+
+ ) +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 018a1ba..37cb925 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -8,6 +8,23 @@ import { import { Button } from '@/components/ui/button' import { api } from '../../convex/_generated/api' +function QuickBuildIcon(props: React.SVGProps) { + return ( + + Quick build + + + ) +} + export default function LandingPage() { const navigate = useNavigate() const { signIn } = useAuthActions() @@ -16,7 +33,6 @@ export default function LandingPage() { return (
- {/* Hero Section */}

@@ -27,7 +43,7 @@ export default function LandingPage() { Create custom profiles, build firmware in the cloud, and flash directly from your browser.

-
+
+
@@ -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}`) } }} diff --git a/src/pages/ProfileDetail.tsx b/src/pages/ProfileDetail.tsx index 29aa912..09965f0 100644 --- a/src/pages/ProfileDetail.tsx +++ b/src/pages/ProfileDetail.tsx @@ -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() {

diff --git a/src/pages/ProfileFlash.tsx b/src/pages/ProfileFlash.tsx index a118e47..4e5b8be 100644 --- a/src/pages/ProfileFlash.tsx +++ b/src/pages/ProfileFlash.tsx @@ -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 | 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() {

{profile.name}

- Version: {profile.version} + Version:{' '} + {profile.config.version}

{profile.description}