feat: Phase 6, Device provisioning and deployment of updates on git-pull

This commit is contained in:
2026-02-27 04:42:41 +02:00
parent 32a2634739
commit 57259c2c2f
19 changed files with 1670 additions and 26 deletions

View File

@@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"esptool-js": "^0.5.7",
"leaflet": "^1.9.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -1814,6 +1815,12 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/atob-lite": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz",
"integrity": "sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2252,6 +2259,17 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esptool-js": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.5.7.tgz",
"integrity": "sha512-k3pkXU9OTySCd58OUDjuJWNnFjM+QpPWAghxyWPm3zNfaLiP4ex2jNd7Rj0jWPu3/fgvwau236tetsTZrh4x5g==",
"license": "Apache-2.0",
"dependencies": {
"atob-lite": "^2.0.0",
"pako": "^2.1.0",
"tslib": "^2.4.1"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -3054,6 +3072,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3413,6 +3437,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"esptool-js": "^0.5.7",
"leaflet": "^1.9.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",

View File

@@ -27,7 +27,9 @@ import StaffForm from "./settings/StaffForm";
import DeviceInventory from "./manufacturing/DeviceInventory";
import BatchCreator from "./manufacturing/BatchCreator";
import DeviceInventoryDetail from "./manufacturing/DeviceInventoryDetail";
import ProvisioningWizard from "./manufacturing/ProvisioningWizard";
import FirmwareManager from "./firmware/FirmwareManager";
import DashboardPage from "./dashboard/DashboardPage";
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
@@ -93,18 +95,6 @@ function RoleGate({ roles, children }) {
return children;
}
function DashboardPage() {
const { user } = useAuth();
return (
<div>
<h1 className="text-2xl font-bold mb-4" style={{ color: "var(--text-heading)" }}>Dashboard</h1>
<p style={{ color: "var(--text-secondary)" }}>
Welcome, {user?.name}. You are logged in as{" "}
<span className="font-medium" style={{ color: "var(--accent)" }}>{user?.role}</span>.
</p>
</div>
);
}
export default function App() {
return (
@@ -156,6 +146,7 @@ export default function App() {
{/* Manufacturing */}
<Route path="manufacturing" element={<PermissionGate section="manufacturing"><DeviceInventory /></PermissionGate>} />
<Route path="manufacturing/batch/new" element={<PermissionGate section="manufacturing" action="add"><BatchCreator /></PermissionGate>} />
<Route path="manufacturing/provision" element={<PermissionGate section="manufacturing" action="edit"><ProvisioningWizard /></PermissionGate>} />
<Route path="manufacturing/devices/:sn" element={<PermissionGate section="manufacturing"><DeviceInventoryDetail /></PermissionGate>} />
<Route path="firmware" element={<PermissionGate section="manufacturing"><FirmwareManager /></PermissionGate>} />

View File

@@ -0,0 +1,266 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
const STATUS_STYLES = {
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
provisioned: { bg: "#0a2e2a", color: "#4dd6c8" },
sold: { bg: "#1e1036", color: "#c084fc" },
claimed: { bg: "#2e1a00", color: "#fb923c" },
decommissioned: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
const STATUS_ORDER = ["manufactured", "flashed", "provisioned", "sold", "claimed", "decommissioned"];
const ACTION_LABELS = {
batch_created: "Batch created",
device_flashed: "NVS downloaded",
device_assigned: "Device assigned",
status_updated: "Status updated",
};
function StatusBadge({ status }) {
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
return (
<span
className="px-2 py-0.5 text-xs rounded-full capitalize font-medium"
style={{ backgroundColor: style.bg, color: style.color }}
>
{status}
</span>
);
}
function StatCard({ label, count, status, onClick }) {
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
return (
<button
onClick={onClick}
className="rounded-lg border p-4 text-left transition-colors cursor-pointer w-full"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card)")}
>
<div
className="text-3xl font-bold mb-1"
style={{ color: style.color }}
>
{count}
</div>
<div className="text-xs capitalize font-medium" style={{ color: "var(--text-muted)" }}>
{label}
</div>
</button>
);
}
export default function DashboardPage() {
const { user, hasPermission } = useAuth();
const navigate = useNavigate();
const canViewMfg = hasPermission("manufacturing", "view");
const [stats, setStats] = useState(null);
const [auditLog, setAuditLog] = useState([]);
const [loadingStats, setLoadingStats] = useState(false);
const [loadingAudit, setLoadingAudit] = useState(false);
useEffect(() => {
if (!canViewMfg) return;
setLoadingStats(true);
api.get("/manufacturing/stats")
.then(setStats)
.catch(() => {})
.finally(() => setLoadingStats(false));
setLoadingAudit(true);
api.get("/manufacturing/audit-log?limit=20")
.then((data) => setAuditLog(data.entries || []))
.catch(() => {})
.finally(() => setLoadingAudit(false));
}, [canViewMfg]);
const formatTs = (ts) => {
if (!ts) return "—";
try {
return new Date(ts).toLocaleString("en-US", {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch {
return ts;
}
};
return (
<div>
<h1 className="text-2xl font-bold mb-1" style={{ color: "var(--text-heading)" }}>
Dashboard
</h1>
<p className="text-sm mb-6" style={{ color: "var(--text-secondary)" }}>
Welcome, {user?.name}.{" "}
<span className="font-medium" style={{ color: "var(--accent)" }}>{user?.role}</span>
</p>
{canViewMfg && (
<>
{/* Device Status Summary */}
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
Device Inventory
</h2>
<button
onClick={() => navigate("/manufacturing")}
className="text-xs underline"
style={{ color: "var(--text-link)" }}
>
View all
</button>
</div>
{loadingStats ? (
<div className="text-sm mb-6" style={{ color: "var(--text-muted)" }}>Loading</div>
) : stats ? (
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3 mb-8">
{STATUS_ORDER.map((s) => (
<StatCard
key={s}
label={s}
count={stats.counts[s] ?? 0}
status={s}
onClick={() => navigate(`/manufacturing?status=${s}`)}
/>
))}
</div>
) : null}
{/* Recent Activity */}
{stats?.recent_activity?.length > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>
Recent Activity
</h2>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Serial Number</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Status</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Owner</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Date</th>
</tr>
</thead>
<tbody>
{stats.recent_activity.map((item, i) => (
<tr
key={i}
className="cursor-pointer"
style={{ borderBottom: "1px solid var(--border-secondary)" }}
onClick={() => navigate(`/manufacturing/devices/${item.serial_number}`)}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")}
>
<td className="px-4 py-2 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
{item.serial_number}
</td>
<td className="px-4 py-2">
<StatusBadge status={item.mfg_status} />
</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
{item.owner || "—"}
</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
{formatTs(item.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Audit Log */}
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>
Audit Log
</h2>
{loadingAudit ? (
<div className="text-sm" style={{ color: "var(--text-muted)" }}>Loading</div>
) : auditLog.length === 0 ? (
<div className="text-sm" style={{ color: "var(--text-muted)" }}>No audit entries yet.</div>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Time</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Admin</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Action</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Device</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Detail</th>
</tr>
</thead>
<tbody>
{auditLog.map((entry) => (
<tr
key={entry.id}
style={{ borderBottom: "1px solid var(--border-secondary)" }}
>
<td className="px-4 py-2 text-xs whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{formatTs(entry.timestamp)}
</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-secondary)" }}>
{entry.admin_user}
</td>
<td className="px-4 py-2 text-xs font-medium" style={{ color: "var(--text-primary)" }}>
{ACTION_LABELS[entry.action] || entry.action}
</td>
<td className="px-4 py-2 font-mono text-xs" style={{ color: "var(--text-muted)" }}>
{entry.serial_number
? (
<button
className="underline"
style={{ color: "var(--text-link)" }}
onClick={() => navigate(`/manufacturing/devices/${entry.serial_number}`)}
>
{entry.serial_number}
</button>
)
: "—"}
</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
{entry.detail
? (() => {
try {
const d = JSON.parse(entry.detail);
return Object.entries(d)
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
} catch {
return entry.detail;
}
})()
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
{!canViewMfg && (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
Select a section from the sidebar to get started.
</p>
)}
</div>
);
}

View File

@@ -32,6 +32,7 @@ const navItems = [
children: [
{ to: "/manufacturing", label: "Device Inventory" },
{ to: "/manufacturing/batch/new", label: "New Batch" },
{ to: "/manufacturing/provision", label: "Provision Device" },
{ to: "/firmware", label: "Firmware" },
],
},

View File

@@ -64,6 +64,12 @@ export default function DeviceInventoryDetail() {
const [nvsDownloading, setNvsDownloading] = useState(false);
const [assignEmail, setAssignEmail] = useState("");
const [assignName, setAssignName] = useState("");
const [assignSaving, setAssignSaving] = useState(false);
const [assignError, setAssignError] = useState("");
const [assignSuccess, setAssignSuccess] = useState(false);
const loadDevice = async () => {
setLoading(true);
setError("");
@@ -99,6 +105,28 @@ export default function DeviceInventoryDetail() {
}
};
const handleAssign = async () => {
setAssignError("");
setAssignSaving(true);
try {
const updated = await api.request(`/manufacturing/devices/${sn}/assign`, {
method: "POST",
body: JSON.stringify({
customer_email: assignEmail,
customer_name: assignName || null,
}),
});
setDevice(updated);
setAssignSuccess(true);
setAssignEmail("");
setAssignName("");
} catch (err) {
setAssignError(err.message);
} finally {
setAssignSaving(false);
}
};
const downloadNvs = async () => {
setNvsDownloading(true);
try {
@@ -303,7 +331,7 @@ export default function DeviceInventoryDetail() {
{/* Actions card */}
<div
className="rounded-lg border p-5"
className="rounded-lg border p-5 mb-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
@@ -326,6 +354,77 @@ export default function DeviceInventoryDetail() {
NVS binary encodes device_uid, hw_type, hw_version. Flash at 0x9000.
</p>
</div>
{/* Assign to Customer card */}
{canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && (
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
Assign to Customer
</h2>
{assignSuccess ? (
<div
className="text-sm rounded-md p-3 border"
style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}
>
Device assigned and invitation email sent to <strong>{device?.owner}</strong>.
</div>
) : (
<div className="space-y-3">
{assignError && (
<div
className="text-xs rounded p-2 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{assignError}
</div>
)}
<input
type="email"
placeholder="Customer email address"
value={assignEmail}
onChange={(e) => setAssignEmail(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
/>
<input
type="text"
placeholder="Customer name (optional)"
value={assignName}
onChange={(e) => setAssignName(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
/>
<button
onClick={handleAssign}
disabled={assignSaving || !assignEmail.trim()}
className="px-4 py-1.5 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{assignSaving ? "Sending…" : "Assign & Send Invite"}
</button>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
Sets device status to <em>sold</em> and emails the customer their serial number.
</p>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,868 @@
import { useState, useRef, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { ESPLoader, Transport } from "esptool-js";
import api from "../api/client";
// ─── constants ───────────────────────────────────────────────────────────────
const BOARD_TYPES = [
{ value: "vs", label: "Vesper (VS)" },
{ value: "vp", label: "Vesper+ (VP)" },
{ value: "vx", label: "VesperPro (VX)" },
];
const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" };
const STATUS_STYLES = {
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
provisioned: { bg: "#0a2e2a", color: "#4dd6c8" },
sold: { bg: "#1e1036", color: "#c084fc" },
claimed: { bg: "#2e1a00", color: "#fb923c" },
decommissioned: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
const FLASH_BAUD = 460800;
const NVS_ADDRESS = 0x9000;
const FW_ADDRESS = 0x10000;
const VERIFY_POLL_MS = 5000;
const VERIFY_TIMEOUT_MS = 120_000;
// ─── small helpers ────────────────────────────────────────────────────────────
function StatusBadge({ status }) {
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
return (
<span
className="px-2.5 py-1 text-sm rounded-full capitalize font-medium"
style={{ backgroundColor: style.bg, color: style.color }}
>
{status}
</span>
);
}
function StepIndicator({ current }) {
const steps = ["Select Device", "Flash", "Verify", "Done"];
return (
<div className="flex items-center gap-0 mb-8">
{steps.map((label, i) => {
const idx = i + 1;
const done = idx < current;
const active = idx === current;
const pending = idx > current;
return (
<div key={label} className="flex items-center">
<div className="flex flex-col items-center">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors"
style={{
backgroundColor: done
? "var(--accent)"
: active
? "var(--accent)"
: "var(--bg-card-hover)",
color: done || active ? "var(--bg-primary)" : "var(--text-muted)",
opacity: pending ? 0.5 : 1,
}}
>
{done ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : idx}
</div>
<span
className="text-xs mt-1 whitespace-nowrap"
style={{ color: active ? "var(--text-primary)" : "var(--text-muted)", opacity: pending ? 0.5 : 1 }}
>
{label}
</span>
</div>
{i < steps.length - 1 && (
<div
className="h-px w-12 mx-1 mb-5 flex-shrink-0"
style={{ backgroundColor: done ? "var(--accent)" : "var(--border-primary)" }}
/>
)}
</div>
);
})}
</div>
);
}
function ProgressBar({ label, percent }) {
return (
<div className="space-y-1.5">
<div className="flex justify-between text-xs" style={{ color: "var(--text-secondary)" }}>
<span>{label}</span>
<span>{Math.round(percent)}%</span>
</div>
<div
className="h-2 rounded-full overflow-hidden"
style={{ backgroundColor: "var(--bg-card-hover)" }}
>
<div
className="h-full rounded-full transition-all duration-200"
style={{ width: `${percent}%`, backgroundColor: "var(--accent)" }}
/>
</div>
</div>
);
}
function ErrorBox({ msg }) {
if (!msg) return null;
return (
<div
className="text-sm rounded-md p-3 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{msg}
</div>
);
}
function inputCls() {
return "w-full px-3 py-2 rounded-md text-sm border";
}
function inputStyle() {
return {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
};
}
// ─── Step 1 — Select or create device ─────────────────────────────────────────
function StepSelectDevice({ onSelected }) {
const [mode, setMode] = useState("search"); // "search" | "create"
const [searchSn, setSearchSn] = useState("");
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState("");
const [found, setFound] = useState(null);
// Create-device fields
const [boardType, setBoardType] = useState("vs");
const [boardVersion, setBoardVersion] = useState("01");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
const handleSearch = async (e) => {
e.preventDefault();
setSearchError("");
setFound(null);
setSearching(true);
try {
const data = await api.get(`/manufacturing/devices/${searchSn.trim().toUpperCase()}`);
if (data.mfg_status === "flashed" || data.mfg_status === "provisioned") {
setSearchError(
`Device is already ${data.mfg_status}. Only unprovisioned devices can be re-flashed here.`
);
} else {
setFound(data);
}
} catch (err) {
setSearchError(err.message);
} finally {
setSearching(false);
}
};
const handleCreate = async (e) => {
e.preventDefault();
setCreateError("");
setCreating(true);
try {
const batch = await api.post("/manufacturing/batch", {
board_type: boardType,
board_version: boardVersion,
quantity: 1,
});
const sn = batch.serial_numbers[0];
const device = await api.get(`/manufacturing/devices/${sn}`);
setFound(device);
} catch (err) {
setCreateError(err.message);
} finally {
setCreating(false);
}
};
if (found) {
return (
<div className="space-y-4">
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
Device Selected
</h3>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Serial Number</p>
<p className="text-sm font-mono" style={{ color: "var(--text-primary)" }}>{found.serial_number}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Board Type</p>
<p className="text-sm" style={{ color: "var(--text-primary)" }}>{BOARD_TYPE_LABELS[found.hw_type] || found.hw_type}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>HW Version</p>
<p className="text-sm" style={{ color: "var(--text-primary)" }}>v{found.hw_version}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Status</p>
<StatusBadge status={found.mfg_status} />
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => onSelected(found)}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Continue
</button>
<button
onClick={() => setFound(null)}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Change Device
</button>
</div>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)" }}>
{[["search", "Search Existing"], ["create", "Quick Create"]].map(([val, lbl]) => (
<button
key={val}
onClick={() => { setMode(val); setSearchError(""); setCreateError(""); }}
className="flex-1 py-2 text-sm font-medium transition-colors cursor-pointer"
style={{
backgroundColor: mode === val ? "var(--accent)" : "var(--bg-card-hover)",
color: mode === val ? "var(--bg-primary)" : "var(--text-secondary)",
}}
>
{lbl}
</button>
))}
</div>
{/* Search */}
{mode === "search" && (
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-sm font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Find Unprovisioned Device
</h3>
<ErrorBox msg={searchError} />
{searchError && <div className="h-3" />}
<form onSubmit={handleSearch} className="space-y-3">
<input
type="text"
placeholder="e.g. PV-26B27-VS01R-X7KQA"
value={searchSn}
onChange={(e) => setSearchSn(e.target.value)}
required
className={inputCls()}
style={inputStyle()}
/>
<button
type="submit"
disabled={searching}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{searching ? "Searching…" : "Search"}
</button>
</form>
</div>
)}
{/* Create */}
{mode === "create" && (
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-sm font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Create Single Device
</h3>
<ErrorBox msg={createError} />
{createError && <div className="h-3" />}
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
Board Type
</label>
<select
value={boardType}
onChange={(e) => setBoardType(e.target.value)}
className={inputCls()}
style={inputStyle()}
>
{BOARD_TYPES.map((bt) => (
<option key={bt.value} value={bt.value}>{bt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
Board Version
</label>
<input
type="text"
value={boardVersion}
onChange={(e) => setBoardVersion(e.target.value)}
placeholder="01"
maxLength={2}
pattern="\d{2}"
required
className={inputCls()}
style={inputStyle()}
/>
</div>
<button
type="submit"
disabled={creating}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{creating ? "Creating…" : "Create & Continue"}
</button>
</form>
</div>
)}
</div>
);
}
// ─── Step 2 — Flash ────────────────────────────────────────────────────────────
function StepFlash({ device, onFlashed }) {
const [connecting, setConnecting] = useState(false);
const [flashing, setFlashing] = useState(false);
const [nvsProgress, setNvsProgress] = useState(0);
const [fwProgress, setFwProgress] = useState(0);
const [log, setLog] = useState([]);
const [error, setError] = useState("");
const loaderRef = useRef(null);
const appendLog = (msg) => setLog((prev) => [...prev, msg]);
const fetchBinary = async (url) => {
const token = localStorage.getItem("access_token");
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `Failed to fetch ${url}: ${resp.status}`);
}
return resp.arrayBuffer();
};
// esptool-js wants binary data as a plain string of char codes
const arrayBufferToString = (buf) => {
const bytes = new Uint8Array(buf);
let str = "";
for (let i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i]);
}
return str;
};
const handleFlash = async () => {
setError("");
setLog([]);
setNvsProgress(0);
setFwProgress(0);
// 1. Open Web Serial port
let port;
try {
setConnecting(true);
appendLog("Opening port picker…");
port = await navigator.serial.requestPort();
} catch (err) {
setError(err.message || "Port selection cancelled.");
setConnecting(false);
return;
}
try {
// 2. Fetch binaries from backend
appendLog("Fetching NVS binary…");
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/nvs.bin`);
appendLog(`NVS binary: ${nvsBuffer.byteLength} bytes`);
appendLog("Fetching firmware binary…");
const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/firmware.bin`);
appendLog(`Firmware binary: ${fwBuffer.byteLength} bytes`);
// 3. Connect ESPLoader
setConnecting(false);
setFlashing(true);
appendLog("Connecting to ESP32…");
const transport = new Transport(port, true);
loaderRef.current = new ESPLoader({
transport,
baudrate: FLASH_BAUD,
terminal: {
clean() {},
writeLine: (line) => appendLog(line),
write: (msg) => appendLog(msg),
},
});
await loaderRef.current.main();
appendLog("ESP32 connected.");
// 4. Flash NVS + firmware with progress callbacks
const nvsData = arrayBufferToString(nvsBuffer);
const fwData = arrayBufferToString(fwBuffer);
// Track progress by watching the two images in sequence.
// esptool-js reports progress as { index, fileIndex, written, total }
const totalBytes = nvsBuffer.byteLength + fwBuffer.byteLength;
let writtenSoFar = 0;
await loaderRef.current.writeFlash({
fileArray: [
{ data: nvsData, address: NVS_ADDRESS },
{ data: fwData, address: FW_ADDRESS },
],
flashSize: "keep",
flashMode: "keep",
flashFreq: "keep",
eraseAll: false,
compress: true,
reportProgress(fileIndex, written, total) {
if (fileIndex === 0) {
setNvsProgress((written / total) * 100);
} else {
setNvsProgress(100);
setFwProgress((written / total) * 100);
}
writtenSoFar = written;
},
calculateMD5Hash: (image) => {
// MD5 is optional for progress verification; returning empty disables it
return "";
},
});
setNvsProgress(100);
setFwProgress(100);
appendLog("Flash complete. Disconnecting…");
await transport.disconnect();
appendLog("Done.");
// 5. Update device status → flashed
await api.request(`/manufacturing/devices/${device.serial_number}/status`, {
method: "PATCH",
body: JSON.stringify({ status: "flashed", note: "Flashed via browser provisioning wizard" }),
});
onFlashed();
} catch (err) {
setError(err.message || String(err));
setFlashing(false);
setConnecting(false);
try {
if (loaderRef.current) await loaderRef.current.transport?.disconnect();
} catch (_) {}
}
};
const webSerialAvailable = "serial" in navigator;
return (
<div className="space-y-4">
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
Device to Flash
</h3>
<div className="grid grid-cols-2 gap-3 mb-5">
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Serial Number</p>
<p className="text-sm font-mono" style={{ color: "var(--text-primary)" }}>{device.serial_number}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Board</p>
<p className="text-sm" style={{ color: "var(--text-primary)" }}>
{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} v{device.hw_version}
</p>
</div>
</div>
{!webSerialAvailable && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "#2e1a00", borderColor: "#fb923c", color: "#fb923c" }}
>
Web Serial API not available. Use Chrome or Edge on a desktop system.
</div>
)}
<ErrorBox msg={error} />
{error && <div className="h-3" />}
{(flashing || nvsProgress > 0) && (
<div className="space-y-3 mb-5">
<ProgressBar label="NVS Partition (0x9000)" percent={nvsProgress} />
<ProgressBar label="Firmware (0x10000)" percent={fwProgress} />
</div>
)}
{log.length > 0 && (
<div
className="rounded-md border p-3 mb-4 font-mono text-xs overflow-y-auto max-h-36 space-y-0.5"
style={{
backgroundColor: "var(--bg-primary)",
borderColor: "var(--border-secondary)",
color: "var(--text-muted)",
}}
>
{log.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
)}
{!flashing && (
<button
onClick={handleFlash}
disabled={!webSerialAvailable || connecting}
className="flex items-center gap-2 px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{connecting ? "Connecting…" : "Connect & Flash Device"}
</button>
)}
{flashing && nvsProgress < 100 && fwProgress < 100 && (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
Flashing in progress do not disconnect
</p>
)}
</div>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
Flash addresses: NVS at 0x9000 · Firmware at 0x10000 · Baud: {FLASH_BAUD}
</p>
</div>
);
}
// ─── Step 3 — Verify ──────────────────────────────────────────────────────────
function StepVerify({ device, onVerified }) {
const [polling, setPolling] = useState(false);
const [timedOut, setTimedOut] = useState(false);
const [error, setError] = useState("");
const intervalRef = useRef(null);
const timeoutRef = useRef(null);
const startPolling = useCallback(() => {
if (polling) return;
setPolling(true);
setTimedOut(false);
setError("");
const startTime = Date.now();
intervalRef.current = setInterval(async () => {
try {
const data = await api.get(`/manufacturing/devices/${device.serial_number}`);
if (data.mfg_status === "provisioned") {
clearInterval(intervalRef.current);
clearTimeout(timeoutRef.current);
onVerified(data);
return;
}
// Also accept any last_seen update (heartbeat) as evidence of life
if (data.last_seen) {
const ts = new Date(data.last_seen).getTime();
if (ts > startTime) {
clearInterval(intervalRef.current);
clearTimeout(timeoutRef.current);
// Promote to provisioned
try {
await api.request(`/manufacturing/devices/${device.serial_number}/status`, {
method: "PATCH",
body: JSON.stringify({ status: "provisioned", note: "Auto-verified via wizard" }),
});
} catch (_) {}
onVerified({ ...data, mfg_status: "provisioned" });
return;
}
}
} catch (err) {
// Non-fatal; keep polling
setError(err.message);
}
}, VERIFY_POLL_MS);
timeoutRef.current = setTimeout(() => {
clearInterval(intervalRef.current);
setPolling(false);
setTimedOut(true);
}, VERIFY_TIMEOUT_MS);
}, [polling, device.serial_number, onVerified]);
const stopPolling = () => {
clearInterval(intervalRef.current);
clearTimeout(timeoutRef.current);
setPolling(false);
};
return (
<div className="space-y-4">
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
Waiting for Device
</h3>
<div className="flex flex-col items-center py-6 gap-5">
{polling && (
<>
<svg
className="w-12 h-12 animate-spin"
style={{ color: "var(--accent)" }}
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<p className="text-sm text-center" style={{ color: "var(--text-secondary)" }}>
Waiting for device to connect
<br />
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Power cycle the device and ensure it can reach the MQTT broker.
</span>
</p>
</>
)}
{!polling && !timedOut && (
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
Power cycle the device, then click Start Verification.
</p>
)}
{timedOut && (
<div
className="text-sm rounded-md p-3 border text-center"
style={{ backgroundColor: "#2e1a00", borderColor: "#fb923c", color: "#fb923c" }}
>
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
</div>
)}
</div>
{error && !timedOut && (
<ErrorBox msg={`Poll error (will retry): ${error}`} />
)}
<div className="flex gap-3 mt-2">
{!polling && (
<button
onClick={startPolling}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{timedOut ? "Retry Verification" : "Start Verification"}
</button>
)}
{polling && (
<button
onClick={stopPolling}
className="px-5 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Stop
</button>
)}
</div>
</div>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
</p>
</div>
);
}
// ─── Step 4 — Done ────────────────────────────────────────────────────────────
function StepDone({ device, startedAt, onProvisionNext }) {
const navigate = useNavigate();
const elapsed = startedAt ? Math.round((Date.now() - startedAt) / 1000) : null;
const formatElapsed = (sec) => {
if (sec < 60) return `${sec}s`;
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
};
return (
<div className="space-y-4">
<div
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex flex-col items-center py-4 gap-4">
<div
className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ backgroundColor: "#0a2e2a" }}
>
<svg className="w-8 h-8" style={{ color: "#4dd6c8" }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="text-center">
<h3 className="text-lg font-bold" style={{ color: "var(--text-heading)" }}>Device Provisioned</h3>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
{device.serial_number} is live.
</p>
</div>
</div>
<div
className="grid grid-cols-2 gap-4 rounded-md p-4 mb-5"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Serial Number</p>
<p className="text-sm font-mono" style={{ color: "var(--text-primary)" }}>{device.serial_number}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Board Type</p>
<p className="text-sm" style={{ color: "var(--text-primary)" }}>
{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} v{device.hw_version}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Status</p>
<StatusBadge status={device.mfg_status} />
</div>
{elapsed !== null && (
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Time Taken</p>
<p className="text-sm" style={{ color: "var(--text-primary)" }}>{formatElapsed(elapsed)}</p>
</div>
)}
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={onProvisionNext}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Provision Next Device
</button>
<button
onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
View in Inventory
</button>
</div>
</div>
</div>
);
}
// ─── Main Wizard ──────────────────────────────────────────────────────────────
export default function ProvisioningWizard() {
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [device, setDevice] = useState(null);
const [startedAt, setStartedAt] = useState(null);
const handleDeviceSelected = (dev) => {
setDevice(dev);
setStartedAt(Date.now());
setStep(2);
};
const handleFlashed = () => {
setStep(3);
};
const handleVerified = (updatedDevice) => {
setDevice(updatedDevice);
setStep(4);
};
const handleProvisionNext = () => {
setDevice(null);
setStartedAt(null);
setStep(1);
};
return (
<div className="max-w-2xl">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => navigate("/manufacturing")}
className="text-sm hover:opacity-80 transition-opacity cursor-pointer"
style={{ color: "var(--text-muted)" }}
>
Device Inventory
</button>
<span style={{ color: "var(--text-muted)" }}>/</span>
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>
Provisioning Wizard
</h1>
</div>
<StepIndicator current={step} />
{step === 1 && <StepSelectDevice onSelected={handleDeviceSelected} />}
{step === 2 && device && (
<StepFlash device={device} onFlashed={handleFlashed} />
)}
{step === 3 && device && (
<StepVerify device={device} onVerified={handleVerified} />
)}
{step === 4 && device && (
<StepDone
device={device}
startedAt={startedAt}
onProvisionNext={handleProvisionNext}
/>
)}
</div>
);
}