refactor: update build artifact handling and download functionality

This commit is contained in:
Ben Allfree
2025-12-03 12:38:04 -08:00
parent 2b7fb15104
commit 14e2589a1e
14 changed files with 373 additions and 416 deletions
+31 -21
View File
@@ -45,13 +45,11 @@ jobs:
cat > /tmp/update_status.sh << 'EOF'
update_status() {
local state=$1
local artifact_path=$2
local firmware_path=$2
local source_path=$3
local payload="{\"build_id\": \"$BUILD_ID\", \"state\": \"$state\", \"github_run_id\": \"$GITHUB_RUN_ID\"}"
if [ -n "$artifact_path" ] && [ -n "$source_path" ]; then
payload="{\"build_id\": \"$BUILD_ID\", \"state\": \"$state\", \"artifactPath\": \"$artifact_path\", \"sourcePath\": \"$source_path\", \"github_run_id\": \"$GITHUB_RUN_ID\"}"
elif [ -n "$artifact_path" ]; then
payload="{\"build_id\": \"$BUILD_ID\", \"state\": \"$state\", \"artifactPath\": \"$artifact_path\", \"github_run_id\": \"$GITHUB_RUN_ID\"}"
if [ -n "$firmware_path" ]; then
payload="{\"build_id\": \"$BUILD_ID\", \"state\": \"$state\", \"firmwarePath\": \"$firmware_path\", \"github_run_id\": \"$GITHUB_RUN_ID\"}"
elif [ -n "$source_path" ]; then
payload="{\"build_id\": \"$BUILD_ID\", \"state\": \"$state\", \"sourcePath\": \"$source_path\", \"github_run_id\": \"$GITHUB_RUN_ID\"}"
fi
@@ -166,6 +164,9 @@ jobs:
# Create MESHFORGE.md so it gets included in the archive
# (already created above, just ensuring it exists)
# Define archive suffix for consistent naming
ARTIFACT_ARCHIVE_SUFFIX="-${{ inputs.build_hash }}-${{ github.run_id }}.tar.gz"
# Create archive from working directory to include plugins installed by mpm
# Exclude .git, .pio, and build artifacts
cd ..
@@ -174,17 +175,17 @@ jobs:
--exclude='*.bin' \
--exclude='*.uf2' \
--exclude='build' \
-czf "${{ inputs.build_hash }}.tar.gz" \
-czf "source${ARTIFACT_ARCHIVE_SUFFIX}" \
-C firmware .
update_status uploading_source_archive
SOURCE_ARCHIVE_PATH="/${{ inputs.build_hash }}.tar.gz"
SOURCE_OBJECT_PATH="${R2_BUCKET_NAME}/${{ inputs.build_hash }}.tar.gz"
SOURCE_ARCHIVE_PATH="/source${ARTIFACT_ARCHIVE_SUFFIX}"
SOURCE_OBJECT_PATH="${R2_BUCKET_NAME}/source${ARTIFACT_ARCHIVE_SUFFIX}"
# Upload source archive to R2
wrangler r2 object put "$SOURCE_OBJECT_PATH" \
--file "${{ inputs.build_hash }}.tar.gz" --remote
--file "source${ARTIFACT_ARCHIVE_SUFFIX}" --remote
update_status uploaded_source "" "$SOURCE_ARCHIVE_PATH"
@@ -229,24 +230,33 @@ jobs:
pio run -e ${{ inputs.target }}
update_status uploading_firmware
# Determine file extension based on target (most are .bin, some might be .uf2)
BUILD_FILE=".pio/build/${{ inputs.target }}/firmware.bin"
FILE_EXT=".bin"
if [ ! -f "$BUILD_FILE" ]; then
BUILD_FILE=".pio/build/${{ inputs.target }}/firmware.uf2"
FILE_EXT=".uf2"
# Create tar.gz archive of all build artifacts from the target's build directory
# Change to the build directory and create archive from there
cd ".pio/build/${{ inputs.target }}"
# Find all build artifacts with specified extensions
ARTIFACTS=$(find . -maxdepth 1 -type f \( -name "*.bin" -o -name "*.hex" -o -name "*.elf" -o -name "*.uf2" -o -name "*.dat" -o -name "*.zip" \))
if [ -n "$ARTIFACTS" ]; then
# Create archive with all found artifacts
tar -czf "../../../firmware${ARTIFACT_ARCHIVE_SUFFIX}" $(find . -maxdepth 1 -type f \( -name "*.bin" -o -name "*.hex" -o -name "*.elf" -o -name "*.uf2" -o -name "*.dat" -o -name "*.zip" \))
cd ../../..
else
echo "Error: No build artifacts found matching [.bin, .hex, .elf, .uf2, .dat, .zip] in .pio/build/${{ inputs.target }}/"
echo "Recursive listing of .pio/build/${{ inputs.target }}/:"
find . -type f -ls || true
cd ../../..
exit 1
fi
# Determine artifact path with correct extension (with leading slash for storage)
ARTIFACT_PATH="/${{ inputs.build_hash }}${FILE_EXT}"
# Determine artifact path (with leading slash for storage)
ARTIFACT_PATH="/firmware${ARTIFACT_ARCHIVE_SUFFIX}"
# Object path for wrangler is bucket/key without leading slash
OBJECT_PATH="${R2_BUCKET_NAME}/${{ inputs.build_hash }}${FILE_EXT}"
OBJECT_PATH="${R2_BUCKET_NAME}/firmware${ARTIFACT_ARCHIVE_SUFFIX}"
# Upload to R2 with hash and correct extension
# Upload to R2 with hash and tar.gz extension
wrangler r2 object put "$OBJECT_PATH" \
--file "$BUILD_FILE" --remote
--file "firmware${ARTIFACT_ARCHIVE_SUFFIX}" --remote
SOURCE_ARCHIVE_PATH="/${{ inputs.build_hash }}.tar.gz"
SOURCE_ARCHIVE_PATH="/source${ARTIFACT_ARCHIVE_SUFFIX}"
update_status uploaded "$ARTIFACT_PATH" "$SOURCE_ARCHIVE_PATH"
- name: Update Build Status - Final
+16 -13
View File
@@ -29,6 +29,10 @@ export const dispatchGithubBuild = action({
throw new Error('args.buildHash is missing or empty')
}
// Use test workflow when running in Convex dev mode
const isDev = process.env.CONVEX_ENV === 'dev'
const workflowFile = isDev ? 'custom_build_test.yml' : 'custom_build.yml'
const payload = {
ref: 'main', // or make this configurable
inputs: {
@@ -43,23 +47,22 @@ export const dispatchGithubBuild = action({
}
console.log(
'Dispatching GitHub build with payload:',
`Dispatching GitHub build to ${workflowFile} with payload:`,
JSON.stringify(payload, null, 2)
)
try {
const response = await fetch(
'https://api.github.com/repos/MeshEnvy/configurable-web-flasher/actions/workflows/custom_build.yml/dispatches',
{
method: 'POST',
headers: {
Authorization: `Bearer ${githubToken}`,
Accept: 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}
)
const url = `https://api.github.com/repos/MeshEnvy/mesh-forge/actions/workflows/${workflowFile}/dispatches`
console.log('GitHub API URL:', url)
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${githubToken}`,
Accept: 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
const errorText = await response.text()
+3 -1
View File
@@ -66,10 +66,12 @@ export const retryBuild = adminMutation({
plugins: build.config.pluginsEnabled ?? [],
})
// Update build status to queued
// Update build status to queued and clear artifact paths
await ctx.db.patch(args.buildId, {
status: 'queued',
updatedAt: Date.now(),
firmwarePath: undefined,
sourcePath: undefined,
})
return { success: true }
+141 -225
View File
@@ -1,12 +1,15 @@
import { getAuthUserId } from '@convex-dev/auth/server'
import type { GenericMutationCtx } from 'convex/server'
import { v } from 'convex/values'
import { pick } from 'convex-helpers'
import { api, internal } from './_generated/api'
import type { DataModel, Id } from './_generated/dataModel'
import type { Doc, Id } from './_generated/dataModel'
import { internalMutation, mutation, query } from './_generated/server'
import { generateSignedDownloadUrl } from './lib/r2'
import { type BuildConfigFields, type BuildFields, buildFields } from './schema'
import { buildFields } from './schema'
export enum ArtifactType {
Firmware = 'firmware',
Source = 'source',
}
type BuildUpdateData = {
status: string
@@ -35,7 +38,9 @@ export const getByHash = query({
* Computes flags string from build config.
* Only excludes modules explicitly marked as excluded (config[id] === true).
*/
export function computeFlagsFromConfig(config: BuildConfigFields): string {
export function computeFlagsFromConfig(
config: Doc<'builds'>['config']
): string {
// Sort modules to ensure consistent order
return Object.keys(config.modulesExcluded)
.sort()
@@ -44,6 +49,31 @@ export function computeFlagsFromConfig(config: BuildConfigFields): string {
.join(' ')
}
/**
* Encodes a byte array to base62 string.
* Uses characters: 0-9, a-z, A-Z (62 characters total)
*/
function base62Encode(bytes: Uint8Array): string {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
// Convert bytes to a big-endian number
let num = BigInt(0)
for (let i = 0; i < bytes.length; i++) {
num = num * BigInt(256) + BigInt(bytes[i])
}
// Convert number to base62
if (num === BigInt(0)) return '0'
const result: string[] = []
while (num > BigInt(0)) {
result.push(chars[Number(num % BigInt(62))])
num = num / BigInt(62)
}
return result.reverse().join('')
}
/**
* Computes a stable SHA-256 hash from version, target, flags, and plugins.
* Internal helper for hash computation.
@@ -68,10 +98,10 @@ async function computeBuildHashInternal(
const encoder = new TextEncoder()
const data = encoder.encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
const hashBytes = new Uint8Array(hashBuffer)
return hashHex
// Encode to base62 instead of hex
return base62Encode(hashBytes)
}
/**
@@ -79,7 +109,7 @@ async function computeBuildHashInternal(
* This is the single source of truth for build hash computation.
*/
export async function computeBuildHash(
config: BuildConfigFields
config: Doc<'builds'>['config']
): Promise<{ hash: string; flags: string }> {
const flags = computeFlagsFromConfig(config)
const plugins = config.pluginsEnabled ?? []
@@ -94,20 +124,23 @@ export async function computeBuildHash(
/**
* Constructs the R2 artifact URL from a build.
* Uses artifactPath if available, otherwise falls back to buildHash.uf2
* Or custom domain if R2_PUBLIC_URL is set.
* Uses {artifactType}-<buildHash>-<githubRunId>.tar.gz format.
*/
export function getR2ArtifactUrl(
build: Pick<BuildFields, 'buildHash' | 'artifactPath'>
build: Pick<Doc<'builds'>, 'buildHash' | 'githubRunId'>,
artifactType: ArtifactType
): string {
const r2PublicUrl = process.env.R2_PUBLIC_URL
if (!r2PublicUrl) {
throw new Error('R2_PUBLIC_URL is not set')
}
const path = build.artifactPath || `/${build.buildHash}.uf2`
// Ensure path starts with /
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${r2PublicUrl}${normalizedPath}`
if (!build.githubRunId) {
throw new Error('githubRunId is required to construct artifact URL')
}
const artifactTypeStr =
artifactType === ArtifactType.Source ? 'source' : 'firmware'
const path = `/${artifactTypeStr}-${build.buildHash}-${build.githubRunId}.tar.gz`
return `${r2PublicUrl}${path}`
}
// Internal mutation to upsert a build by buildHash
@@ -136,7 +169,7 @@ export const upsertBuild = internalMutation({
return existingBuild._id
}
// Create new build
// Create new build (artifact paths are omitted, will be undefined)
const buildId = await ctx.db.insert('builds', {
status: 'queued',
startedAt: Date.now(),
@@ -170,7 +203,7 @@ export const ensureBuildFromConfig = mutation({
},
handler: async (ctx, args) => {
// Construct config for the build
const config: BuildConfigFields = {
const config: Doc<'builds'>['config'] = {
version: args.version,
modulesExcluded: args.modulesExcluded ?? {},
target: args.target,
@@ -238,9 +271,9 @@ export const updateBuildStatus = internalMutation({
...pick(buildFields, [
'status',
'completedAt',
'artifactPath',
'sourceUrl',
'githubRunId',
'firmwarePath',
'sourcePath',
]),
buildId: v.id('builds'),
},
@@ -249,10 +282,10 @@ export const updateBuildStatus = internalMutation({
if (!build) return
const updateData: BuildUpdateData & {
artifactPath?: string
sourceUrl?: string
githubRunId?: number
githubRunIdHistory?: number[]
firmwarePath?: string
sourcePath?: string
} = {
status: args.status,
}
@@ -262,14 +295,20 @@ export const updateBuildStatus = internalMutation({
updateData.completedAt = Date.now()
}
// Set artifactPath if provided
if (args.artifactPath !== undefined) {
updateData.artifactPath = args.artifactPath
// Clear artifact paths when build starts (queued status)
if (args.status === 'queued') {
updateData.firmwarePath = undefined
updateData.sourcePath = undefined
}
// Set sourceUrl if provided
if (args.sourceUrl !== undefined) {
updateData.sourceUrl = args.sourceUrl
// Set firmwarePath if provided
if (args.firmwarePath !== undefined) {
updateData.firmwarePath = args.firmwarePath
}
// Set sourcePath if provided
if (args.sourcePath !== undefined) {
updateData.sourcePath = args.sourcePath
}
// Set githubRunId if provided
@@ -281,6 +320,9 @@ export const updateBuildStatus = internalMutation({
if (existingRunId !== undefined && existingRunId !== args.githubRunId) {
// Prepend existing run ID to history array, avoiding duplicates
existingHistory.unshift(existingRunId)
// Clear artifact paths when a new run starts
updateData.firmwarePath = undefined
updateData.sourcePath = undefined
}
updateData.githubRunId = args.githubRunId
}
@@ -293,214 +335,88 @@ export const updateBuildStatus = internalMutation({
},
})
/**
* Helper to generate authenticated download URL
*/
async function generateAuthenticatedDownloadUrl(
ctx: GenericMutationCtx<DataModel>,
buildId: Id<'builds'>,
profileId: Id<'profiles'>,
objectKey: string,
ext: string,
filenameSuffix: string = '',
contentType: string = 'application/octet-stream',
incrementFlashCount: boolean = true
): Promise<string> {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Unauthorized')
export const generateDownloadUrl = mutation({
args: {
buildId: v.id('builds'),
artifactType: v.union(v.literal('firmware'), v.literal('source')),
profileId: v.optional(v.id('profiles')),
},
handler: async (ctx, args) => {
const build = await ctx.db.get(args.buildId)
if (!build) throw new Error('Build not found')
// Verify profile belongs to user or is public
const profile = await ctx.db.get(profileId)
if (!profile) throw new Error('Profile not found')
if (!build.githubRunId) {
throw new Error('Build githubRunId is required for download')
}
// If profile is private, ensure user owns it
if (profile.isPublic === false && profile.userId !== userId) {
throw new Error('Unauthorized')
}
const artifactTypeEnum =
args.artifactType === 'source'
? ArtifactType.Source
: ArtifactType.Firmware
const build = await ctx.db.get(buildId)
if (!build) throw new Error('Build not found')
const isSource = artifactTypeEnum === ArtifactType.Source
const artifactTypeStr =
artifactTypeEnum === ArtifactType.Source ? 'source' : 'firmware'
const contentType = isSource
? 'application/gzip'
: 'application/octet-stream'
// Increment flash count for firmware downloads
if (incrementFlashCount) {
const nextCount = (profile.flashCount ?? 0) + 1
await ctx.db.patch(profileId, {
flashCount: nextCount,
updatedAt: Date.now(),
})
// Use stored path if available, otherwise construct from buildHash and githubRunId
const storedPath = isSource ? build.sourcePath : build.firmwarePath
const objectKey = storedPath
? storedPath.startsWith('/')
? storedPath.slice(1)
: storedPath
: `${artifactTypeStr}-${build.buildHash}-${build.githubRunId}.tar.gz`
// Increment plugin flash counts if build has plugins enabled
if (build.config.pluginsEnabled && build.config.pluginsEnabled.length > 0) {
// Fetch profile if profileId is provided
const profile = await (async () => {
if (!args.profileId) return
const profileDoc = await ctx.db.get(args.profileId)
if (!profileDoc) throw new Error('Profile not found')
return profileDoc
})()
// Slugify profile name for filename (if authenticated)
const profileSlug = profile
? profile.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '')
: ''
// Increment profile flash count for firmware downloads
if (profile && !isSource) {
const nextCount = (profile.flashCount ?? 0) + 1
await ctx.db.patch(profile._id, {
flashCount: nextCount,
updatedAt: Date.now(),
})
}
// Increment plugin flash counts for firmware downloads (independent of profile)
if (
!isSource &&
build.config.pluginsEnabled &&
build.config.pluginsEnabled.length > 0
) {
await ctx.runMutation(internal.plugins.incrementFlashCount, {
slugs: build.config.pluginsEnabled,
})
}
}
// Slugify profile name for filename
const slug = profile.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '')
const last4Hash = build.buildHash.slice(-4)
const os = 'meshtastic' // OS/platform identifier
const version = build.config.version
const target = build.config.target
const jobId = build.githubRunId
const filename = `${slug}-${build.config.target}${filenameSuffix}.${ext}`
// Format: {os}-{version}-{profileSlug}-{target}-{last4hash}-{jobId}-{assetType}.tar.gz
// If no profile, omit profileSlug and its trailing dash
const filename = profileSlug
? `${os}-${version}-${profileSlug}-${target}-${last4Hash}-${jobId}-${artifactTypeStr}.tar.gz`
: `${os}-${version}-${target}-${last4Hash}-${jobId}-${artifactTypeStr}.tar.gz`
return await generateSignedDownloadUrl(objectKey, filename, contentType)
}
/**
* Helper to generate anonymous download URL
*/
function generateAnonymousDownloadUrlHelper(
build: BuildFields,
slug: string,
objectKey: string,
ext: string,
filenameSuffix: string = '',
contentType: string = 'application/octet-stream'
): Promise<string> {
const {
buildHash,
config: { target, version },
} = build
const pfx = slug ? `${slug}-` : ''
const filename = `${pfx}${target}-${version}-${buildHash.substring(0, 4)}${filenameSuffix}.${ext}`
return generateSignedDownloadUrl(objectKey, filename, contentType)
}
export const generateDownloadUrl = mutation({
args: {
buildId: v.id('builds'),
profileId: v.id('profiles'),
},
handler: async (ctx, args) => {
const build = await ctx.db.get(args.buildId)
if (!build) throw new Error('Build not found')
// User indicated that artifactPath must be present for a valid download
if (!build.artifactPath) {
throw new Error('Build artifact path is missing')
}
let objectKey = build.artifactPath
// Remove leading slash if present
if (objectKey.startsWith('/')) {
objectKey = objectKey.substring(1)
}
// Determine extension from objectKey
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')
}
return await generateAuthenticatedDownloadUrl(
ctx,
args.buildId,
args.profileId,
objectKey,
ext
)
},
})
export const generateAnonymousDownloadUrl = mutation({
args: {
build: v.object(buildFields),
slug: v.string(),
},
handler: async (ctx, args) => {
// Increment plugin flash counts if build has plugins enabled
if (
args.build.config.pluginsEnabled &&
args.build.config.pluginsEnabled.length > 0
) {
await ctx.runMutation(internal.plugins.incrementFlashCount, {
slugs: args.build.config.pluginsEnabled,
})
}
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')
}
return await generateAnonymousDownloadUrlHelper(
args.build,
args.slug,
objectKey,
ext
)
},
})
export const generateSourceDownloadUrl = mutation({
args: {
buildId: v.id('builds'),
profileId: v.id('profiles'),
},
handler: async (ctx, args) => {
const build = await ctx.db.get(args.buildId)
if (!build) throw new Error('Build not found')
// Use sourceUrl if available, otherwise fall back to constructing from buildHash
let objectKey: string
if (build.sourceUrl) {
// Remove leading slash if present
objectKey = build.sourceUrl.startsWith('/')
? build.sourceUrl.substring(1)
: build.sourceUrl
} else {
objectKey = `${build.buildHash}.tar.gz`
}
return await generateAuthenticatedDownloadUrl(
ctx,
args.buildId,
args.profileId,
objectKey,
'tar.gz',
'-source',
'application/gzip',
false // Don't increment flash count for source downloads
)
},
})
export const generateAnonymousSourceDownloadUrl = mutation({
args: {
build: v.object(buildFields),
slug: v.string(),
},
handler: async (_ctx, args) => {
// Use sourceUrl if available, otherwise fall back to constructing from buildHash
let objectKey: string
if (args.build.sourceUrl) {
// Remove leading slash if present
objectKey = args.build.sourceUrl.startsWith('/')
? args.build.sourceUrl.substring(1)
: args.build.sourceUrl
} else {
objectKey = `${args.build.buildHash}.tar.gz`
}
return await generateAnonymousDownloadUrlHelper(
args.build,
args.slug,
objectKey,
'tar.gz',
'-source',
'application/gzip'
)
return await generateSignedDownloadUrl(objectKey, filename, contentType)
},
})
+2 -2
View File
@@ -52,9 +52,9 @@ http.route({
await ctx.runMutation(internal.builds.updateBuildStatus, {
buildId: payload.build_id,
status: payload.state,
artifactPath: payload.artifactPath,
sourceUrl: payload.sourcePath,
githubRunId,
firmwarePath: payload.firmwarePath ?? payload.artifactPath,
sourcePath: payload.sourcePath,
})
return new Response(null, { status: 200 })
+5 -11
View File
@@ -1,7 +1,6 @@
import { authTables } from '@convex-dev/auth/server'
import { defineSchema, defineTable } from 'convex/server'
import { type Infer, v } from 'convex/values'
import type { Doc } from './_generated/dataModel'
import { v } from 'convex/values'
export const buildConfigFields = {
version: v.string(),
@@ -29,8 +28,10 @@ export const buildFields = {
// Optional props
completedAt: v.optional(v.number()),
artifactPath: v.optional(v.string()),
sourceUrl: v.optional(v.string()),
artifactPath: v.optional(v.string()), // Deprecated
firmwarePath: v.optional(v.string()),
sourceUrl: v.optional(v.string()), // Deprecated
sourcePath: v.optional(v.string()),
githubRunId: v.optional(v.number()),
githubRunIdHistory: v.optional(v.array(v.number())),
}
@@ -54,14 +55,7 @@ export const schema = defineSchema({
userSettings: defineTable(userSettingsFields).index('by_user', ['userId']),
})
export type ProfilesDoc = Doc<'profiles'>
export type BuildsDoc = Doc<'builds'>
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
+25 -33
View File
@@ -1,14 +1,15 @@
import { useMutation } from 'convex/react'
import { pick } from 'convex-helpers'
import { useState } from 'react'
import { toast } from 'sonner'
import { SourceAvailable } from '@/components/SourceAvailable'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import { type BuildFields, buildFields } from '../../convex/schema'
import type { Doc } from '../../convex/_generated/dataModel'
import { ArtifactType } from '../../convex/builds'
interface BuildDownloadButtonProps {
build: BuildFields
type: 'firmware' | 'source'
build: Doc<'builds'>
type: ArtifactType
variant?: 'default' | 'outline'
className?: string
}
@@ -19,21 +20,16 @@ export function BuildDownloadButton({
variant,
className,
}: BuildDownloadButtonProps) {
const generateDownloadUrl = useMutation(
api.builds.generateAnonymousDownloadUrl
)
const generateSourceDownloadUrl = useMutation(
api.builds.generateAnonymousSourceDownloadUrl
)
const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Default styling based on type
const defaultVariant =
variant ?? (type === 'firmware' ? 'default' : 'outline')
variant ?? (type === ArtifactType.Firmware ? 'default' : 'outline')
const defaultClassName =
className ??
(type === 'firmware'
(type === ArtifactType.Firmware
? 'bg-cyan-600 hover:bg-cyan-700'
: 'bg-slate-700 hover:bg-slate-600')
@@ -41,27 +37,15 @@ export function BuildDownloadButton({
setError(null)
setIsLoading(true)
try {
const url =
type === 'firmware'
? await generateDownloadUrl({
build: pick(
build,
Object.keys(buildFields) as (keyof BuildFields)[]
),
slug: 'download',
})
: await generateSourceDownloadUrl({
build: pick(
build,
Object.keys(buildFields) as (keyof BuildFields)[]
),
slug: 'download',
})
const url = await generateDownloadUrl({
buildId: build._id,
artifactType: type,
})
window.location.href = url
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const errorMsg =
type === 'firmware'
type === ArtifactType.Firmware
? 'Failed to generate download link.'
: 'Failed to generate source download link.'
setError(errorMsg)
@@ -73,10 +57,9 @@ export function BuildDownloadButton({
}
}
if (type === 'firmware' && !build.artifactPath) return null
if (type === 'source' && !build.sourceUrl && !build.buildHash) return null
if (type === ArtifactType.Firmware && !build.buildHash) return null
return (
const button = (
<div className="space-y-2">
<Button
onClick={handleDownload}
@@ -84,9 +67,18 @@ export function BuildDownloadButton({
variant={defaultVariant}
className={defaultClassName}
>
Download {type === 'firmware' ? 'firmware' : 'source'}
Download {type === ArtifactType.Firmware ? 'firmware' : 'source'}
</Button>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
)
// For source downloads, only show when sourcePath is available
if (type === ArtifactType.Source) {
return (
<SourceAvailable sourcePath={build.sourcePath}>{button}</SourceAvailable>
)
}
return button
}
+1 -2
View File
@@ -1,5 +1,4 @@
import type { Doc } from '../../convex/_generated/dataModel'
import type { ProfileFields } from '../../convex/schema'
export const profileCardClasses =
'border border-slate-800 rounded-lg p-6 bg-slate-900/50 flex flex-col gap-4'
@@ -31,7 +30,7 @@ export function ProfileStatisticPills({
}
interface ProfileCardContentProps {
profile: Doc<'profiles'> & ProfileFields
profile: Doc<'profiles'>
}
export function ProfileCardContent({ profile }: ProfileCardContentProps) {
+4 -8
View File
@@ -4,23 +4,19 @@ import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
import modulesData from '../../convex/modules.json'
import type {
BuildConfigFields,
ProfileFields,
ProfilesDoc,
} from '../../convex/schema'
import { VERSIONS } from '../constants/versions'
import { ModuleToggle } from './ModuleToggle'
// Form values use flattened config for UI, but will be transformed to nested on submit
type ProfileFormValues = Omit<
ProfileFields,
Doc<'profiles'>,
'_id' | '_creationTime' | 'userId' | 'flashCount' | 'updatedAt'
>
interface ProfileEditorProps {
initialData?: ProfilesDoc
initialData?: Doc<'profiles'>
onSave: () => void
onCancel: () => void
}
@@ -153,7 +149,7 @@ export default function ProfileEditor({
{modulesData.modules.map((module) => {
// Flattened config: config[id] === true -> Explicitly Excluded
// config[id] === undefined/false -> Default (included if target supports)
const currentConfig = watch('config') as BuildConfigFields
const currentConfig = watch('config') as Doc<'builds'>['config']
const configValue = currentConfig.modulesExcluded[module.id]
const isExcluded = configValue === true
+19
View File
@@ -0,0 +1,19 @@
interface SourceAvailableProps {
sourcePath: string | undefined
children: React.ReactNode
}
/**
* Component that only renders children when sourcePath is available.
* Uses the sourcePath field from the build instead of polling.
*/
export function SourceAvailable({
sourcePath,
children,
}: SourceAvailableProps) {
if (!sourcePath) {
return null
}
return <>{children}</>
}
+109 -84
View File
@@ -1,11 +1,12 @@
import { useMutation, useQuery } from 'convex/react'
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { BuildDownloadButton } from '@/components/BuildDownloadButton'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
import { ArtifactType } from '../../convex/builds'
type FilterType = 'all' | 'failed'
@@ -130,100 +131,124 @@ export default function Admin() {
key={build._id}
className="bg-slate-900 border border-slate-800 rounded-lg p-6"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-4 mb-2">
<h3 className="text-lg font-semibold">
Build:{' '}
<Link
to={`/builds/${build.buildHash}`}
className="text-cyan-400 hover:text-cyan-300 underline"
>
{build.buildHash.substring(0, 8)}
</Link>
</h3>
{getStatusBadge(build.status)}
{/* Header Section */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-slate-800">
<div className="flex items-center gap-3">
<span className="text-xl font-mono font-semibold text-white">
{build.buildHash.substring(0, 8)}
</span>
{getStatusBadge(build.status)}
</div>
<div className="flex gap-2">
<Button
onClick={() => navigate(`/builds/${build.buildHash}`)}
variant="outline"
size="sm"
className="border-slate-600 hover:bg-slate-800"
>
Public View
</Button>
<Button
onClick={() => navigate(`/builds/new/${build.buildHash}`)}
variant="outline"
size="sm"
className="border-slate-600 hover:bg-slate-800"
>
Clone
</Button>
<Button
onClick={() => handleRetry(build._id)}
className="bg-cyan-600 hover:bg-cyan-700"
size="sm"
>
Re-run Build
</Button>
</div>
</div>
{/* Build Configuration Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="space-y-2">
<div>
<span className="text-sm text-slate-500">Target</span>
<div className="text-sm font-mono text-white mt-1">
{build.config.target}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-slate-300">
<div>
<span className="text-slate-500">Target:</span>{' '}
<span className="font-mono">{build.config.target}</span>
<div>
<span className="text-sm text-slate-500">Version</span>
<div className="text-sm font-mono text-white mt-1">
{build.config.version}
</div>
<div>
<span className="text-slate-500">Version:</span>{' '}
<span className="font-mono">
{build.config.version}
</span>
</div>
<div>
<span className="text-slate-500">
{build.completedAt ? 'Completed' : 'Started'}:
</span>{' '}
</div>
</div>
<div className="space-y-2">
<div>
<span className="text-sm text-slate-500">
{build.completedAt ? 'Completed' : 'Started'}
</span>
<div className="text-sm text-white mt-1">
{build.completedAt
? formatDate(build.completedAt)
: build.startedAt
? formatDate(build.startedAt)
: 'Unknown'}
</div>
<div>
<span className="text-slate-500">Run ID:</span>{' '}
{build.githubRunId ? (
<a
href={`https://github.com/MeshEnvy/configurable-web-flasher/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 underline"
>
{build.githubRunId}
</a>
) : (
'N/A'
)}
</div>
</div>
{build.githubRunIdHistory &&
build.githubRunIdHistory.length > 0 && (
<div className="mt-2 text-xs text-slate-500">
Previous runs:{' '}
{build.githubRunIdHistory.map((id, idx) => (
<span key={id}>
<a
href={`https://github.com/MeshEnvy/configurable-web-flasher/actions/runs/${id}`}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 underline"
>
{id}
</a>
{idx <
(build.githubRunIdHistory?.length ?? 0) - 1 &&
', '}
</span>
))}
</div>
)}
{(build.artifactPath ||
build.sourceUrl ||
build.buildHash) && (
<div className="mt-3 flex gap-3">
{build.artifactPath && (
<BuildDownloadButton build={build} type="firmware" />
)}
{(build.sourceUrl || build.buildHash) && (
<BuildDownloadButton build={build} type="source" />
)}
</div>
)}
</div>
<div className="ml-4">
<Button
onClick={() => handleRetry(build._id)}
className="bg-cyan-600 hover:bg-cyan-700"
>
Re-run Build
</Button>
</div>
</div>
{/* Run History Section */}
{(build.githubRunId ||
(build.githubRunIdHistory?.length ?? 0) > 0) && (
<div className="mb-4 pb-4 border-b border-slate-800">
<span className="text-xs text-slate-500 mb-2 block">
Run History
{(build.githubRunIdHistory?.length ?? 0) > 0 &&
` (${(build.githubRunIdHistory?.length ?? 0) + (build.githubRunId ? 1 : 0)} total)`}
</span>
<div className="flex flex-wrap gap-2">
{build.githubRunId && (
<a
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300 underline font-semibold"
title="Current run"
>
{build.githubRunId}
</a>
)}
{build.githubRunIdHistory?.map((id) => (
<a
key={id}
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${id}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300 underline"
>
{id}
</a>
))}
</div>
</div>
)}
{/* Download Actions */}
{build.buildHash && (
<div className="flex gap-3">
{build.status === 'success' && (
<BuildDownloadButton
build={build}
type={ArtifactType.Firmware}
/>
)}
<BuildDownloadButton
build={build}
type={ArtifactType.Source}
/>
</div>
)}
</div>
))}
</div>
+6 -5
View File
@@ -14,6 +14,7 @@ import { BuildDownloadButton } from '@/components/BuildDownloadButton'
import { Button } from '@/components/ui/button'
import { humanizeStatus } from '@/lib/utils'
import { api } from '../../convex/_generated/api'
import { ArtifactType } from '../../convex/builds'
import { TARGETS } from '../constants/targets'
export default function BuildProgress() {
@@ -94,7 +95,7 @@ export default function BuildProgress() {
const githubActionUrl =
build.githubRunId && build.githubRunId > 0
? `https://github.com/MeshEnvy/configurable-web-flasher/actions/runs/${build.githubRunId}`
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
: null
const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}`
@@ -232,18 +233,18 @@ export default function BuildProgress() {
</div>
)}
{status === 'success' && build.artifactPath && (
{status === 'success' && build.buildHash && (
<BuildDownloadButton
build={build}
type="firmware"
type={ArtifactType.Firmware}
className="w-full bg-cyan-600 hover:bg-cyan-700"
/>
)}
{build.sourceUrl && (
{build.buildHash && (
<BuildDownloadButton
build={build}
type="source"
type={ArtifactType.Source}
variant="outline"
className="w-full bg-slate-700 hover:bg-slate-600"
/>
+1 -2
View File
@@ -11,7 +11,6 @@ import ProfileEditor from '@/components/ProfileEditor'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Doc, Id } from '../../convex/_generated/dataModel'
import type { ProfileFields } from '../../convex/schema'
export default function Dashboard() {
const navigate = useNavigate()
@@ -19,7 +18,7 @@ export default function Dashboard() {
const removeProfile = useMutation(api.profiles.remove)
const [isCreating, setIsCreating] = useState(false)
const handleEdit = (profile: Doc<'profiles'> & ProfileFields) => {
const handleEdit = (profile: Doc<'profiles'>) => {
navigate(`/dashboard/profiles/${profile._id}`)
}
+10 -9
View File
@@ -3,10 +3,12 @@ import { ArrowLeft, CheckCircle, Loader2, XCircle } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ProfileStatisticPills } from '@/components/ProfileCard'
import { SourceAvailable } from '@/components/SourceAvailable'
import { Button } from '@/components/ui/button'
import { humanizeStatus } from '@/lib/utils'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
import { ArtifactType } from '../../convex/builds'
import modulesData from '../../convex/modules.json'
import { TARGETS } from '../constants/targets'
@@ -30,9 +32,6 @@ export default function ProfileFlash() {
id ? { id: id as Id<'profiles'> } : 'skip'
)
const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl)
const generateSourceDownloadUrl = useMutation(
api.builds.generateSourceDownloadUrl
)
useEffect(() => {
if (id && target && profile) {
@@ -94,12 +93,13 @@ export default function ProfileFlash() {
const totalFlashes = profile.flashCount ?? 0
const handleDownload = async () => {
if (!id || !build.artifactPath) return
if (!id || !build.buildHash) return
try {
const url = await generateDownloadUrl({
buildId: build._id,
profileId: id as Id<'profiles'>,
artifactType: ArtifactType.Firmware,
})
window.location.href = url
} catch (error) {
@@ -111,9 +111,10 @@ export default function ProfileFlash() {
if (!id) return
try {
const url = await generateSourceDownloadUrl({
const url = await generateDownloadUrl({
buildId: build._id,
profileId: id as Id<'profiles'>,
artifactType: ArtifactType.Source,
})
window.location.href = url
} catch (error) {
@@ -139,7 +140,7 @@ export default function ProfileFlash() {
const githubActionUrl =
build.githubRunId && build.githubRunId > 0
? `https://github.com/MeshEnvy/configurable-web-flasher/actions/runs/${build.githubRunId}`
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
: null
return (
@@ -232,7 +233,7 @@ export default function ProfileFlash() {
</div>
</div>
{build.status === 'success' && build.artifactPath && (
{build.status === 'success' && build.buildHash && (
<div className="space-y-2">
<Button
onClick={handleDownload}
@@ -243,7 +244,7 @@ export default function ProfileFlash() {
</div>
)}
{build.sourceUrl && (
<SourceAvailable sourcePath={build.sourcePath}>
<div className="space-y-2">
<Button
onClick={handleSourceDownload}
@@ -253,7 +254,7 @@ export default function ProfileFlash() {
Download Source
</Button>
</div>
)}
</SourceAvailable>
</div>
</div>
</div>