Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
// 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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Asset Note"
|
||||
size="sm"
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleSave} loading={saving}>Save Note</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
Board: <span style={{ fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-secondary)' }}>{hwType}</span>
|
||||
{' '}— stored alongside binaries as{' '}
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-secondary)' }}>note.txt</span>
|
||||
</p>
|
||||
{error && (
|
||||
<div style={{ padding: 'var(--space-2)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<FormField
|
||||
label="Note"
|
||||
name="asset_note"
|
||||
type="textarea"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="e.g. Built from PlatformIO env:vesper_plus_v2, commit abc1234…"
|
||||
hint="Leave blank to clear the note."
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Asset Pill ───────────────────────────────────────────────────────────────
|
||||
|
||||
function AssetPill({ label, info, canDelete, onUpload, onDelete }) {
|
||||
const exists = info?.exists
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: `1px solid ${exists ? 'var(--color-border)' : 'var(--color-danger)'}`,
|
||||
backgroundColor: exists ? 'var(--color-bg-elevated)' : 'var(--color-danger-bg)',
|
||||
}}>
|
||||
<Icon
|
||||
name={exists ? 'check' : 'warning'}
|
||||
size={14}
|
||||
color={exists ? 'var(--color-success)' : 'var(--color-danger)'}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: exists ? 'var(--color-text-secondary)' : 'var(--color-danger)', margin: 0 }}>
|
||||
{label}
|
||||
</p>
|
||||
{exists ? (
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', margin: 0 }}>
|
||||
{formatBytes(info.size_bytes)}{info.uploaded_at ? ` · ${formatDate(info.uploaded_at)}` : ''}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-danger)', margin: 0, opacity: 0.8 }}>Not uploaded</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-1)', flexShrink: 0 }}>
|
||||
<Button variant="ghost" size="sm" onClick={onUpload} aria-label={exists ? 'Re-upload' : 'Upload'}>
|
||||
<Icon name="upload" size={12} />
|
||||
</Button>
|
||||
{exists && canDelete && (
|
||||
<Button variant="danger" size="sm" onClick={onDelete} aria-label="Delete">
|
||||
<Icon name="delete" size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div style={{
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderLeft: `3px solid ${accent}`,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: 'var(--space-3) var(--space-4)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-2)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', minWidth: 0 }}>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-display)', letterSpacing: 'var(--tracking-tight)' }}>
|
||||
{label}
|
||||
</span>
|
||||
{is_bespoke && <StatusBadge variant="info">bespoke</StatusBadge>}
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
{hw_type}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexShrink: 0 }}>
|
||||
<StatusBadge variant={readyVariant}>{readyLabel}</StatusBadge>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onNote(entry)}>
|
||||
<Icon name="note" size={12} />
|
||||
{note ? 'Edit note' : 'Add note'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset pills */}
|
||||
<div style={{ padding: 'var(--space-3) var(--space-4)', display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
<AssetPill label="bootloader.bin" info={bootloader} canDelete={canDelete}
|
||||
onUpload={() => onUpload(hw_type, 'bootloader.bin')}
|
||||
onDelete={() => onDelete(hw_type, 'bootloader.bin')} />
|
||||
<AssetPill label="partitions.bin" info={partitions} canDelete={canDelete}
|
||||
onUpload={() => onUpload(hw_type, 'partitions.bin')}
|
||||
onDelete={() => onDelete(hw_type, 'partitions.bin')} />
|
||||
</div>
|
||||
|
||||
{/* Note — shown below the files, hover-X to delete */}
|
||||
{note && (
|
||||
<div
|
||||
onMouseEnter={() => 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)',
|
||||
}}
|
||||
>
|
||||
<p style={{ flex: 1, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontStyle: 'italic', lineHeight: 1.5, margin: 0 }}>
|
||||
{note}
|
||||
</p>
|
||||
{canEdit && noteHovered && (
|
||||
<button
|
||||
aria-label="Delete note"
|
||||
onClick={() => onDeleteNote(entry)}
|
||||
style={{
|
||||
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--color-text-muted)', padding: 'var(--space-1)',
|
||||
borderRadius: 'var(--radius-sm)', lineHeight: 1,
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--color-danger)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--color-text-muted)' }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="2" y1="2" x2="10" y2="10"/><line x1="10" y1="2" x2="2" y2="10"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={label} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
}}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-semibold)' }}>
|
||||
{label}
|
||||
</span>
|
||||
<StatusBadge variant={variant}>{value}</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={fetchAssets}>
|
||||
<Icon name="refresh" size={14} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||||
{FILTER_OPTIONS.map(({ value, label }) => (
|
||||
<Button key={value} variant={filter === value ? 'secondary' : 'ghost'} size="sm" onClick={() => setFilter(value)}>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div style={{ padding: 'var(--space-12)', textAlign: 'center' }}>
|
||||
<Icon name="box" size={40} color="var(--color-text-muted)" />
|
||||
<p style={{ marginTop: 'var(--space-3)', fontSize: 'var(--font-size-md)', color: 'var(--color-text-secondary)' }}>
|
||||
No assets match the current filter.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 'var(--space-4)' }}>
|
||||
{filteredAssets.map((entry) => (
|
||||
<BoardCard
|
||||
key={entry.hw_type}
|
||||
entry={entry}
|
||||
canDelete={canDelete}
|
||||
canEdit={canEdit}
|
||||
onUpload={(hwType, assetName) => setUploadTarget({ hwType, assetName })}
|
||||
onDelete={(hwType, assetName) => { setDeleteTarget({ hwType, assetName }); setShowConfirmDel(true) }}
|
||||
onNote={(e) => setNoteTarget(e)}
|
||||
onDeleteNote={(e) => setNoteDelTarget(e)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UploadAssetModal
|
||||
open={!!uploadTarget}
|
||||
hwType={uploadTarget?.hwType}
|
||||
assetName={uploadTarget?.assetName}
|
||||
onClose={() => setUploadTarget(null)}
|
||||
onSaved={() => { setUploadTarget(null); fetchAssets() }}
|
||||
/>
|
||||
<NoteModal
|
||||
open={!!noteTarget}
|
||||
hwType={noteTarget?.hw_type}
|
||||
currentNote={noteTarget?.note}
|
||||
onClose={() => setNoteTarget(null)}
|
||||
onSaved={(newNote) => { if (noteTarget) handleNoteUpdate(noteTarget.hw_type, newNote); setNoteTarget(null) }}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!noteDelTarget}
|
||||
variant="danger"
|
||||
title="Delete note?"
|
||||
message={noteDelTarget ? `Remove the note for ${boardLabel(noteDelTarget.hw_type, noteDelTarget.is_bespoke)}?` : ''}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDeleteNote(noteDelTarget)}
|
||||
onCancel={() => setNoteDelTarget(null)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={showConfirmDel}
|
||||
variant="danger"
|
||||
title="Delete flash asset?"
|
||||
message={deleteTarget ? `Delete ${deleteTarget.assetName} for ${deleteTarget.hwType}? This cannot be undone. Flashing will fail until the file is re-uploaded.` : ''}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => { setShowConfirmDel(false); setDeleteTarget(null) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (embedded) return content
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader title="Flash Asset Manager" subtitle="Manage bootloader and partition binaries for each board type" />
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user