feat: Phase 6, Device provisioning and deployment of updates on git-pull
This commit is contained in:
0
backend/admin/__init__.py
Normal file
0
backend/admin/__init__.py
Normal file
64
backend/admin/router.py
Normal file
64
backend/admin/router.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger("admin.deploy")
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.post("/deploy")
|
||||
async def deploy(request: Request):
|
||||
"""Gitea webhook endpoint — pulls latest code and rebuilds Docker containers.
|
||||
|
||||
Gitea webhook configuration:
|
||||
URL: https://<your-domain>/api/admin/deploy
|
||||
Secret token: value of DEPLOY_SECRET env var
|
||||
Content-Type: application/json
|
||||
Trigger: Push events only (branch: main)
|
||||
|
||||
Add to VPS .env:
|
||||
DEPLOY_SECRET=<random-strong-token>
|
||||
DEPLOY_PROJECT_PATH=/home/bellsystems/bellsystems-cp
|
||||
"""
|
||||
if not settings.deploy_secret:
|
||||
raise HTTPException(status_code=503, detail="Deploy secret not configured on server")
|
||||
|
||||
# Gitea sends the HMAC-SHA256 of the request body in X-Gitea-Signature
|
||||
sig_header = request.headers.get("X-Gitea-Signature", "")
|
||||
body = await request.body()
|
||||
expected_sig = hmac.new(
|
||||
key=settings.deploy_secret.encode(),
|
||||
msg=body,
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(sig_header, expected_sig):
|
||||
raise HTTPException(status_code=403, detail="Invalid webhook signature")
|
||||
|
||||
logger.info("Auto-deploy triggered via Gitea webhook")
|
||||
|
||||
project_path = settings.deploy_project_path
|
||||
cmd = f"cd {project_path} && git pull origin main && docker compose up -d --build"
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=300)
|
||||
output = stdout.decode(errors="replace") if stdout else ""
|
||||
|
||||
if proc.returncode != 0:
|
||||
logger.error(f"Deploy failed (exit {proc.returncode}):\n{output}")
|
||||
raise HTTPException(status_code=500, detail=f"Deploy script failed:\n{output[-500:]}")
|
||||
|
||||
logger.info(f"Deploy succeeded:\n{output[-300:]}")
|
||||
return {"ok": True, "output": output[-1000:]}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="Deploy timed out after 300 seconds")
|
||||
@@ -29,10 +29,18 @@ class Settings(BaseSettings):
|
||||
built_melodies_storage_path: str = "./storage/built_melodies"
|
||||
firmware_storage_path: str = "./storage/firmware"
|
||||
|
||||
# Email (Resend)
|
||||
resend_api_key: str = "re_placeholder_change_me"
|
||||
email_from: str = "noreply@yourdomain.com"
|
||||
|
||||
# App
|
||||
backend_cors_origins: str = '["http://localhost:5173"]'
|
||||
debug: bool = True
|
||||
|
||||
# Auto-deploy (Gitea webhook)
|
||||
deploy_secret: str = ""
|
||||
deploy_project_path: str = "/app"
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> List[str]:
|
||||
return json.loads(self.backend_cors_origins)
|
||||
|
||||
@@ -16,6 +16,7 @@ from helpdesk.router import router as helpdesk_router
|
||||
from builder.router import router as builder_router
|
||||
from manufacturing.router import router as manufacturing_router
|
||||
from firmware.router import router as firmware_router
|
||||
from admin.router import router as admin_router
|
||||
from mqtt.client import mqtt_manager
|
||||
from mqtt import database as mqtt_db
|
||||
from melodies import service as melody_service
|
||||
@@ -48,6 +49,7 @@ app.include_router(staff_router)
|
||||
app.include_router(builder_router)
|
||||
app.include_router(manufacturing_router)
|
||||
app.include_router(firmware_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
|
||||
41
backend/manufacturing/audit.py
Normal file
41
backend/manufacturing/audit.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
import logging
|
||||
from mqtt.database import get_db
|
||||
|
||||
logger = logging.getLogger("manufacturing.audit")
|
||||
|
||||
|
||||
async def log_action(
|
||||
admin_user: str,
|
||||
action: str,
|
||||
serial_number: str | None = None,
|
||||
detail: dict | None = None,
|
||||
):
|
||||
"""Write a manufacturing audit entry to SQLite.
|
||||
|
||||
action examples: batch_created, device_flashed, device_assigned, status_updated
|
||||
"""
|
||||
try:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO mfg_audit_log (admin_user, action, serial_number, detail)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(
|
||||
admin_user,
|
||||
action,
|
||||
serial_number,
|
||||
json.dumps(detail) if detail else None,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write audit log: {e}")
|
||||
|
||||
|
||||
async def get_recent(limit: int = 20) -> list[dict]:
|
||||
db = await get_db()
|
||||
rows = await db.execute_fetchall(
|
||||
"SELECT * FROM mfg_audit_log ORDER BY timestamp DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
@@ -59,3 +59,21 @@ class DeviceInventoryListResponse(BaseModel):
|
||||
class DeviceStatusUpdate(BaseModel):
|
||||
status: MfgStatus
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class DeviceAssign(BaseModel):
|
||||
customer_email: str
|
||||
customer_name: Optional[str] = None
|
||||
|
||||
|
||||
class RecentActivityItem(BaseModel):
|
||||
serial_number: str
|
||||
hw_type: str
|
||||
mfg_status: str
|
||||
owner: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class ManufacturingStats(BaseModel):
|
||||
counts: dict
|
||||
recent_activity: List[RecentActivityItem]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from typing import Optional
|
||||
|
||||
from auth.models import TokenPayload
|
||||
@@ -7,19 +8,48 @@ from auth.dependencies import require_permission
|
||||
from manufacturing.models import (
|
||||
BatchCreate, BatchResponse,
|
||||
DeviceInventoryItem, DeviceInventoryListResponse,
|
||||
DeviceStatusUpdate,
|
||||
DeviceStatusUpdate, DeviceAssign,
|
||||
ManufacturingStats,
|
||||
)
|
||||
from manufacturing import service
|
||||
from manufacturing import audit
|
||||
|
||||
router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"])
|
||||
|
||||
|
||||
@router.post("/batch", response_model=BatchResponse, status_code=201)
|
||||
def create_batch(
|
||||
body: BatchCreate,
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
@router.get("/stats", response_model=ManufacturingStats)
|
||||
def get_stats(
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
):
|
||||
return service.create_batch(body)
|
||||
return service.get_stats()
|
||||
|
||||
|
||||
@router.get("/audit-log")
|
||||
async def get_audit_log(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
):
|
||||
entries = await audit.get_recent(limit=limit)
|
||||
return {"entries": entries}
|
||||
|
||||
|
||||
@router.post("/batch", response_model=BatchResponse, status_code=201)
|
||||
async def create_batch(
|
||||
body: BatchCreate,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
):
|
||||
result = service.create_batch(body)
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="batch_created",
|
||||
detail={
|
||||
"batch_id": result.batch_id,
|
||||
"board_type": result.board_type,
|
||||
"board_version": result.board_version,
|
||||
"quantity": len(result.serial_numbers),
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/devices", response_model=DeviceInventoryListResponse)
|
||||
@@ -50,22 +80,62 @@ def get_device(
|
||||
|
||||
|
||||
@router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem)
|
||||
def update_status(
|
||||
async def update_status(
|
||||
sn: str,
|
||||
body: DeviceStatusUpdate,
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
):
|
||||
return service.update_device_status(sn, body)
|
||||
result = service.update_device_status(sn, body)
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="status_updated",
|
||||
serial_number=sn,
|
||||
detail={"status": body.status.value, "note": body.note},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/devices/{sn}/nvs.bin")
|
||||
def download_nvs(
|
||||
async def download_nvs(
|
||||
sn: str,
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
):
|
||||
binary = service.get_nvs_binary(sn)
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="device_flashed",
|
||||
serial_number=sn,
|
||||
)
|
||||
return Response(
|
||||
content=binary,
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/devices/{sn}/assign", response_model=DeviceInventoryItem)
|
||||
async def assign_device(
|
||||
sn: str,
|
||||
body: DeviceAssign,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
):
|
||||
result = service.assign_device(sn, body)
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="device_assigned",
|
||||
serial_number=sn,
|
||||
detail={"customer_email": body.customer_email, "customer_name": body.customer_name},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/devices/{sn}/firmware.bin")
|
||||
def redirect_firmware(
|
||||
sn: str,
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
):
|
||||
"""Redirect to the latest stable firmware binary for this device's hw_type.
|
||||
Resolves to GET /api/firmware/{hw_type}/stable/{version}/firmware.bin.
|
||||
"""
|
||||
url = service.get_firmware_url(sn)
|
||||
return RedirectResponse(url=url, status_code=302)
|
||||
|
||||
@@ -6,7 +6,7 @@ from shared.firebase import get_db
|
||||
from shared.exceptions import NotFoundError
|
||||
from utils.serial_number import generate_serial
|
||||
from utils.nvs_generator import generate as generate_nvs_binary
|
||||
from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate
|
||||
from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, RecentActivityItem
|
||||
|
||||
COLLECTION = "devices"
|
||||
_BATCH_ID_CHARS = string.ascii_uppercase + string.digits
|
||||
@@ -151,3 +151,77 @@ def get_nvs_binary(sn: str) -> bytes:
|
||||
hw_type=item.hw_type,
|
||||
hw_version=item.hw_version,
|
||||
)
|
||||
|
||||
|
||||
def assign_device(sn: str, data: DeviceAssign) -> DeviceInventoryItem:
|
||||
from utils.email import send_device_assignment_invite
|
||||
|
||||
db = get_db()
|
||||
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise NotFoundError("Device")
|
||||
|
||||
doc_ref = docs[0].reference
|
||||
doc_ref.update({
|
||||
"owner": data.customer_email,
|
||||
"assigned_to": data.customer_email,
|
||||
"mfg_status": "sold",
|
||||
})
|
||||
|
||||
send_device_assignment_invite(
|
||||
customer_email=data.customer_email,
|
||||
serial_number=sn,
|
||||
customer_name=data.customer_name,
|
||||
)
|
||||
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
|
||||
|
||||
def get_stats() -> ManufacturingStats:
|
||||
db = get_db()
|
||||
docs = list(db.collection(COLLECTION).stream())
|
||||
|
||||
all_statuses = ["manufactured", "flashed", "provisioned", "sold", "claimed", "decommissioned"]
|
||||
counts = {s: 0 for s in all_statuses}
|
||||
|
||||
activity_candidates = []
|
||||
for doc in docs:
|
||||
data = doc.to_dict() or {}
|
||||
status = data.get("mfg_status", "manufactured")
|
||||
if status in counts:
|
||||
counts[status] += 1
|
||||
|
||||
if status in ("provisioned", "sold", "claimed"):
|
||||
# Use created_at as a proxy timestamp; Firestore DatetimeWithNanoseconds or plain datetime
|
||||
ts = data.get("created_at")
|
||||
if isinstance(ts, datetime):
|
||||
ts_str = ts.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
else:
|
||||
ts_str = str(ts) if ts else None
|
||||
|
||||
activity_candidates.append(RecentActivityItem(
|
||||
serial_number=data.get("serial_number", ""),
|
||||
hw_type=data.get("hw_type", ""),
|
||||
mfg_status=status,
|
||||
owner=data.get("owner"),
|
||||
updated_at=ts_str,
|
||||
))
|
||||
|
||||
# Sort by updated_at descending, take latest 10
|
||||
activity_candidates.sort(
|
||||
key=lambda x: x.updated_at or "",
|
||||
reverse=True,
|
||||
)
|
||||
recent = activity_candidates[:10]
|
||||
|
||||
return ManufacturingStats(counts=counts, recent_activity=recent)
|
||||
|
||||
|
||||
def get_firmware_url(sn: str) -> str:
|
||||
"""Return the FastAPI download URL for the latest stable firmware for this device's hw_type."""
|
||||
from firmware.service import get_latest
|
||||
item = get_device_by_sn(sn)
|
||||
hw_type = item.hw_type.lower()
|
||||
latest = get_latest(hw_type, "stable")
|
||||
# download_url is a relative path like /api/firmware/vs/stable/1.4.2/firmware.bin
|
||||
return latest.download_url
|
||||
|
||||
@@ -65,6 +65,17 @@ SCHEMA_STATEMENTS = [
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)""",
|
||||
# Manufacturing audit log
|
||||
"""CREATE TABLE IF NOT EXISTS mfg_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
admin_user TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
serial_number TEXT,
|
||||
detail TEXT
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mfg_audit_time ON mfg_audit_log(timestamp)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mfg_audit_action ON mfg_audit_log(action)",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -9,3 +9,4 @@ passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.20
|
||||
bcrypt==4.0.1
|
||||
aiosqlite==0.20.0
|
||||
resend==2.10.0
|
||||
87
backend/utils/email.py
Normal file
87
backend/utils/email.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import logging
|
||||
import resend
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_client() -> resend.Resend:
|
||||
return resend.Resend(api_key=settings.resend_api_key)
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, html: str) -> None:
|
||||
"""Send a transactional email via Resend. Logs errors but does not raise."""
|
||||
try:
|
||||
client = _get_client()
|
||||
client.emails.send({
|
||||
"from": settings.email_from,
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"html": html,
|
||||
})
|
||||
logger.info("Email sent to %s — subject: %s", to, subject)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send email to %s: %s", to, exc)
|
||||
raise
|
||||
|
||||
|
||||
def send_device_assignment_invite(
|
||||
customer_email: str,
|
||||
serial_number: str,
|
||||
customer_name: str | None = None,
|
||||
) -> None:
|
||||
"""Notify a customer that a Vesper device has been assigned to them."""
|
||||
greeting = f"Hi {customer_name}," if customer_name else "Hello,"
|
||||
html = f"""
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #111827;">Your Vesper device is ready</h2>
|
||||
<p>{greeting}</p>
|
||||
<p>A Vesper bell automation device has been registered and assigned to you.</p>
|
||||
<p>
|
||||
<strong>Serial Number:</strong>
|
||||
<code style="background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-size: 14px;">{serial_number}</code>
|
||||
</p>
|
||||
<p>Open the Vesper app and enter this serial number to get started.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;" />
|
||||
<p style="color: #6b7280; font-size: 12px;">
|
||||
If you did not expect this email, please contact your system administrator.
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
send_email(
|
||||
to=customer_email,
|
||||
subject=f"Your Vesper device is ready — {serial_number}",
|
||||
html=html,
|
||||
)
|
||||
|
||||
|
||||
def send_device_provisioned_alert(
|
||||
admin_email: str,
|
||||
serial_number: str,
|
||||
hw_type: str,
|
||||
) -> None:
|
||||
"""Internal alert sent to an admin when a device reaches provisioned status."""
|
||||
html = f"""
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #111827;">Device Provisioned</h2>
|
||||
<p>A Vesper device has successfully provisioned and is ready to ship.</p>
|
||||
<table style="border-collapse: collapse; width: 100%; margin-top: 16px;">
|
||||
<tr>
|
||||
<td style="padding: 6px 12px; font-weight: bold; background: #f9fafb;">Serial Number</td>
|
||||
<td style="padding: 6px 12px; font-family: monospace;">{serial_number}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 6px 12px; font-weight: bold; background: #f9fafb;">Board Type</td>
|
||||
<td style="padding: 6px 12px;">{hw_type.upper()}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin-top: 24px;">
|
||||
<a href="#" style="color: #2563eb;">View in Admin Console</a>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
send_email(
|
||||
to=admin_email,
|
||||
subject=f"[Vesper] Device provisioned — {serial_number}",
|
||||
html=html,
|
||||
)
|
||||
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>} />
|
||||
|
||||
|
||||
266
frontend/src/dashboard/DashboardPage.jsx
Normal file
266
frontend/src/dashboard/DashboardPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
868
frontend/src/manufacturing/ProvisioningWizard.jsx
Normal file
868
frontend/src/manufacturing/ProvisioningWizard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,18 @@ http {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# OTA firmware files — allow browser (esptool-js) to fetch .bin files directly
|
||||
location /ota/ {
|
||||
root /srv;
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type";
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Max-Age 3600;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# API requests → FastAPI backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
|
||||
Reference in New Issue
Block a user