mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-09 10:22:49 +02:00
refactor: update build artifact handling and download functionality
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user