Files
bellsystems-cp/frontend/src/pages/engineering/manufacturing/FlashAssetManager.jsx
T

459 lines
19 KiB
React

// 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>
)
}