mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-05-10 07:14:47 +02:00
Add support for W3C Web Serial API types and enhance EspFlasher component. Updated package.json and tsconfig.json to include new type definitions. Improved EspFlasher functionality with progress tracking and error handling during firmware flashing. Refactored espFlashLayout and espFlashRun for better firmware resolution and flashing process. Updated RepoPage to handle flash view state and integrate flashing functionality.
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/w3c-web-serial": "^1.0.8",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"postcss": "^8.5.9",
|
||||
@@ -592,6 +593,8 @@
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/w3c-web-serial": ["@types/w3c-web-serial@1.0.8", "", {}, "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.1", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.47", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA=="],
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/w3c-web-serial": "^1.0.8",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"postcss": "^8.5.9",
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { buildFlashParts, layoutPreviewFromManifest, manifestFromMap, type FlashManifest } from '../lib/espFlashLayout'
|
||||
import { pulseUsbBootloaderPort, runEspFlash } from '../lib/espFlashRun'
|
||||
import { extractTarGz, findInTar } from '../lib/untarGz'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { buildFlashParts, layoutPreviewFromManifest, manifestFromMap } from '../lib/espFlashLayout'
|
||||
import type { FlashManifest } from '../lib/untarGz'
|
||||
import {
|
||||
isSerialUserCancelledError,
|
||||
pulseUsbBootloaderPort,
|
||||
runEspFlash,
|
||||
type FlashPhase,
|
||||
} from '../lib/espFlashRun'
|
||||
import { extractTarGz } from '../lib/untarGz'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type FlashProgress =
|
||||
| { kind: 'indeterminate'; label: string }
|
||||
| { kind: 'determinate'; label: string; pct: number }
|
||||
|
||||
const PHASE_LABEL: Record<FlashPhase, string> = {
|
||||
connect: 'Connecting to bootloader…',
|
||||
detect: 'Detecting flash size…',
|
||||
write: 'Writing firmware…',
|
||||
}
|
||||
|
||||
type EspFlasherProps = {
|
||||
bundleUrl: string
|
||||
/** Primary CTA label (default matches standalone copy). */
|
||||
@@ -29,26 +45,7 @@ export default function EspFlasher({
|
||||
const [noReset, setNoReset] = useState(false)
|
||||
const [baud, setBaud] = useState(921600)
|
||||
const [layoutPreview, setLayoutPreview] = useState<FlashManifest | null>(null)
|
||||
const [log, setLog] = useState('')
|
||||
const logRef = useRef('')
|
||||
|
||||
const terminal = useMemo(
|
||||
() => ({
|
||||
clean: () => {
|
||||
logRef.current = ''
|
||||
setLog('')
|
||||
},
|
||||
write: (data: string) => {
|
||||
logRef.current += data
|
||||
setLog(logRef.current)
|
||||
},
|
||||
writeLine: (data: string) => {
|
||||
logRef.current += data + '\n'
|
||||
setLog(logRef.current)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const [flashProgress, setFlashProgress] = useState<FlashProgress | null>(null)
|
||||
|
||||
const prepareBundle = useCallback(async () => {
|
||||
const res = await fetch(bundleUrl)
|
||||
@@ -66,32 +63,46 @@ export default function EspFlasher({
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
terminal.clean()
|
||||
setFlashProgress({ kind: 'indeterminate', label: 'Select a serial port…' })
|
||||
try {
|
||||
const port = await navigator.serial.requestPort()
|
||||
setFlashProgress({ kind: 'indeterminate', label: 'Downloading firmware…' })
|
||||
const files = await prepareBundle()
|
||||
const parts = buildFlashParts(files)
|
||||
if (!parts) {
|
||||
toast.error('Could not detect flash layout from bundle')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
|
||||
await runEspFlash({
|
||||
port,
|
||||
parts,
|
||||
baud,
|
||||
eraseAll,
|
||||
terminal,
|
||||
resetMode: noReset ? 'no_reset' : 'default_reset',
|
||||
onPhase: phase => {
|
||||
setFlashProgress({ kind: 'indeterminate', label: PHASE_LABEL[phase] })
|
||||
},
|
||||
onWriteProgress: p => {
|
||||
setFlashProgress({
|
||||
kind: 'determinate',
|
||||
label: `Writing firmware (${p.imageIndex + 1}/${p.imageCount})`,
|
||||
pct: p.overallPct,
|
||||
})
|
||||
},
|
||||
})
|
||||
toast.success('Flash complete')
|
||||
} catch (e) {
|
||||
if (isSerialUserCancelledError(e)) {
|
||||
return
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
terminal.writeLine(`\nError: ${msg}`)
|
||||
toast.error('Flash failed', { description: msg })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setFlashProgress(null)
|
||||
}
|
||||
}, [baud, eraseAll, noReset, prepareBundle, terminal])
|
||||
}, [baud, eraseAll, noReset, prepareBundle])
|
||||
|
||||
const boot1200 = useCallback(async () => {
|
||||
try {
|
||||
@@ -100,6 +111,9 @@ export default function EspFlasher({
|
||||
description: 'If the board did not enter bootloader, hold BOOT, tap RST, then try flash again.',
|
||||
})
|
||||
} catch (e) {
|
||||
if (isSerialUserCancelledError(e)) {
|
||||
return
|
||||
}
|
||||
toast.error(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
@@ -175,10 +189,20 @@ export default function EspFlasher({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{log ? (
|
||||
<pre className="text-xs text-slate-400 max-h-48 overflow-auto whitespace-pre-wrap bg-black/40 p-2 rounded">
|
||||
{log}
|
||||
</pre>
|
||||
{flashProgress ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-slate-400">{flashProgress.label}</p>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-800">
|
||||
{flashProgress.kind === 'determinate' ? (
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-600 transition-[width] duration-150 ease-out"
|
||||
style={{ width: `${flashProgress.pct}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-full bg-amber-600/50 animate-pulse" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,45 @@ import { findInTar, parseFlashManifest, type FlashManifest } from './untarGz'
|
||||
|
||||
export type FlashPart = { data: Uint8Array; address: number; name: string }
|
||||
|
||||
function tarBasename(path: string): string {
|
||||
const parts = path.replace(/^\.\//, '').split('/')
|
||||
return parts[parts.length - 1] ?? path
|
||||
}
|
||||
|
||||
/**
|
||||
* PlatformIO projects (e.g. Meshtastic) often emit versioned names like
|
||||
* firmware-heltec-v3-2.7.20.factory.bin instead of firmware.factory.bin.
|
||||
*/
|
||||
function resolveVersionedFirmwareApp(
|
||||
files: Map<string, Uint8Array>
|
||||
): { data: Uint8Array; name: string } | undefined {
|
||||
type Entry = { base: string; data: Uint8Array }
|
||||
const list: Entry[] = []
|
||||
for (const [path, data] of files) {
|
||||
const base = tarBasename(path)
|
||||
const lower = base.toLowerCase()
|
||||
if (lower.startsWith('littlefs-')) continue
|
||||
if (
|
||||
lower === 'bootloader.bin' ||
|
||||
lower === 'partitions.bin' ||
|
||||
lower === 'boot_app0.bin'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
list.push({ base, data })
|
||||
}
|
||||
|
||||
const factory = list.find(e => /^firmware-.+\.factory\.bin$/i.test(e.base))
|
||||
if (factory) return { data: factory.data, name: factory.base }
|
||||
|
||||
const app = list.find(
|
||||
e => /^firmware-.+\.bin$/i.test(e.base) && !/\.factory\.bin$/i.test(e.base)
|
||||
)
|
||||
if (app) return { data: app.data, name: app.base }
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function layoutPreviewFromManifest(m: FlashManifest): string[] {
|
||||
return m.images.map(im => `${im.file} @ ${String(im.offset)}`)
|
||||
}
|
||||
@@ -28,22 +67,35 @@ export function buildFlashParts(files: Map<string, Uint8Array>): FlashPart[] | n
|
||||
const bootloader = findInTar(files, 'bootloader.bin')
|
||||
const partitions = findInTar(files, 'partitions.bin')
|
||||
const bootApp0 = findInTar(files, 'boot_app0.bin')
|
||||
const factory = findInTar(files, 'firmware.factory.bin')
|
||||
const firmware = findInTar(files, 'firmware.bin')
|
||||
const factoryExact = findInTar(files, 'firmware.factory.bin')
|
||||
const firmwareExact = findInTar(files, 'firmware.bin')
|
||||
const versioned = resolveVersionedFirmwareApp(files)
|
||||
|
||||
const app = factory ?? firmware
|
||||
if (bootloader && partitions && app) {
|
||||
let app: Uint8Array | undefined
|
||||
let appName: string | undefined
|
||||
if (factoryExact) {
|
||||
app = factoryExact
|
||||
appName = 'firmware.factory.bin'
|
||||
} else if (firmwareExact) {
|
||||
app = firmwareExact
|
||||
appName = 'firmware.bin'
|
||||
} else if (versioned) {
|
||||
app = versioned.data
|
||||
appName = versioned.name
|
||||
}
|
||||
|
||||
if (bootloader && partitions && app && appName) {
|
||||
const arr: FlashPart[] = [
|
||||
{ data: bootloader, address: 0x1000, name: 'bootloader.bin' },
|
||||
{ data: partitions, address: 0x8000, name: 'partitions.bin' },
|
||||
{ data: app, address: 0x10000, name: factory ? 'firmware.factory.bin' : 'firmware.bin' },
|
||||
{ data: app, address: 0x10000, name: appName },
|
||||
]
|
||||
if (bootApp0) arr.push({ data: bootApp0, address: 0xe000, name: 'boot_app0.bin' })
|
||||
return arr
|
||||
}
|
||||
|
||||
if (app && !bootloader && !partitions) {
|
||||
return [{ data: app, address: 0x0, name: factory ? 'firmware.factory.bin' : 'firmware.bin' }]
|
||||
if (app && appName && !bootloader && !partitions) {
|
||||
return [{ data: app, address: 0x0, name: appName }]
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
+55
-7
@@ -1,25 +1,58 @@
|
||||
import { ESPLoader, Transport } from 'esptool-js'
|
||||
import { ESPLoader, Transport, type FlashSizeValues } from 'esptool-js'
|
||||
import type { FlashPart } from './espFlashLayout'
|
||||
|
||||
type EspTerminal = {
|
||||
export type EspTerminal = {
|
||||
clean: () => void
|
||||
write: (data: string) => void
|
||||
writeLine: (data: string) => void
|
||||
}
|
||||
|
||||
export const noopEspTerminal: EspTerminal = {
|
||||
clean: () => {},
|
||||
write: () => {},
|
||||
writeLine: () => {},
|
||||
}
|
||||
|
||||
export function isSerialUserCancelledError(e: unknown): boolean {
|
||||
if (e instanceof DOMException && e.name === 'NotFoundError') return true
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return msg.includes('No port selected')
|
||||
}
|
||||
|
||||
export type FlashPhase = 'connect' | 'detect' | 'write'
|
||||
|
||||
export type WriteProgressPayload = {
|
||||
imageIndex: number
|
||||
imageCount: number
|
||||
written: number
|
||||
total: number
|
||||
overallPct: number
|
||||
}
|
||||
|
||||
export async function runEspFlash(options: {
|
||||
parts: FlashPart[]
|
||||
port: SerialPort
|
||||
baud: number
|
||||
eraseAll: boolean
|
||||
terminal: EspTerminal
|
||||
terminal?: EspTerminal
|
||||
resetMode?: 'default_reset' | 'no_reset'
|
||||
onPhase?: (phase: FlashPhase) => void
|
||||
onWriteProgress?: (p: WriteProgressPayload) => void
|
||||
}): Promise<void> {
|
||||
const { parts, baud, eraseAll, terminal, resetMode = 'default_reset' } = options
|
||||
const {
|
||||
parts,
|
||||
port,
|
||||
baud,
|
||||
eraseAll,
|
||||
terminal = noopEspTerminal,
|
||||
resetMode = 'default_reset',
|
||||
onPhase,
|
||||
onWriteProgress,
|
||||
} = options
|
||||
if (!('serial' in navigator)) {
|
||||
throw new Error('Web Serial is not available (use Chromium on https:// or localhost)')
|
||||
}
|
||||
|
||||
const port = await navigator.serial.requestPort()
|
||||
const transport = new Transport(port)
|
||||
const loader = new ESPLoader({
|
||||
transport,
|
||||
@@ -28,10 +61,15 @@ export async function runEspFlash(options: {
|
||||
})
|
||||
|
||||
const fileArray = parts.map(p => ({ data: p.data, address: p.address }))
|
||||
const lengths = fileArray.map(f => f.data.byteLength)
|
||||
const totalBytes = lengths.reduce((a, b) => a + b, 0)
|
||||
|
||||
onPhase?.('connect')
|
||||
await loader.main(resetMode)
|
||||
const flashSize = await loader.detectFlashSize()
|
||||
onPhase?.('detect')
|
||||
const flashSize = (await loader.detectFlashSize()) as FlashSizeValues
|
||||
|
||||
onPhase?.('write')
|
||||
await loader.writeFlash({
|
||||
fileArray,
|
||||
flashMode: 'dio',
|
||||
@@ -40,7 +78,17 @@ export async function runEspFlash(options: {
|
||||
eraseAll,
|
||||
compress: true,
|
||||
reportProgress: (i, written, total) => {
|
||||
loader.info(`Image ${i + 1}/${fileArray.length}: ${Math.round((100 * written) / total)}%`)
|
||||
let offset = 0
|
||||
for (let j = 0; j < i; j++) offset += lengths[j] ?? 0
|
||||
const overallPct =
|
||||
totalBytes > 0 ? Math.min(100, Math.round((100 * (offset + written)) / totalBytes)) : 0
|
||||
onWriteProgress?.({
|
||||
imageIndex: i,
|
||||
imageCount: fileArray.length,
|
||||
written,
|
||||
total,
|
||||
overallPct,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+21
-8
@@ -1,26 +1,37 @@
|
||||
/**
|
||||
* Mesh Forge tree URLs: `/owner/repo/tree/<tag-or-ref segments>/target/<env>`
|
||||
* Mesh Forge tree URLs: `/owner/repo/tree/<tag-or-ref segments>/target/<env>` with optional `/flash` for the flasher-only view.
|
||||
* Source ref may contain `/` (nested tags are rare but allowed). `target` is a reserved final segment pair.
|
||||
*/
|
||||
const TARGET_TAIL = /\/target\/([^/]+)$/
|
||||
const FLASH_AFTER_TARGET = /\/target\/[^/]+\/flash$/
|
||||
|
||||
export function parseTreeSplat(treePath: string | undefined): {
|
||||
sourceRef: string | null
|
||||
targetEnv: string | null
|
||||
flash: boolean
|
||||
} {
|
||||
if (!treePath?.trim()) return { sourceRef: null, targetEnv: null }
|
||||
if (!treePath?.trim()) return { sourceRef: null, targetEnv: null, flash: false }
|
||||
const segments = treePath.split("/").filter(Boolean)
|
||||
if (segments.length === 0) return { sourceRef: null, targetEnv: null }
|
||||
const joined = segments.map(s => decodeURIComponent(s)).join("/")
|
||||
if (segments.length === 0) return { sourceRef: null, targetEnv: null, flash: false }
|
||||
let joined = segments.map(s => decodeURIComponent(s)).join("/")
|
||||
let flash = false
|
||||
if (FLASH_AFTER_TARGET.test(joined)) {
|
||||
flash = true
|
||||
joined = joined.slice(0, -"/flash".length)
|
||||
}
|
||||
const m = TARGET_TAIL.exec(joined)
|
||||
if (!m) return { sourceRef: joined, targetEnv: null }
|
||||
if (!m) return { sourceRef: joined, targetEnv: null, flash }
|
||||
const sourceRef = joined.slice(0, m.index).replace(/\/$/, "") || null
|
||||
const targetEnv = m[1] ? decodeURIComponent(m[1]) : null
|
||||
return { sourceRef, targetEnv }
|
||||
return { sourceRef, targetEnv, flash }
|
||||
}
|
||||
|
||||
/** Path after `/tree/` (no leading slash). Empty string means no ref — short `/owner/repo` redirects to latest tag. */
|
||||
export function buildTreeSplatPath(sourceRef: string | null, targetEnv: string | null): string {
|
||||
export function buildTreeSplatPath(
|
||||
sourceRef: string | null,
|
||||
targetEnv: string | null,
|
||||
opts?: { flash?: boolean }
|
||||
): string {
|
||||
const b = sourceRef?.trim()
|
||||
if (!b) return ""
|
||||
const enc = b
|
||||
@@ -29,5 +40,7 @@ export function buildTreeSplatPath(sourceRef: string | null, targetEnv: string |
|
||||
.join("/")
|
||||
const t = targetEnv?.trim()
|
||||
if (!t) return enc
|
||||
return `${enc}/target/${encodeURIComponent(t)}`
|
||||
const base = `${enc}/target/${encodeURIComponent(t)}`
|
||||
if (opts?.flash) return `${base}/flash`
|
||||
return base
|
||||
}
|
||||
|
||||
+169
-94
@@ -37,7 +37,11 @@ export default function RepoPage() {
|
||||
const treePath = params["*"]
|
||||
const owner = useMemo(() => decodeURIComponent(ownerParam), [ownerParam])
|
||||
const repo = useMemo(() => decodeURIComponent(repoParam), [repoParam])
|
||||
const { sourceRef, targetEnv: targetFromUrl } = useMemo(() => parseTreeSplat(treePath), [treePath])
|
||||
const { sourceRef, targetEnv: targetFromUrl, flash: flashFromUrl } = useMemo(
|
||||
() => parseTreeSplat(treePath),
|
||||
[treePath]
|
||||
)
|
||||
const isFlashView = flashFromUrl
|
||||
const hasRef = Boolean(sourceRef)
|
||||
|
||||
const tagData = useQuery(api.repoTags.get, owner && repo ? { owner, repo } : "skip")
|
||||
@@ -103,6 +107,11 @@ export default function RepoPage() {
|
||||
const [readmeMd, setReadmeMd] = useState<string | null>(null)
|
||||
const [readmeDownloadUrl, setReadmeDownloadUrl] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (isFlashView) {
|
||||
setReadmeMd(null)
|
||||
setReadmeDownloadUrl(null)
|
||||
return
|
||||
}
|
||||
if (!effectiveRef) {
|
||||
setReadmeMd(null)
|
||||
setReadmeDownloadUrl(null)
|
||||
@@ -127,7 +136,7 @@ export default function RepoPage() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [owner, repo, effectiveRef, fetchReadme])
|
||||
}, [owner, repo, effectiveRef, fetchReadme, isFlashView])
|
||||
|
||||
const readmeMarkdownComponents = useMemo(
|
||||
() => ({
|
||||
@@ -240,6 +249,11 @@ export default function RepoPage() {
|
||||
const [flashPrep, setFlashPrep] = useState<"idle" | "loading" | "ready" | "error">("idle")
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFlashView) {
|
||||
setFlashUrl(null)
|
||||
setFlashPrep("idle")
|
||||
return
|
||||
}
|
||||
if (!build?._id || build.status !== "succeeded") {
|
||||
setFlashUrl(null)
|
||||
setFlashPrep("idle")
|
||||
@@ -262,13 +276,43 @@ export default function RepoPage() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [build?._id, build?.status, getSignedUrl])
|
||||
}, [isFlashView, build?._id, build?.status, getSignedUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFlashView || !owner || !repo || !effectiveRef || !resolvedSha || !resolvedTargetEnv) return
|
||||
if (!(hasRef && resolvedSha && scan?.scanStatus === "complete" && envNames.length > 0)) return
|
||||
void ensureBuild({
|
||||
owner,
|
||||
repo,
|
||||
ref: effectiveRef,
|
||||
resolvedSourceSha: resolvedSha,
|
||||
targetEnv: resolvedTargetEnv,
|
||||
}).catch(e => toast.error(String(e)))
|
||||
}, [
|
||||
isFlashView,
|
||||
owner,
|
||||
repo,
|
||||
effectiveRef,
|
||||
resolvedSha,
|
||||
resolvedTargetEnv,
|
||||
hasRef,
|
||||
scan,
|
||||
envNames.length,
|
||||
ensureBuild,
|
||||
])
|
||||
|
||||
const queueFlashArtifacts = () => {
|
||||
if (!effectiveRef || !resolvedSha || !resolvedTargetEnv) return
|
||||
const goFlash = () =>
|
||||
navigate(
|
||||
`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(effectiveRef, resolvedTargetEnv, { flash: true })}`
|
||||
)
|
||||
if (build?.status === "failed" && build._id) {
|
||||
void retryBuild({ buildId: build._id })
|
||||
.then(() => toast.message("Starting a new build…"))
|
||||
.then(() => {
|
||||
toast.message("Starting a new build…")
|
||||
goFlash()
|
||||
})
|
||||
.catch(e => toast.error(String(e)))
|
||||
return
|
||||
}
|
||||
@@ -278,7 +322,11 @@ export default function RepoPage() {
|
||||
ref: effectiveRef,
|
||||
resolvedSourceSha: resolvedSha,
|
||||
targetEnv: resolvedTargetEnv,
|
||||
}).catch(e => toast.error(String(e)))
|
||||
})
|
||||
.then(() => {
|
||||
goFlash()
|
||||
})
|
||||
.catch(e => toast.error(String(e)))
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
@@ -357,9 +405,121 @@ export default function RepoPage() {
|
||||
? "No targets"
|
||||
: "--target--"
|
||||
|
||||
const backToRepoPath = `/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, resolvedTargetEnv || null)}`
|
||||
|
||||
const statusStripEl = (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||
{tagData?.isStale ? <span>Tag list may be stale.</span> : null}
|
||||
{refError ? <span className="text-red-400">{refError}</span> : null}
|
||||
{!refError && hasRef && !resolvedSha ? <span>Resolving tag…</span> : null}
|
||||
{resolvedSha && (scan == null || scan.scanStatus === "in_progress") ? (
|
||||
<span>Scanning PlatformIO…</span>
|
||||
) : null}
|
||||
{resolvedSha && scan?.scanStatus === "failed" ? (
|
||||
<span className="text-red-300">Scan failed: {scan.scanError ?? "unknown"}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
const ciAndFlasherEl = (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
{showCiCard ? (
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900/40 p-4 space-y-2 text-sm">
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-slate-500">CI</span>
|
||||
<span className="text-white font-medium">{build.status}</span>
|
||||
{build.githubRunId ? (
|
||||
<a
|
||||
className="text-cyan-400 hover:underline text-xs"
|
||||
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View run on GitHub
|
||||
</a>
|
||||
) : build.status === "failed" ? (
|
||||
<a
|
||||
className="text-cyan-400 hover:underline text-xs"
|
||||
href={meshForgeWorkflowUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="No run ID — usually the workflow never started (e.g. dispatch rejected). Open the Mesh Forge workflow to fix YAML or inspect recent runs."
|
||||
>
|
||||
Mesh Forge workflow on GitHub
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
{build.status === "failed" && build.errorSummary ? (
|
||||
<div className="space-y-2 text-xs">
|
||||
{(() => {
|
||||
const { headline, body } = buildFailurePresentation(build.errorSummary)
|
||||
return (
|
||||
<>
|
||||
<p className="font-medium text-slate-200">{headline}</p>
|
||||
{body ? <p className="text-slate-400 leading-relaxed">{body}</p> : null}
|
||||
<details className="text-slate-500">
|
||||
<summary className="cursor-pointer select-none hover:text-slate-400">Technical details</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap wrap-break-word text-[11px] text-red-300/90">
|
||||
{build.errorSummary.length > 2500
|
||||
? `…${build.errorSummary.slice(-2500)}`
|
||||
: build.errorSummary}
|
||||
</pre>
|
||||
</details>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
{build.status === "succeeded" ? (
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void download()}>
|
||||
Download bundle
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{flashPrep === "loading" ? <p className="text-sm text-slate-400">Preparing USB flasher…</p> : null}
|
||||
{flashPrep === "error" ? (
|
||||
<p className="text-sm text-amber-200/90">
|
||||
Could not load a signed URL for flashing. Use <strong>Download bundle</strong> if you need the file.
|
||||
</p>
|
||||
) : null}
|
||||
{flashUrl ? (
|
||||
<EspFlasher
|
||||
bundleUrl={flashUrl}
|
||||
condensed
|
||||
flashButtonLabel="USB flash"
|
||||
flashBusyLabel="Writing…"
|
||||
flashButtonSize="lg"
|
||||
className="border-amber-900/50 bg-amber-950/25"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-10 text-slate-200">
|
||||
<div
|
||||
className={`${isFlashView ? "max-w-3xl" : "max-w-6xl"} mx-auto px-6 py-10 text-slate-200`}
|
||||
>
|
||||
<section className="rounded-2xl border border-slate-700/90 bg-slate-950/90 p-6 md:p-8 shadow-xl shadow-black/30">
|
||||
{isFlashView ? (
|
||||
<div className="min-w-0 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold tracking-tight text-slate-100 wrap-break-word">
|
||||
{owner}/{repo}@{effectiveRef}
|
||||
{resolvedTargetEnv ? ` ${resolvedTargetEnv}` : ""} Flasher
|
||||
</h1>
|
||||
<Link
|
||||
to={backToRepoPath}
|
||||
className="inline-block text-sm text-cyan-400 hover:underline"
|
||||
>
|
||||
← Repository
|
||||
</Link>
|
||||
</div>
|
||||
{statusStripEl}
|
||||
{ciAndFlasherEl}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_17.5rem] lg:gap-10 items-start
|
||||
[grid-template-areas:'repo-main''repo-aside''repo-readme']
|
||||
@@ -382,7 +542,7 @@ export default function RepoPage() {
|
||||
return
|
||||
}
|
||||
if (tagOptions.includes(v)) {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(v, null)}`)
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(v, targetFromUrl)}`)
|
||||
}
|
||||
}}
|
||||
disabled={tagOptions.length === 0}
|
||||
@@ -435,93 +595,7 @@ export default function RepoPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||
{tagData?.isStale ? <span>Tag list may be stale.</span> : null}
|
||||
{refError ? <span className="text-red-400">{refError}</span> : null}
|
||||
{!refError && hasRef && !resolvedSha ? <span>Resolving tag…</span> : null}
|
||||
{resolvedSha && (scan == null || scan.scanStatus === "in_progress") ? (
|
||||
<span>Scanning PlatformIO…</span>
|
||||
) : null}
|
||||
{resolvedSha && scan?.scanStatus === "failed" ? (
|
||||
<span className="text-red-300">Scan failed: {scan.scanError ?? "unknown"}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl space-y-4">
|
||||
{showCiCard ? (
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900/40 p-4 space-y-2 text-sm">
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-slate-500">CI</span>
|
||||
<span className="text-white font-medium">{build.status}</span>
|
||||
{build.githubRunId ? (
|
||||
<a
|
||||
className="text-cyan-400 hover:underline text-xs"
|
||||
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View run on GitHub
|
||||
</a>
|
||||
) : build.status === "failed" ? (
|
||||
<a
|
||||
className="text-cyan-400 hover:underline text-xs"
|
||||
href={meshForgeWorkflowUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="No run ID — usually the workflow never started (e.g. dispatch rejected). Open the Mesh Forge workflow to fix YAML or inspect recent runs."
|
||||
>
|
||||
Mesh Forge workflow on GitHub
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
{build.status === "failed" && build.errorSummary ? (
|
||||
<div className="space-y-2 text-xs">
|
||||
{(() => {
|
||||
const { headline, body } = buildFailurePresentation(build.errorSummary)
|
||||
return (
|
||||
<>
|
||||
<p className="font-medium text-slate-200">{headline}</p>
|
||||
{body ? <p className="text-slate-400 leading-relaxed">{body}</p> : null}
|
||||
<details className="text-slate-500">
|
||||
<summary className="cursor-pointer select-none hover:text-slate-400">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap wrap-break-word text-[11px] text-red-300/90">
|
||||
{build.errorSummary.length > 2500
|
||||
? `…${build.errorSummary.slice(-2500)}`
|
||||
: build.errorSummary}
|
||||
</pre>
|
||||
</details>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
{build.status === "succeeded" ? (
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void download()}>
|
||||
Download bundle
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{flashPrep === "loading" ? <p className="text-sm text-slate-400">Preparing USB flasher…</p> : null}
|
||||
{flashPrep === "error" ? (
|
||||
<p className="text-sm text-amber-200/90">
|
||||
Could not load a signed URL for flashing. Use <strong>Download bundle</strong> if you need the file.
|
||||
</p>
|
||||
) : null}
|
||||
{flashUrl ? (
|
||||
<EspFlasher
|
||||
bundleUrl={flashUrl}
|
||||
condensed
|
||||
flashButtonLabel="USB flash"
|
||||
flashBusyLabel="Writing…"
|
||||
flashButtonSize="lg"
|
||||
className="border-amber-900/50 bg-amber-950/25"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{statusStripEl}
|
||||
</div>
|
||||
|
||||
<aside className="[grid-area:repo-aside] border-b border-slate-800 pb-8 lg:border-b-0 lg:border-l lg:border-slate-800 lg:pb-0 lg:pl-8 space-y-4">
|
||||
@@ -603,6 +677,7 @@ export default function RepoPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "w3c-web-serial"],
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
Reference in New Issue
Block a user