diff --git a/.github/workflows/custom_build.yml b/.github/workflows/custom_build.yml index c683eb2..6c95518 100644 --- a/.github/workflows/custom_build.yml +++ b/.github/workflows/custom_build.yml @@ -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 diff --git a/convex/actions.ts b/convex/actions.ts index 84d3a2c..a3e4b16 100644 --- a/convex/actions.ts +++ b/convex/actions.ts @@ -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() diff --git a/convex/admin.ts b/convex/admin.ts index 00382c4..005f6ea 100644 --- a/convex/admin.ts +++ b/convex/admin.ts @@ -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 } diff --git a/convex/builds.ts b/convex/builds.ts index 4373292..291ca5e 100644 --- a/convex/builds.ts +++ b/convex/builds.ts @@ -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}--.tar.gz format. */ export function getR2ArtifactUrl( - build: Pick + build: Pick, '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, - buildId: Id<'builds'>, - profileId: Id<'profiles'>, - objectKey: string, - ext: string, - filenameSuffix: string = '', - contentType: string = 'application/octet-stream', - incrementFlashCount: boolean = true -): Promise { - 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 { - 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) }, }) diff --git a/convex/http.ts b/convex/http.ts index b0058ef..88fb4ca 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -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 }) diff --git a/convex/schema.ts b/convex/schema.ts index 0e0fbb8..205e17a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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 -export type BuildFields = Infer - -const buildConfigFieldsValidator = v.object(buildConfigFields) -export type BuildConfigFields = Infer export default schema diff --git a/src/components/BuildDownloadButton.tsx b/src/components/BuildDownloadButton.tsx index ef3dd45..2bf18dd 100644 --- a/src/components/BuildDownloadButton.tsx +++ b/src/components/BuildDownloadButton.tsx @@ -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(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 = (
{error &&

{error}

}
) + + // For source downloads, only show when sourcePath is available + if (type === ArtifactType.Source) { + return ( + {button} + ) + } + + return button } diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 679b3a1..4acaae0 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -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) { diff --git a/src/components/ProfileEditor.tsx b/src/components/ProfileEditor.tsx index 6c27778..4d9a069 100644 --- a/src/components/ProfileEditor.tsx +++ b/src/components/ProfileEditor.tsx @@ -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 diff --git a/src/components/SourceAvailable.tsx b/src/components/SourceAvailable.tsx new file mode 100644 index 0000000..d591b50 --- /dev/null +++ b/src/components/SourceAvailable.tsx @@ -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} +} diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index 2941675..d2db5e7 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -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" > -
-
-
-

- Build:{' '} - - {build.buildHash.substring(0, 8)} - -

- {getStatusBadge(build.status)} + {/* Header Section */} +
+
+ + {build.buildHash.substring(0, 8)} + + {getStatusBadge(build.status)} +
+
+ + + +
+
+ + {/* Build Configuration Section */} +
+
+
+ Target +
+ {build.config.target} +
-
-
- Target:{' '} - {build.config.target} +
+ Version +
+ {build.config.version}
-
- Version:{' '} - - {build.config.version} - -
-
- - {build.completedAt ? 'Completed' : 'Started'}: - {' '} +
+
+
+
+ + {build.completedAt ? 'Completed' : 'Started'} + +
{build.completedAt ? formatDate(build.completedAt) : build.startedAt ? formatDate(build.startedAt) : 'Unknown'}
-
- Run ID:{' '} - {build.githubRunId ? ( - - {build.githubRunId} - - ) : ( - 'N/A' - )} -
- {build.githubRunIdHistory && - build.githubRunIdHistory.length > 0 && ( -
- Previous runs:{' '} - {build.githubRunIdHistory.map((id, idx) => ( - - - {id} - - {idx < - (build.githubRunIdHistory?.length ?? 0) - 1 && - ', '} - - ))} -
- )} - {(build.artifactPath || - build.sourceUrl || - build.buildHash) && ( -
- {build.artifactPath && ( - - )} - {(build.sourceUrl || build.buildHash) && ( - - )} -
- )} -
-
-
+ + {/* Run History Section */} + {(build.githubRunId || + (build.githubRunIdHistory?.length ?? 0) > 0) && ( +
+ + Run History + {(build.githubRunIdHistory?.length ?? 0) > 0 && + ` (${(build.githubRunIdHistory?.length ?? 0) + (build.githubRunId ? 1 : 0)} total)`} + +
+ {build.githubRunId && ( + + {build.githubRunId} + + )} + {build.githubRunIdHistory?.map((id) => ( + + {id} + + ))} +
+
+ )} + + {/* Download Actions */} + {build.buildHash && ( +
+ {build.status === 'success' && ( + + )} + +
+ )}
))}
diff --git a/src/pages/BuildProgress.tsx b/src/pages/BuildProgress.tsx index 82ccb86..b94cc60 100644 --- a/src/pages/BuildProgress.tsx +++ b/src/pages/BuildProgress.tsx @@ -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() {
)} - {status === 'success' && build.artifactPath && ( + {status === 'success' && build.buildHash && ( )} - {build.sourceUrl && ( + {build.buildHash && ( diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 3d19c0f..fd3ec3c 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -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}`) } diff --git a/src/pages/ProfileFlash.tsx b/src/pages/ProfileFlash.tsx index 2863e5f..192d4a2 100644 --- a/src/pages/ProfileFlash.tsx +++ b/src/pages/ProfileFlash.tsx @@ -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() {
- {build.status === 'success' && build.artifactPath && ( + {build.status === 'success' && build.buildHash && (
- )} +