// frontend/src/pages/manufacturing/firmware/FlashAssetManager.jsx
import { useState, useEffect, useCallback } from 'react'
import api from '@/lib/api'
import { useAuth } from '@/hooks/useAuth'
import PageHeader from '@/components/ui/PageHeader'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import Spinner from '@/components/ui/Spinner'
import Icon from '@/components/ui/Icon'
import Modal from '@/components/ui/Modal'
import FormField from '@/components/ui/FormField'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
import UploadAssetModal from '@/modals/engineering/manufacturing/UploadAssetModal'
import { fmtDateTimeMedium } from '@/lib/formatters'
// ─── Constants ────────────────────────────────────────────────────────────────
const KNOWN_BOARD_TYPES = [
{ value: 'vesper', label: 'Vesper', family: 'vesper' },
{ value: 'vesper_plus', label: 'Vesper+', family: 'vesper' },
{ value: 'vesper_pro', label: 'Vesper Pro', family: 'vesper' },
{ value: 'agnus', label: 'Agnus', family: 'agnus' },
{ value: 'agnus_mini', label: 'Agnus Mini', family: 'agnus' },
{ value: 'chronos', label: 'Chronos', family: 'chronos' },
{ value: 'chronos_pro', label: 'Chronos Pro', family: 'chronos' },
]
const FAMILY_ACCENT = {
vesper: 'var(--color-info)',
agnus: 'var(--color-warning)',
chronos: 'var(--color-danger)',
bespoke: 'var(--color-primary)',
}
const BOARD_LABELS = Object.fromEntries(KNOWN_BOARD_TYPES.map((b) => [b.value, b.label]))
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatBytes(bytes) {
if (!bytes) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}
function formatDate(iso) { return fmtDateTimeMedium(iso) }
function boardLabel(hwType, isBespoke) {
if (isBespoke) return hwType
return BOARD_LABELS[hwType] || hwType
}
function familyOf(hwType, isBespoke) {
if (isBespoke) return 'bespoke'
return KNOWN_BOARD_TYPES.find((b) => b.value === hwType)?.family ?? 'vesper'
}
// ─── Note Modal ───────────────────────────────────────────────────────────────
function NoteModal({ open, hwType, currentNote, onClose, onSaved }) {
const [note, setNote] = useState(currentNote || '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const handleSave = async () => {
setSaving(true); setError('')
try {
await api.put(`/manufacturing/flash-assets/${hwType}/note`, { note })
onSaved(note)
onClose()
} catch (err) {
setError(err.message || 'Failed to save note')
} finally {
setSaving(false)
}
}
return (
}
>
Board: {hwType}
{' '}— stored alongside binaries as{' '}
note.txt
{error && (
{error}
)}
setNote(e.target.value)}
placeholder="e.g. Built from PlatformIO env:vesper_plus_v2, commit abc1234…"
hint="Leave blank to clear the note."
/>
)
}
// ─── Asset Pill ───────────────────────────────────────────────────────────────
function AssetPill({ label, info, canDelete, onUpload, onDelete }) {
const exists = info?.exists
return (
{label}
{exists ? (
{formatBytes(info.size_bytes)}{info.uploaded_at ? ` · ${formatDate(info.uploaded_at)}` : ''}
) : (
Not uploaded
)}
{exists && canDelete && (
)}
)
}
// ─── Board Card ───────────────────────────────────────────────────────────────
function BoardCard({ entry, canDelete, canEdit, onUpload, onDelete, onNote, onDeleteNote }) {
const { hw_type, bootloader, partitions, note, is_bespoke } = entry
const family = familyOf(hw_type, is_bespoke)
const accent = FAMILY_ACCENT[family] || 'var(--color-primary)'
const label = boardLabel(hw_type, is_bespoke)
const hasBootloader = bootloader?.exists
const hasPartitions = partitions?.exists
const hasAll = hasBootloader && hasPartitions
const hasNone = !hasBootloader && !hasPartitions
const readyVariant = hasAll ? 'success' : hasNone ? 'danger' : 'warning'
const readyLabel = hasAll ? 'Ready' : hasNone ? 'Missing' : 'Partial'
const [noteHovered, setNoteHovered] = useState(false)
return (
{/* Header */}
{label}
{is_bespoke && bespoke}
{hw_type}
{readyLabel}
{canEdit && (
)}
{/* Asset pills */}
onUpload(hw_type, 'bootloader.bin')}
onDelete={() => onDelete(hw_type, 'bootloader.bin')} />
onUpload(hw_type, 'partitions.bin')}
onDelete={() => onDelete(hw_type, 'partitions.bin')} />
{/* Note — shown below the files, hover-X to delete */}
{note && (
setNoteHovered(true)}
onMouseLeave={() => setNoteHovered(false)}
style={{
padding: 'var(--space-2) var(--space-4)',
backgroundColor: 'var(--color-bg-elevated)',
borderTop: '1px solid var(--color-border)',
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-2)',
}}
>
{note}
{canEdit && noteHovered && (
)}
)}
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function FlashAssetManager({ embedded = false }) {
const { hasPermission } = useAuth()
const canDelete = hasPermission('manufacturing', 'delete')
const canEdit = hasPermission('manufacturing', 'edit')
const [assets, setAssets] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [filter, setFilter] = useState('all')
const [uploadTarget, setUploadTarget] = useState(null)
const [deleteTarget, setDeleteTarget] = useState(null)
const [noteTarget, setNoteTarget] = useState(null)
const [noteDelTarget, setNoteDelTarget] = useState(null)
const [showConfirmDel, setShowConfirmDel] = useState(false)
const fetchAssets = useCallback(async () => {
setLoading(true); setError('')
try {
const data = await api.get('/manufacturing/flash-assets')
setAssets(data.assets || [])
} catch (err) {
setError(err.message || 'Failed to load flash assets')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchAssets() }, [fetchAssets])
const handleDelete = async () => {
if (!deleteTarget) return
try {
const token = localStorage.getItem('access_token')
const res = await fetch(`/api/manufacturing/flash-assets/${deleteTarget.hwType}/${deleteTarget.assetName}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Delete failed')
setShowConfirmDel(false); setDeleteTarget(null)
fetchAssets()
} catch (err) {
setError(err.message)
}
}
const handleNoteUpdate = (hwType, newNote) => {
setAssets((prev) => prev.map((a) => a.hw_type === hwType ? { ...a, note: newNote } : a))
}
const handleDeleteNote = async (entry) => {
try {
await api.put(`/manufacturing/flash-assets/${entry.hw_type}/note`, { note: '' })
handleNoteUpdate(entry.hw_type, '')
} catch (err) {
setError(err.message || 'Failed to delete note')
} finally {
setNoteDelTarget(null)
}
}
const filteredAssets = assets.filter((e) => {
const hasAll = e.bootloader?.exists && e.partitions?.exists
const hasNone = !e.bootloader?.exists && !e.partitions?.exists
if (filter === 'ready') return hasAll
if (filter === 'missing') return hasNone
if (filter === 'partial') return !hasAll && !hasNone
return true
})
const readyCount = assets.filter((e) => e.bootloader?.exists && e.partitions?.exists).length
const missingCount = assets.filter((e) => !e.bootloader?.exists && !e.partitions?.exists).length
const partialCount = assets.length - readyCount - missingCount
const FILTER_OPTIONS = [
{ value: 'all', label: `All (${assets.length})` },
{ value: 'ready', label: `Ready (${readyCount})` },
{ value: 'partial', label: `Partial (${partialCount})` },
{ value: 'missing', label: `Missing (${missingCount})` },
]
const content = (
{[
{ label: 'Total', value: assets.length, variant: 'neutral' },
{ label: 'Ready', value: readyCount, variant: 'success' },
{ label: 'Partial', value: partialCount, variant: 'warning' },
{ label: 'Missing', value: missingCount, variant: 'danger' },
].map(({ label, value, variant }) => (
{label}
{value}
))}
{FILTER_OPTIONS.map(({ value, label }) => (
))}
{error && (
{error}
)}
{loading ? (
) : filteredAssets.length === 0 ? (
No assets match the current filter.
) : (
{filteredAssets.map((entry) => (
setUploadTarget({ hwType, assetName })}
onDelete={(hwType, assetName) => { setDeleteTarget({ hwType, assetName }); setShowConfirmDel(true) }}
onNote={(e) => setNoteTarget(e)}
onDeleteNote={(e) => setNoteDelTarget(e)}
/>
))}
)}
setUploadTarget(null)}
onSaved={() => { setUploadTarget(null); fetchAssets() }}
/>
setNoteTarget(null)}
onSaved={(newNote) => { if (noteTarget) handleNoteUpdate(noteTarget.hw_type, newNote); setNoteTarget(null) }}
/>
handleDeleteNote(noteDelTarget)}
onCancel={() => setNoteDelTarget(null)}
/>
{ setShowConfirmDel(false); setDeleteTarget(null) }}
/>
)
if (embedded) return content
return (
)
}