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:
Ben Allfree
2026-04-10 05:59:51 -07:00
parent 346e468ea5
commit cf689d351d
8 changed files with 366 additions and 150 deletions
+3
View File
@@ -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=="],
+1
View File
@@ -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",
+57 -33
View File
@@ -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>
)
+59 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,