Compare commits

..

7 Commits

Author SHA1 Message Date
1ed5012a95 fix: key melody binary storage on pid instead of uid, add migration script
melody.uid is never actually populated anywhere in MelodyForm.jsx, so keying
local .bsm storage on it (as the previous commit did) would silently break
for every existing melody. pid is the correct key anyway: it identifies the
underlying archetype binary, and multiple melodies legitimately share one
pid (each remaps the same note sequence to different bells/speed/duration
via its own settings). Deletion is now share-aware — a melody's binary is
only removed from disk once no other melody still references its pid.

Also adds backend/scripts/migrate_melody_binaries_to_local.py to backfill
existing melodies from their old Firebase URLs to local storage, with
--dry-run support and a warning list for pids whose melodies point at
different source files (a pre-existing data issue, flagged for manual
review via each melody's playback button rather than silently resolved).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 10:53:25 +03:00
72f7e00990 feat: serve melody .bsm binaries over plain HTTP instead of Firebase Storage
ESP32 devices can't spare the 40KB+ RAM a TLS client needs, so Firebase
Storage's HTTPS-only download URLs were blocking melody downloads. Binaries
are now written to local disk (./data/melody_binaries) and served through a
new unauthenticated /api/melodies/download/{pid} route, exposed publicly on
a separate melodies.bellsystems.net vhost (plain HTTP, no TLS) so the main
console domain can stay HTTPS-only with no exceptions. Preview audio still
uses Firebase Storage since it's only ever fetched by the HTTPS admin UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 09:41:24 +03:00
9df80dd4e1 fix: vite server hotfix 2026-06-16 12:15:25 +03:00
1022b7e5f1 fix: remove duplicate port mapping 8001:5174 from frontend service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:22:14 +03:00
024ba88470 fix: route manufacturing audit logs to shared Postgres audit log
- Manufacturing router now uses shared/audit.log_action (Postgres) instead
  of the separate manufacturing/audit.py (SQLite mfg_audit_log), so all
  manufacturing events appear in the Log Viewer
- Added log_action calls to 5 previously unlogged endpoints: lifecycle
  patch, lifecycle create, lifecycle delete, flash asset upload, flash
  asset note
- Removed the now-redundant /manufacturing/audit-log endpoint
- Log Viewer restricted to sysadmin only: backend uses require_sysadmin
  (was require_admin_or_above), frontend adds role guard on the page
- Fixed Action badge column clipping: table-layout auto + whiteSpace nowrap
  so the column sizes to fit the widest badge (Status Change)
- Added device_batch entity type to Log Viewer entity labels and filters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:21:39 +03:00
9a213e93f8 fix: brought back postgress that we removed in the last commit, and added persistent folder for it 2026-04-19 20:10:40 +03:00
d8b0b9ce28 fix: Flashing window size made fixed, and fixed Vite.config.js 2026-04-19 15:52:34 +03:00
23 changed files with 4283 additions and 143 deletions

View File

@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.postgres import get_pg_session
from shared.orm import AuditLog
from auth.dependencies import require_admin_or_above
from auth.dependencies import require_sysadmin
from auth.models import TokenPayload
router = APIRouter(prefix="/api/audit-log", tags=["audit-log"])
@@ -26,7 +26,7 @@ async def list_audit_log(
to_date: Optional[datetime] = Query(None),
limit: int = Query(_DEFAULT_LIMIT, ge=1, le=_MAX_LIMIT),
offset: int = Query(0, ge=0),
_user: TokenPayload = Depends(require_admin_or_above),
_user: TokenPayload = Depends(require_sysadmin),
db: AsyncSession = Depends(get_pg_session),
):
filters = []

View File

@@ -33,6 +33,8 @@ class Settings(BaseSettings):
built_melodies_storage_path: str = "./storage/built_melodies"
firmware_storage_path: str = "./storage/firmware"
flash_assets_storage_path: str = "./storage/flash_assets"
melody_binaries_storage_path: str = "./storage/melody_binaries"
melody_download_base_url: str = "http://melodies.bellsystems.net/download"
# Email (Resend)
resend_api_key: str = "re_placeholder_change_me"

View File

@@ -3,6 +3,7 @@ from fastapi.responses import Response
from fastapi.responses import RedirectResponse
from typing import Optional
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from auth.models import TokenPayload
from auth.dependencies import require_permission
@@ -13,9 +14,10 @@ from manufacturing.models import (
ManufacturingStats,
)
from manufacturing import service
from manufacturing import audit
from shared.audit import log_action
from shared.exceptions import NotFoundError
from shared.firebase import get_db as get_firestore
from database.postgres import get_pg_session
class LifecycleEntryPatch(BaseModel):
@@ -43,26 +45,21 @@ def get_stats(
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")),
db: AsyncSession = Depends(get_pg_session),
):
result = service.create_batch(body)
await audit.log_action(
admin_user=user.email,
action="batch_created",
detail={
"batch_id": result.batch_id,
await log_action(
db, user.sub, user.email,
action="CREATE",
entity_type="device_batch",
entity_id=result.batch_id,
entity_label=f"Batch {result.batch_id} ({result.board_type}, qty {len(result.serial_numbers)})",
meta={
"board_type": result.board_type,
"board_version": result.board_version,
"quantity": len(result.serial_numbers),
@@ -137,6 +134,7 @@ async def update_status(
sn: str,
body: DeviceStatusUpdate,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
):
# Guard: claimed requires at least one user in user_list
# (allow if explicitly force_claimed=true, which the mfg UI sets after adding a user manually)
@@ -167,11 +165,13 @@ async def update_status(
)
result = service.update_device_status(sn, body, set_by=user.email)
await audit.log_action(
admin_user=user.email,
action="status_updated",
serial_number=sn,
detail={"status": body.status.value, "note": body.note},
await log_action(
db, user.sub, user.email,
action="STATUS_CHANGE",
entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"status": body.status.value, "note": body.note},
)
return result
@@ -181,10 +181,11 @@ async def patch_lifecycle_entry(
sn: str,
body: LifecycleEntryPatch,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
):
"""Edit the date and/or note of a lifecycle history entry by index."""
db = get_firestore()
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
fs = get_firestore()
docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream())
if not docs:
raise HTTPException(status_code=404, detail="Device not found")
doc_ref = docs[0].reference
@@ -198,7 +199,16 @@ async def patch_lifecycle_entry(
history[body.index]["note"] = body.note
doc_ref.update({"lifecycle_history": history})
from manufacturing.service import _doc_to_inventory_item
return _doc_to_inventory_item(doc_ref.get())
result = _doc_to_inventory_item(doc_ref.get())
await log_action(
db, user.sub, user.email,
action="UPDATE",
entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"lifecycle_index": body.index, "date": body.date, "note": body.note},
)
return result
@router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=200)
@@ -206,6 +216,7 @@ async def create_lifecycle_entry(
sn: str,
body: LifecycleEntryCreate,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
):
"""Upsert a lifecycle history entry for the given status_id.
@@ -214,8 +225,8 @@ async def create_lifecycle_entry(
a status is visited more than once (max one entry per status).
"""
from datetime import datetime, timezone
db = get_firestore()
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
fs = get_firestore()
docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream())
if not docs:
raise HTTPException(status_code=404, detail="Device not found")
doc_ref = docs[0].reference
@@ -229,19 +240,28 @@ async def create_lifecycle_entry(
"set_by": user.email,
}
# Overwrite existing entry for this status if present, else append
existing_idx = next(
(i for i, e in enumerate(history) if e.get("status_id") == body.status_id),
None,
)
if existing_idx is not None:
is_update = existing_idx is not None
if is_update:
history[existing_idx] = new_entry
else:
history.append(new_entry)
doc_ref.update({"lifecycle_history": history})
from manufacturing.service import _doc_to_inventory_item
return _doc_to_inventory_item(doc_ref.get())
result = _doc_to_inventory_item(doc_ref.get())
await log_action(
db, user.sub, user.email,
action="UPDATE" if is_update else "CREATE",
entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"lifecycle_status": body.status_id, "date": new_entry["date"], "note": body.note},
)
return result
@router.delete("/devices/{sn}/lifecycle/{index}", response_model=DeviceInventoryItem)
@@ -249,10 +269,11 @@ async def delete_lifecycle_entry(
sn: str,
index: int,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
):
"""Delete a lifecycle history entry by index. Cannot delete the entry for the current status."""
db = get_firestore()
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
fs = get_firestore()
docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream())
if not docs:
raise HTTPException(status_code=404, detail="Device not found")
doc_ref = docs[0].reference
@@ -261,12 +282,22 @@ async def delete_lifecycle_entry(
if index < 0 or index >= len(history):
raise HTTPException(status_code=400, detail="Invalid lifecycle entry index")
current_status = data.get("mfg_status", "")
if history[index].get("status_id") == current_status:
deleted_entry = history[index]
if deleted_entry.get("status_id") == current_status:
raise HTTPException(status_code=400, detail="Cannot delete the entry for the current status. Change the status first.")
history.pop(index)
doc_ref.update({"lifecycle_history": history})
from manufacturing.service import _doc_to_inventory_item
return _doc_to_inventory_item(doc_ref.get())
result = _doc_to_inventory_item(doc_ref.get())
await log_action(
db, user.sub, user.email,
action="DELETE",
entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"lifecycle_status": deleted_entry.get("status_id"), "index": index},
)
return result
@router.get("/devices/{sn}/nvs.bin")
@@ -276,12 +307,16 @@ async def download_nvs(
hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"),
nvs_schema: Optional[str] = Query(None, description="NVS schema to use: 'legacy' or 'new' (default)"),
user: TokenPayload = Depends(require_permission("manufacturing", "view")),
db: AsyncSession = Depends(get_pg_session),
):
binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override, legacy=(nvs_schema == "legacy"))
await audit.log_action(
admin_user=user.email,
action="device_flashed",
serial_number=sn,
await log_action(
db, user.sub, user.email,
action="COMMAND",
entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"command": "nvs_flash", "hw_type_override": hw_type_override, "nvs_schema": nvs_schema or "new"},
)
return Response(
content=binary,
@@ -295,16 +330,19 @@ async def assign_device(
sn: str,
body: DeviceAssign,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
):
try:
result = service.assign_device(sn, body)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
await audit.log_action(
admin_user=user.email,
action="device_assigned",
serial_number=sn,
detail={"customer_id": body.customer_id},
await log_action(
db, user.sub, user.email,
action="UPDATE",
entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"customer_id": body.customer_id},
)
return result
@@ -314,6 +352,7 @@ async def delete_device(
sn: str,
force: bool = Query(False, description="Required to delete sold/claimed devices"),
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
db: AsyncSession = Depends(get_pg_session),
):
"""Delete a device. Sold/claimed devices require force=true."""
try:
@@ -322,11 +361,13 @@ async def delete_device(
raise HTTPException(status_code=404, detail="Device not found")
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
await audit.log_action(
admin_user=user.email,
action="device_deleted",
serial_number=sn,
detail={"force": force},
await log_action(
db, user.sub, user.email,
action="DELETE",
entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"force": force},
)
@@ -334,6 +375,7 @@ async def delete_device(
async def send_manufactured_email(
sn: str,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
):
"""Send the 'device manufactured' notification to the assigned customer's email."""
db = get_firestore()
@@ -361,11 +403,13 @@ async def send_manufactured_email(
device_name=hw_family.replace("_", " ").title(),
customer_name=customer_name,
)
await audit.log_action(
admin_user=user.email,
action="email_manufactured_sent",
serial_number=sn,
detail={"recipient": email},
await log_action(
db, user.sub, user.email,
action="COMMAND",
entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"command": "email_manufactured", "recipient": email},
)
@@ -373,6 +417,7 @@ async def send_manufactured_email(
async def send_assigned_email(
sn: str,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
):
"""Send the 'device assigned / app instructions' email to the assigned user(s)."""
db = get_firestore()
@@ -407,24 +452,30 @@ async def send_assigned_email(
errors.append(str(exc))
if errors:
raise HTTPException(status_code=500, detail=f"Some emails failed: {'; '.join(errors)}")
await audit.log_action(
admin_user=user.email,
action="email_assigned_sent",
serial_number=sn,
detail={"user_count": len(user_list)},
await log_action(
db, user.sub, user.email,
action="COMMAND",
entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"command": "email_assigned", "user_count": len(user_list)},
)
@router.delete("/devices", status_code=200)
async def delete_unprovisioned(
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
db: AsyncSession = Depends(get_pg_session),
):
"""Delete all devices with status 'manufactured' (never provisioned)."""
deleted = service.delete_unprovisioned_devices()
await audit.log_action(
admin_user=user.email,
action="bulk_delete_unprovisioned",
detail={"count": len(deleted), "serial_numbers": deleted},
await log_action(
db, user.sub, user.email,
action="DELETE",
entity_type="device_batch",
entity_id="bulk_unprovisioned",
entity_label=f"Bulk delete unprovisioned ({len(deleted)} devices)",
meta={"count": len(deleted), "serial_numbers": deleted},
)
return {"deleted": deleted, "count": len(deleted)}
@@ -466,6 +517,7 @@ async def delete_flash_asset(
hw_type: str,
asset: str,
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
db: AsyncSession = Depends(get_pg_session),
):
"""Delete a single flash asset file (bootloader.bin or partitions.bin)."""
if asset not in VALID_FLASH_ASSETS:
@@ -474,10 +526,13 @@ async def delete_flash_asset(
service.delete_flash_asset(hw_type, asset)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
await audit.log_action(
admin_user=user.email,
action="flash_asset_deleted",
detail={"hw_type": hw_type, "asset": asset},
await log_action(
db, user.sub, user.email,
action="DELETE",
entity_type="firmware",
entity_id=f"{hw_type}/{asset}",
entity_label=f"{hw_type} / {asset}",
meta={"hw_type": hw_type, "asset": asset},
)
@@ -489,7 +544,8 @@ class FlashAssetNoteBody(BaseModel):
async def set_flash_asset_note(
hw_type: str,
body: FlashAssetNoteBody,
_user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
):
"""Save (or overwrite) the note for a hw_type's flash asset set.
@@ -497,6 +553,14 @@ async def set_flash_asset_note(
Pass an empty string to clear the note.
"""
service.set_flash_asset_note(hw_type, body.note)
await log_action(
db, user.sub, user.email,
action="UPDATE",
entity_type="firmware",
entity_id=hw_type,
entity_label=hw_type,
meta={"note": body.note},
)
@router.post("/flash-assets/{hw_type}/{asset}", status_code=204)
@@ -504,7 +568,8 @@ async def upload_flash_asset(
hw_type: str,
asset: str,
file: UploadFile = File(...),
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
user: TokenPayload = Depends(require_permission("manufacturing", "add")),
db: AsyncSession = Depends(get_pg_session),
):
"""Upload a bootloader.bin or partitions.bin for a given hw_type.
@@ -512,13 +577,20 @@ async def upload_flash_asset(
and .pio/build/{env}/partitions.bin). Upload them once per hw_type after
each PlatformIO build that changes the partition layout.
"""
# hw_type can be a standard board type OR a bespoke UID (any non-empty slug)
if not hw_type or len(hw_type) > 128:
raise HTTPException(status_code=400, detail="Invalid hw_type/bespoke UID.")
if asset not in VALID_FLASH_ASSETS:
raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}")
data = await file.read()
service.save_flash_asset(hw_type, asset, data)
await log_action(
db, user.sub, user.email,
action="CREATE",
entity_type="firmware",
entity_id=f"{hw_type}/{asset}",
entity_label=f"{hw_type} / {asset}",
meta={"hw_type": hw_type, "asset": asset, "size_bytes": len(data)},
)
@router.get("/devices/{sn}/bootloader.bin")

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException, Response
from fastapi.responses import FileResponse
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from auth.models import TokenPayload
@@ -13,6 +14,22 @@ from shared.audit import log_action
router = APIRouter(prefix="/api/melodies", tags=["melodies"])
@router.get("/download/{pid}")
async def download_binary_by_pid(pid: str):
"""Download a melody's .bsm binary by PID over plain HTTP.
No auth — ESP32 devices call this directly and can't afford a TLS client's
memory footprint, so this is served from a plain-HTTP-only host
(melodies.bellsystems.net) rather than the HTTPS console domain.
"""
path = await service.get_binary_path_by_pid(pid)
return FileResponse(
path=str(path),
media_type="application/octet-stream",
filename=f"{pid}.bsm",
)
@router.get("", response_model=MelodyListResponse)
async def list_melodies(
search: Optional[str] = Query(None),
@@ -134,15 +151,21 @@ async def upload_file(
if file_type == "binary":
content_type = "application/octet-stream"
url = service.upload_file_for_melody(
melody_id=melody_id,
melody_uid=melody.uid,
melody_pid=melody.pid,
file_bytes=contents,
filename=file.filename,
content_type=content_type,
)
if not melody.pid:
raise HTTPException(status_code=400, detail="Melody must have a PID before uploading a binary")
url = service.save_binary_for_melody(
pid=melody.pid,
file_bytes=contents,
)
else:
url = service.upload_file_for_melody(
melody_id=melody_id,
melody_uid=melody.uid,
melody_pid=melody.pid,
file_bytes=contents,
filename=file.filename,
content_type=content_type,
)
# Update the melody document with the file URL
if file_type == "preview":
@@ -169,7 +192,7 @@ async def delete_file(
raise HTTPException(status_code=400, detail="file_type must be 'binary' or 'preview'")
melody = await service.get_melody(melody_id)
service.delete_file(melody_id, file_type, melody.uid)
await service.delete_file(melody_id, file_type, melody.uid, melody.pid)
@router.get("/{melody_id}/files")
@@ -179,7 +202,7 @@ async def get_files(
):
"""Get storage file URLs for a melody."""
melody = await service.get_melody(melody_id)
return service.get_storage_files(melody_id, melody.uid)
return service.get_storage_files(melody_id, melody.uid, melody.pid)
@router.patch("/{melody_id}/set-outdated", response_model=MelodyInDB)
@@ -206,7 +229,7 @@ async def download_binary_file(
):
"""Download current melody binary with a PID-based filename."""
melody = await service.get_melody(melody_id)
file_bytes, content_type = service.get_binary_file_bytes(melody_id, melody.uid)
file_bytes, content_type = service.get_binary_file_bytes(melody_id, melody.pid)
filename = f"{(melody.pid or 'binary')}.bsm"
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
return Response(content=file_bytes, media_type=content_type, headers=headers)

View File

@@ -2,15 +2,39 @@ import json
import uuid
import logging
from datetime import datetime
from pathlib import Path
from fastapi import HTTPException
from shared.firebase import get_db as get_firestore, get_bucket
from shared.exceptions import NotFoundError
from melodies.models import MelodyCreate, MelodyUpdate, MelodyInDB
from melodies import database as melody_db
from config import settings
COLLECTION = "melodies"
logger = logging.getLogger("melodies.service")
# Local disk storage for melody .bsm binaries — served over plain HTTP for ESP32 compatibility.
# The audio preview file still goes to Firebase Storage (only ever fetched by the browser admin UI).
BINARY_STORAGE_DIR = Path(settings.melody_binaries_storage_path)
def _ensure_binary_storage_dir():
BINARY_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
def _binary_file_path(pid: str) -> Path:
"""Path to the local .bsm file for a given archetype PID.
Keyed on pid, not melody uid: pid identifies the underlying archetype binary
(raw note sequence). Multiple melodies can legitimately share one pid — each
melody remaps those notes to actual bells via its own noteAssignments/speed/
duration settings — so several melodies writing/reading the same {pid}.bsm
file is expected, not a collision.
"""
return BINARY_STORAGE_DIR / f"{pid}.bsm"
def _parse_localized_string(value: str) -> dict:
"""Parse a JSON-encoded localized string into a dict. Returns {} on failure."""
@@ -232,12 +256,50 @@ async def delete_melody(melody_id: str) -> None:
doc_ref.delete()
# Delete storage files
_delete_storage_files(melody_id, row["data"].get("uid"))
await _delete_storage_files(melody_id, row["data"].get("uid"), row["data"].get("pid"))
# Delete from SQLite
await melody_db.delete_melody(melody_id)
def save_binary_for_melody(pid: str, file_bytes: bytes) -> str:
"""Save an archetype binary to local disk under its pid, replacing any previous
file for that pid. Multiple melodies can share one pid (they remap the same
underlying note sequence via their own noteAssignments/speed/duration), so
writing the same {pid}.bsm from a different melody is expected — not a collision.
Returns the plain-HTTP download URL devices use (served from melody_download_base_url,
not Firebase — ESP32 devices can't afford the RAM for a TLS client).
"""
_ensure_binary_storage_dir()
path = _binary_file_path(pid)
path.write_bytes(file_bytes)
return f"{settings.melody_download_base_url}/{pid}"
async def get_binary_path_by_pid(pid: str) -> Path:
"""Resolve a pid to its local .bsm file path. Used by the unauthenticated
device-facing download route."""
path = _binary_file_path(pid)
if path.exists():
return path
raise NotFoundError("Binary file")
def delete_local_binary(pid: str) -> None:
"""Delete the local .bsm binary file for a pid, if present.
Only call this when no other melody still references this pid — since the
file may be shared by multiple melodies, deleting one melody shouldn't
reflexively delete a binary others still use.
"""
if not pid:
return
path = _binary_file_path(pid)
if path.exists():
path.unlink()
def upload_file(melody_id: str, file_bytes: bytes, filename: str, content_type: str) -> str:
"""Upload a file to Firebase Storage under melodies/{melody_id}/."""
bucket = get_bucket()
@@ -334,32 +396,32 @@ def upload_file_for_melody(melody_id: str, melody_uid: str | None, melody_pid: s
return blob.public_url
def get_binary_file_bytes(melody_id: str, melody_uid: str | None = None) -> tuple[bytes, str]:
"""Fetch current binary bytes for a melody from Firebase Storage."""
bucket = get_bucket()
if not bucket:
raise RuntimeError("Firebase Storage not initialized")
prefixes = _storage_prefixes(melody_id, melody_uid)
blobs = [b for b in _list_blobs_for_prefixes(bucket, prefixes) if _is_binary_blob_name(b.name)]
if not blobs:
raise NotFoundError("Binary file")
# Prefer explicit binary.* naming, then newest.
blobs.sort(
key=lambda b: (
0 if "binary" in b.name.rsplit("/", 1)[-1].lower() else 1,
-(int(b.time_created.timestamp()) if getattr(b, "time_created", None) else 0),
)
async def _pid_used_by_other_melody(pid: str, exclude_melody_id: str) -> bool:
"""Check whether any melody other than exclude_melody_id still references this pid."""
if not pid:
return False
rows = await melody_db.list_melodies()
return any(
row["id"] != exclude_melody_id and row["data"].get("pid") == pid
for row in rows
)
chosen = blobs[0]
data = chosen.download_as_bytes()
content_type = chosen.content_type or "application/octet-stream"
return data, content_type
def delete_file(melody_id: str, file_type: str, melody_uid: str | None = None) -> None:
def get_binary_file_bytes(melody_id: str, melody_pid: str | None = None) -> tuple[bytes, str]:
"""Fetch current binary bytes for a melody from local disk storage."""
path = _binary_file_path(melody_pid) if melody_pid else None
if not path or not path.exists():
raise NotFoundError("Binary file")
return path.read_bytes(), "application/octet-stream"
async def delete_file(melody_id: str, file_type: str, melody_uid: str | None = None, melody_pid: str | None = None) -> None:
"""Delete a specific file from storage. file_type is 'binary' or 'preview'."""
if file_type == "binary":
if melody_pid and not await _pid_used_by_other_melody(melody_pid, melody_id):
delete_local_binary(melody_pid)
return
bucket = get_bucket()
if not bucket:
return
@@ -368,14 +430,19 @@ def delete_file(melody_id: str, file_type: str, melody_uid: str | None = None) -
blobs = _list_blobs_for_prefixes(bucket, prefixes)
for blob in blobs:
if file_type == "binary" and "binary" in blob.name:
blob.delete()
elif file_type == "preview" and "preview" in blob.name:
if file_type == "preview" and "preview" in blob.name:
blob.delete()
def _delete_storage_files(melody_id: str, melody_uid: str | None = None) -> None:
"""Delete all storage files for a melody."""
async def _delete_storage_files(melody_id: str, melody_uid: str | None = None, melody_pid: str | None = None) -> None:
"""Delete all storage files for a melody (local binary + Firebase preview).
The local binary is only deleted if no other melody still references the same
pid — multiple melodies can share one archetype binary by design.
"""
if melody_pid and not await _pid_used_by_other_melody(melody_pid, melody_id):
delete_local_binary(melody_pid)
bucket = get_bucket()
if not bucket:
return
@@ -383,24 +450,29 @@ def _delete_storage_files(melody_id: str, melody_uid: str | None = None) -> None
prefixes = _storage_prefixes(melody_id, melody_uid)
blobs = _list_blobs_for_prefixes(bucket, prefixes)
for blob in blobs:
if _is_binary_blob_name(blob.name):
continue # legacy Firebase binaries, if any, are no longer authoritative
blob.delete()
def get_storage_files(melody_id: str, melody_uid: str | None = None) -> dict:
"""List storage files for a melody, returning URLs."""
def get_storage_files(melody_id: str, melody_uid: str | None = None, melody_pid: str | None = None) -> dict:
"""List storage files for a melody, returning URLs. Binary comes from local disk,
preview still comes from Firebase Storage."""
result = {"binary_url": None, "preview_url": None}
if melody_pid and _binary_file_path(melody_pid).exists():
result["binary_url"] = f"{settings.melody_download_base_url}/{melody_pid}"
bucket = get_bucket()
if not bucket:
return {"binary_url": None, "preview_url": None}
return result
prefixes = _storage_prefixes(melody_id, melody_uid)
blobs = _list_blobs_for_prefixes(bucket, prefixes)
result = {"binary_url": None, "preview_url": None}
for blob in blobs:
blob.make_public()
if _is_binary_blob_name(blob.name):
result["binary_url"] = blob.public_url
elif "preview" in blob.name:
if "preview" in blob.name:
blob.make_public()
result["preview_url"] = blob.public_url
return result

View File

@@ -0,0 +1,262 @@
"""
One-time migration: move every melody's .bsm binary off Firebase Storage and
onto local disk, updating melody.url to point at our own plain-HTTP download
endpoint instead of the Firebase public URL.
Background: ESP32 devices can't afford the RAM for a TLS client, so melody
binaries are now served from ./storage/melody_binaries via
GET /api/melodies/download/{pid} (exposed publicly as
melodies.bellsystems.net/download/{pid}) instead of Firebase Storage's
HTTPS-only URLs. This script backfills existing melodies created before that
change — melodies created/edited after the change already use the new path
automatically (via "Select Archetype" / "Build on the Fly").
What this script does, for every melody whose url points at Firebase Storage:
1. Downloads the .bsm bytes from the Firebase Storage URL
2. Writes them to ./storage/melody_binaries/{pid}.bsm
3. Updates melody.url -> http://melodies.bellsystems.net/download/{pid}
4. Updates both SQLite (melody_drafts) and Firestore (if published)
Storage is keyed on pid, not melody uid: pid identifies the underlying
archetype binary, and multiple melodies legitimately share one pid (each
remaps the same note sequence to different bells/speed/duration). The script
downloads each distinct pid's binary only once (from whichever melody it
encounters first) and reuses it for every other melody sharing that pid.
CAVEAT: some existing melodies share a pid despite pointing at *different*
Firebase source files (observed in practice — e.g. voice-count variants like
1N_/2N_/3N_/4N_-prefixed filenames all filed under one pid). This script does
NOT attempt to detect or fix that — it flags it in the output (see "WARNING:
also seen with a different source file") so you can review and re-assign the
correct archetype per melody afterward using the in-app playback button. This
is a pre-existing PID data issue, not something safe to silently resolve here.
Melodies with no pid are skipped and reported, since the new URL scheme
requires one (pid is the public lookup key for the download route).
Run from the backend/ directory (or scripts/ — it searches upward for .env):
docker exec -it bellsystems-backend python scripts/migrate_melody_binaries_to_local.py --dry-run
docker exec -it bellsystems-backend python scripts/migrate_melody_binaries_to_local.py
Requires: firebase-admin, requests (both already in the backend image).
"""
import argparse
import json
import os
import sqlite3
import sys
from pathlib import Path
import requests
# Melody names may contain Greek/non-ASCII text — force UTF-8 stdout so this
# doesn't crash on Windows consoles defaulting to cp1252.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# ---------------------------------------------------------------------------
# .env loader — searches upward from script location for a .env file
# ---------------------------------------------------------------------------
def _load_env() -> dict:
search = Path(__file__).resolve().parent
for _ in range(4):
env_file = search / ".env"
if env_file.exists():
result = {}
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
result[key.strip()] = val.strip().strip('"').strip("'")
print(f"[INFO] Loaded config from {env_file}")
return result
search = search.parent
print("[WARN] No .env file found — relying on environment variables")
return {}
_env = _load_env()
def _cfg(key: str, default: str = "") -> str:
return _env.get(key) or os.environ.get(key) or default
# ---------------------------------------------------------------------------
# Firebase (only needed to read Firestore for published melodies)
# ---------------------------------------------------------------------------
try:
import firebase_admin
from firebase_admin import credentials, firestore
_fb_app = None
def _init_firebase(sa_path: str, bucket_name: str):
global _fb_app
if _fb_app is not None:
return
cred = credentials.Certificate(sa_path)
_fb_app = firebase_admin.initialize_app(cred, {"storageBucket": bucket_name})
def get_firestore(sa_path: str, bucket_name: str):
_init_firebase(sa_path, bucket_name)
return firestore.client()
FIREBASE_AVAILABLE = True
except Exception as _fb_err:
print(f"[WARN] Firebase unavailable: {_fb_err}")
FIREBASE_AVAILABLE = False
def get_firestore(sa_path: str, bucket_name: str):
return None
def _is_firebase_url(url: str) -> bool:
return bool(url) and ("firebasestorage" in url or "storage.googleapis.com" in url)
# ---------------------------------------------------------------------------
# Main migration
# ---------------------------------------------------------------------------
def run(dry_run: bool = False, db_path: str = "", storage_dir: str = "", base_url: str = ""):
label = "[DRY-RUN]" if dry_run else "[LIVE]"
db_path = db_path or _cfg("SQLITE_DB_PATH", "./data/database.db")
storage_dir = Path(storage_dir or _cfg("MELODY_BINARIES_STORAGE_PATH", "./storage/melody_binaries"))
base_url = base_url or _cfg("MELODY_DOWNLOAD_BASE_URL", "http://melodies.bellsystems.net/download")
sa_path = _cfg("FIREBASE_SERVICE_ACCOUNT_PATH", "./firebase-service-account.json")
bucket_name = _cfg("FIREBASE_STORAGE_BUCKET")
print(f"\n{label} Database: {db_path}")
print(f"{label} Local binary storage: {storage_dir}")
print(f"{label} New base URL: {base_url}\n")
firestore_db = get_firestore(sa_path, bucket_name) if FIREBASE_AVAILABLE and bucket_name else None
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
rows = con.execute("SELECT * FROM melody_drafts").fetchall()
candidates = []
for row in rows:
data = json.loads(row["data"])
if _is_firebase_url(data.get("url", "")):
candidates.append((dict(row), data))
if not candidates:
print("No melodies with a Firebase Storage url found. Nothing to do.")
con.close()
return
print(f"Found {len(candidates)} melody(ies) with a Firebase Storage binary url:\n")
if not dry_run:
storage_dir.mkdir(parents=True, exist_ok=True)
migrated = 0
skipped_no_pid = 0
failed = 0
downloaded_pids: dict[str, bool] = {} # pid -> whether the local file is ready to use
pid_source_urls: dict[str, str] = {} # pid -> first-seen Firebase source url, to detect mismatches
mismatch_warnings = []
for row, data in candidates:
melody_id = row["id"]
pid = data.get("pid")
old_url = data.get("url")
name = (data.get("information") or {}).get("name", "")
label_id = f"[{melody_id[:8]}]"
if not pid:
print(f" {label_id} SKIPPED: no pid set — the new download route requires one (name: {name})")
skipped_no_pid += 1
continue
print(f" {label_id} {name!r} pid={pid}")
print(f" {old_url}")
first_seen_url = pid_source_urls.get(pid)
if first_seen_url and first_seen_url != old_url:
warning = (f"pid '{pid}': melody {label_id} points at a DIFFERENT Firebase file "
f"than the one already saved for this pid — only the first one encountered "
f"is kept as {pid}.bsm. Verify with the playback button after migrating.")
print(f" WARNING: also seen with a different source file! {warning}")
mismatch_warnings.append(warning)
else:
pid_source_urls[pid] = old_url
dest = storage_dir / f"{pid}.bsm"
new_url = f"{base_url}/{pid}"
if dry_run:
if pid in downloaded_pids:
print(f" -> pid already handled by another melody in this run, would reuse {dest}")
else:
print(f" -> would download and save to {dest}")
print(f" -> would set url = {new_url}")
downloaded_pids[pid] = True
continue
if pid not in downloaded_pids:
try:
resp = requests.get(old_url, timeout=30)
resp.raise_for_status()
except Exception as e:
print(f" ERROR downloading binary: {e}")
failed += 1
continue
dest.write_bytes(resp.content)
print(f" saved {len(resp.content)} bytes -> {dest}")
downloaded_pids[pid] = True
else:
print(f" reusing already-downloaded {dest} (shared pid)")
print(f" url -> {new_url}")
data["url"] = new_url
con.execute(
"UPDATE melody_drafts SET data=? WHERE id=?",
(json.dumps(data), melody_id),
)
con.commit()
if row["status"] == "published":
if firestore_db:
try:
firestore_db.collection("melodies").document(melody_id).update({"url": new_url})
print(f" Firestore updated")
except Exception as e:
print(f" ERROR updating Firestore: {e}")
else:
print(f" WARNING: melody is published but Firestore unavailable!")
migrated += 1
con.close()
print(f"\n{'-'*60}")
print(f"{label} Done. Migrated: {migrated}, skipped (no pid): {skipped_no_pid}, failed: {failed}")
if mismatch_warnings:
print(f"\n{len(mismatch_warnings)} pid(s) had melodies pointing at different source files "
f"— please review these with the playback button:")
for w in mismatch_warnings:
print(f" - {w}")
if dry_run:
print("\nThis was a dry run. No changes were made. Run without --dry-run to apply.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Migrate melody .bsm binaries from Firebase Storage to local disk"
)
parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing anything")
parser.add_argument("--db", default="", help="Override SQLite database path (default: read from .env)")
parser.add_argument("--storage-dir", default="", help="Override local binary storage dir (default: read from .env)")
parser.add_argument("--base-url", default="", help="Override download base URL (default: read from .env)")
args = parser.parse_args()
run(dry_run=args.dry_run, db_path=args.db, storage_dir=args.storage_dir, base_url=args.base_url)

View File

@@ -9,21 +9,19 @@ services:
- ./data/built_melodies:/app/storage/built_melodies
- ./data/firmware:/app/storage/firmware
- ./data/flash_assets:/app/storage/flash_assets
- ./data/melody_binaries:/app/storage/melody_binaries
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
networks:
- internal
frontend:
build: ./frontend
container_name: bellsystems-frontend
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "5173:5174"
- "8001:5174"
networks:
- internal
@@ -40,6 +38,24 @@ services:
networks:
- internal
postgres:
image: postgres:16-alpine
container_name: bellsystems-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./data/postgres:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
internal:
driver: bridge

View File

@@ -0,0 +1,119 @@
# Melody Binary Serving (Plain HTTP)
## Why this exists
ESP32 devices download `.bsm` melody files directly, using a URL supplied through the
Android app. The download previously pointed at a Firebase Storage public URL (HTTPS
only). ESP32's TLS client needs 40KB+ of RAM to open an HTTPS connection, which these
devices can't reliably spare. To fix this, melody `.bsm` binaries are now stored and
served by our own backend over plain HTTP, instead of Firebase Storage.
This does **not** affect the audio preview file (`information.previewURL`) — that's
only ever fetched by the browser admin UI, which is already HTTPS, so it stays on
Firebase Storage unchanged.
## PID vs UID — why storage is keyed on pid, not uid
- **`pid`** ("Playback ID") identifies the underlying **archetype binary** — the raw
note sequence (A, B, C...) baked into a `.bsm` file. Multiple melodies can
legitimately share one `pid`: each melody remaps those notes to actual bells via its
own `noteAssignments`, speed, and duration settings. One binary can back many
different "remix" melodies. **Duplicate PIDs across melodies are correct and
expected, not a bug.**
- **`uid`** is meant to be each melody's own unique database identity. It is *not*
currently populated anywhere in `MelodyForm.jsx` — this is a pre-existing, separate
bug, deferred as its own task. Because of this, local binary storage is keyed on
`pid`, not `uid`.
## What changed
- **Storage**: `.bsm` binaries are written to `./data/melody_binaries/{pid}.bsm` on
the host (mounted into the backend container at `/app/storage/melody_binaries`,
same pattern as `./data/firmware` and `./data/built_melodies`). Because storage is
keyed on `pid`, multiple melodies sharing a `pid` share one file — saving from any
of them overwrites the same file, which is expected.
- **Upload path**: `SelectArchetypeModal` / `BuildOnTheFlyModal` still call
`POST /api/melodies/{melodyId}/upload/binary` exactly as before. The backend
(`backend/melodies/service.py::save_binary_for_melody`) now writes the bytes to
local disk instead of uploading to Firebase Storage, and returns a URL like
`http://melodies.bellsystems.net/download/{pid}`. The melody must have a `pid` set
before uploading a binary (the endpoint returns 400 if not).
- **Stored URL**: the melody's `url` field (Firestore + SQLite) now holds that
plain-HTTP URL instead of a Firebase public URL. No schema change — `url` was
already a plain string field.
- **Download route**: `GET /api/melodies/download/{pid}` (`backend/melodies/router.py`)
is unauthenticated (devices have no login token) and resolves `pid` directly to
`{pid}.bsm` on disk, same pattern as the existing firmware download route
(`backend/firmware/router.py::download_firmware`).
- **Deletion is share-aware**: deleting a melody or its binary
(`service.delete_file` / `service._delete_storage_files`) only removes the local
`.bsm` file if no *other* melody still references the same `pid`
(`service._pid_used_by_other_melody`). This prevents one melody's deletion from
breaking playback for other melodies sharing its archetype binary.
## Migrating existing melodies
Melodies created before this change still have a Firebase Storage URL in their `url`
field — deploying this code does not touch existing data. They keep working exactly
as before (still HTTPS to Firebase) until migrated.
Two ways to move a melody to the new plain-HTTP URL:
1. **Per-melody, via the UI**: open the melody and re-run "Select Archetype" or
"Build on the Fly" — this naturally re-uploads through the same endpoint, which now
writes to local disk and updates `url`.
2. **Bulk, via script**: `backend/scripts/migrate_melody_binaries_to_local.py`
downloads each melody's current Firebase binary and writes it to local disk under
its `pid`, then updates `url` (SQLite + Firestore if published). Run with
`--dry-run` first:
```
docker exec -it bellsystems-backend python scripts/migrate_melody_binaries_to_local.py --dry-run
docker exec -it bellsystems-backend python scripts/migrate_melody_binaries_to_local.py
```
**Known caveat**: some existing melodies share a `pid` despite their Firebase URLs
pointing at *different* source files (observed on real data — e.g. voice-count
variant filenames like `1N_`/`2N_`/`3N_`/`4N_` all filed under one `pid`). The
script downloads only the first melody's file for each `pid` and reuses it for
every other melody sharing that `pid` — it does not attempt to detect which file is
"correct". It prints a warning list of every `pid` where this happened; use each
melody's playback button afterward to verify the right archetype plays, and
manually re-assign the correct one via "Select Archetype" if it's wrong.
## Infrastructure (outside this repo)
The plain-HTTP requirement is handled by keeping `melodies.bellsystems.net` on a
**separate** hostname from `console.bellsystems.net`, so the console's NPM proxy host
can stay HTTPS-only with no per-path exceptions.
Required, one-time setup outside this repository:
1. **DNS**: add an A/CNAME record for `melodies.bellsystems.net` pointing at the same
host as `console.bellsystems.net`.
2. **NPM (Nginx Proxy Manager) proxy host**: create a new proxy host for
`melodies.bellsystems.net` forwarding to the `nginx` container's exposed port
(`90` per `docker-compose.yml`). **Do not force SSL / do not enable the "Force
SSL" redirect** on this proxy host — it must remain reachable over plain HTTP.
The in-repo `nginx/nginx.conf` already has a dedicated `server_name
melodies.bellsystems.net` block that maps `/download/{pid}` to the backend's
`/api/melodies/download/{pid}` route, so no further nginx changes are needed once
the NPM proxy host exists.
## Verification
1. Build/select an archetype for a melody with a `pid` set → confirm a file appears
at `./data/melody_binaries/{pid}.bsm` and the melody's `url` becomes
`http://melodies.bellsystems.net/download/{pid}`.
2. `curl http://localhost:8000/api/melodies/download/{pid}` (direct to backend,
bypassing nginx) → returns the `.bsm` bytes, no auth header needed.
3. `curl http://localhost:90/api/melodies/download/{pid}` (through nginx on the
console server block) → same result.
4. Once NPM is configured, `curl http://melodies.bellsystems.net/download/{pid}`
→ same result, over plain HTTP.
5. Delete a melody whose `pid` is *not* shared with any other melody → confirm the
local `.bsm` file is removed. Delete one of two melodies sharing a `pid` → confirm
the file survives until the last one is deleted.
6. Upload a preview audio file → confirm it still lands in Firebase Storage and
`previewURL` still populates (regression check).

View File

@@ -1,4 +1,5 @@
FROM node:20-alpine
# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
@@ -6,5 +7,12 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["npm", "run", "dev"]
# Stage 2: serve with nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.prod.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

17
frontend/nginx.prod.conf Normal file
View File

@@ -0,0 +1,17 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA fallback — all unknown routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|svg|ico|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -1107,7 +1107,9 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
border: '1px solid var(--color-border)',
overflow: 'hidden',
display: 'flex', flexDirection: 'column',
minHeight: 320,
height: 320,
alignSelf: 'start',
position: 'sticky', top: 0,
}}>
<div style={{
padding: '8px 12px',

View File

@@ -32,17 +32,18 @@ const ACTION_META = {
}
const ENTITY_LABELS = {
customer: 'Customer',
order: 'Order',
device: 'Device',
melody: 'Melody',
product: 'Product',
staff: 'Staff',
ticket: 'Ticket',
note: 'Note',
quotation: 'Quotation',
firmware: 'Firmware',
archetype: 'Archetype',
customer: 'Customer',
order: 'Order',
device: 'Device',
device_batch: 'Device Batch',
melody: 'Melody',
product: 'Product',
staff: 'Staff',
ticket: 'Ticket',
note: 'Note',
quotation: 'Quotation',
firmware: 'Firmware',
archetype: 'Archetype',
}
const ACTION_OPTIONS = [
@@ -246,7 +247,7 @@ function LogRow({ entry, isExpanded, onToggle }) {
</td>
{/* Action badge */}
<td className="log-cell">
<td className="log-cell" style={{ whiteSpace: 'nowrap' }}>
<StatusBadge variant={variant}>{label}</StatusBadge>
</td>
@@ -492,9 +493,7 @@ const INITIAL_FILTERS = {
offset: 0,
}
export default function LogViewerPage() {
const { user } = useAuth()
function LogViewerContent() {
const [entries, setEntries] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -592,7 +591,7 @@ export default function LogViewerPage() {
.log-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
table-layout: auto;
}
.log-table thead th {
padding: var(--space-2) var(--space-4);
@@ -668,7 +667,7 @@ export default function LogViewerPage() {
<tr>
<th style={{ width: 148 }}>Timestamp</th>
<th style={{ width: 160 }}>Staff</th>
<th style={{ width: 110 }}>Action</th>
<th style={{ whiteSpace: 'nowrap' }}>Action</th>
<th>Entity</th>
<th style={{ width: 120 }}>ID</th>
<th style={{ width: 40 }} />
@@ -752,3 +751,20 @@ export default function LogViewerPage() {
</>
)
}
export default function LogViewerPage() {
const { hasRole } = useAuth()
if (!hasRole('sysadmin')) {
return (
<div className="page-wrapper">
<PageHeader title="Log Viewer" subtitle="Staff actions and system events across the console" />
<div style={{ padding: 'var(--space-12)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
Access restricted to system administrators.
</div>
</div>
)
}
return <LogViewerContent />
}

View File

@@ -13,6 +13,7 @@ export default defineConfig({
},
server: {
host: '0.0.0.0',
allowedHosts: ['.localhost', 'console.bellsystems.net'],
port: 5174,
hmr: {
clientPort: 8001,

View File

@@ -11,7 +11,7 @@ http {
resolver 127.0.0.11 valid=5s;
set $backend_upstream http://backend:8000;
set $frontend_upstream http://frontend:5174;
set $frontend_upstream http://frontend:80;
location /ota/ {
root /srv;
@@ -54,4 +54,25 @@ http {
proxy_set_header Connection "upgrade";
}
}
# Plain-HTTP-only host for ESP32 melody downloads. Kept separate from the
# console.bellsystems.net server block above so console can stay HTTPS-only
# in NPM with no path-based TLS exceptions. ESP32 devices can't afford the
# RAM for a TLS client, so this host must never be forced to HTTPS upstream.
server {
listen 80;
server_name melodies.bellsystems.net;
resolver 127.0.0.11 valid=5s;
set $backend_upstream http://backend:8000;
location /download/ {
rewrite ^/download/(.*)$ /api/melodies/download/$1 break;
proxy_pass $backend_upstream$uri;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

View File

@@ -0,0 +1,22 @@
# CODING AGENTS: READ THIS FIRST
This is a **handoff bundle** from Claude Design (claude.ai/design).
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
## What you should do — IMPORTANT
**Find the primary design file under `bellsystems-console-template/project/` and read it top to bottom.** Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
## About the design files
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
## Bundle contents
- `bellsystems-console-template/README.md` — this file
- `bellsystems-console-template/project/` — the `Bellsystems Console (Template)` project files (HTML prototypes, assets, components)

View File

@@ -0,0 +1,744 @@
<!doctype html>
<meta charset="utf-8">
<title>Provisioning Wizard — Device · Bellsystems Console</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&amp;family=JetBrains+Mono:wght@400;500;600;700&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="wizard.css">
<style>
/* Page-specific */
.mode-pill{
display:inline-flex;align-items:center;gap:8px;
padding:6px 12px;border-radius:999px;
background:var(--bg-3);border:1px solid var(--line);
font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;
color:var(--text-1);
}
.mode-pill .swatch{width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent)}
.mode-pill.new .swatch{background:var(--violet);box-shadow:0 0 8px var(--violet)}
/* Search box */
.search-wrap{
position:relative;
padding:0;
border-bottom:1px solid var(--line);
}
.search-wrap input{
width:100%;
padding:18px 20px 18px 54px;
background:transparent;border:0;
color:var(--text-0);font-family:var(--sans);font-size:15px;
outline:none;
letter-spacing:-0.005em;
}
.search-wrap input::placeholder{color:var(--text-3)}
.search-wrap .search-icon{
position:absolute;left:20px;top:50%;transform:translateY(-50%);
color:var(--text-3);pointer-events:none;
}
.search-wrap .kbd-hint{
position:absolute;right:20px;top:50%;transform:translateY(-50%);
display:flex;gap:4px;
}
.search-wrap .kbd-hint kbd{
font-family:var(--mono);font-size:10px;padding:2px 6px;border-radius:4px;
background:var(--bg-3);border:1px solid var(--line-strong);color:var(--text-2);
}
.filter-bar{
display:flex;align-items:center;gap:8px;
padding:10px 20px;
border-bottom:1px solid var(--line);
background:rgba(0,0,0,.15);
flex-wrap:wrap;
}
.chip{
display:inline-flex;align-items:center;gap:6px;
padding:4px 10px;border-radius:999px;
background:var(--bg-3);border:1px solid var(--line);
font-family:var(--mono);font-size:10.5px;letter-spacing:.05em;
color:var(--text-2);cursor:pointer;transition:.15s;
text-transform:uppercase;
}
.chip:hover{color:var(--text-0);border-color:var(--line-strong)}
.chip.active{background:rgba(34,224,122,.08);color:var(--accent);border-color:rgba(34,224,122,.3)}
.chip .count{
font-size:9.5px;padding:1px 5px;border-radius:3px;
background:var(--bg-1);color:var(--text-3);
border:1px solid var(--line-strong);
}
.chip.active .count{background:rgba(34,224,122,.15);color:var(--accent);border-color:transparent}
.filter-bar .sort{
margin-left:auto;color:var(--text-3);font-family:var(--mono);font-size:10.5px;
display:flex;align-items:center;gap:6px;letter-spacing:.05em;text-transform:uppercase;
}
/* Device rows */
.device-list{
max-height:540px;overflow:auto;
scrollbar-width:thin;scrollbar-color: var(--line-strong) transparent;
}
.device-list::-webkit-scrollbar{width:8px}
.device-list::-webkit-scrollbar-thumb{background:var(--line-strong);border-radius:4px}
.device-row{
display:grid;
grid-template-columns: 28px minmax(0,1.4fr) minmax(0,1fr) 130px 120px;
align-items:center;
gap:14px;
padding:12px 20px;
border-bottom:1px solid var(--line);
cursor:pointer;
transition:.12s;
position:relative;
}
.device-row:last-child{border-bottom:0}
.device-row:hover{background:rgba(255,255,255,.02)}
.device-row.selected{
background:rgba(34,224,122,.04);
}
.device-row.selected::before{
content:"";position:absolute;left:0;top:0;bottom:0;width:2px;
background:var(--accent);box-shadow:0 0 12px var(--accent);
}
.device-row .check{
width:18px;height:18px;border-radius:50%;
border:1.5px solid var(--line-strong);
background:var(--bg-3);
display:grid;place-items:center;color:transparent;
}
.device-row.selected .check{
background:var(--accent);border-color:transparent;color:var(--accent-ink);
}
.device-row .check svg{width:10px;height:10px}
.device-row .serial-cell{
font-family:var(--mono);font-size:13px;font-weight:500;
color:var(--text-0);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.device-row .serial-cell .meta-sub{
font-size:10.5px;color:var(--text-3);font-weight:400;margin-top:2px;
letter-spacing:.04em;text-transform:uppercase;
}
.device-row .board-cell-x{
display:flex;align-items:center;gap:8px;
font-size:13px;color:var(--text-1);
}
.device-row .board-cell-x .rev{
font-family:var(--mono);font-size:10.5px;color:var(--text-3);
padding:1px 6px;border-radius:4px;background:var(--bg-3);border:1px solid var(--line);
}
.device-row .date{
font-family:var(--mono);font-size:11px;color:var(--text-3);
}
.status-badge{
display:inline-flex;align-items:center;gap:6px;
padding:3px 9px;border-radius:999px;
font-family:var(--mono);font-size:10px;font-weight:600;
letter-spacing:.1em;text-transform:uppercase;
border:1px solid transparent;
justify-self:start;
}
.status-badge .sdot{width:5px;height:5px;border-radius:50%;background:currentColor}
.status-badge.manufactured{background:rgba(90,176,255,.08);color:var(--blue);border-color:rgba(90,176,255,.22)}
.status-badge.flashed{background:rgba(255,179,71,.08);color:var(--warn);border-color:rgba(255,179,71,.22)}
.status-badge.provisioned{background:rgba(34,224,122,.08);color:var(--accent);border-color:rgba(34,224,122,.22)}
/* -------- NEW DEVICE FORM -------- */
.new-wrap{padding:24px 24px 28px;display:flex;flex-direction:column;gap:22px}
.field-label{
font-size:10.5px;letter-spacing:.14em;color:var(--text-3);
text-transform:uppercase;font-weight:600;margin-bottom:10px;
display:flex;align-items:center;gap:8px;
}
.field-label .req{color:var(--accent);font-weight:700;letter-spacing:0}
.board-select-grid{
display:grid;grid-template-columns:repeat(4, minmax(0,1fr));gap:8px;
}
.board-select-grid .bopt{
padding:14px 12px;border:1px solid var(--line);border-radius:10px;
background:rgba(255,255,255,.012);cursor:pointer;
display:flex;flex-direction:column;gap:6px;
transition:.15s;
text-align:left;
}
.board-select-grid .bopt:hover{border-color:var(--line-strong);background:rgba(255,255,255,.03)}
.board-select-grid .bopt.selected{
border-color:rgba(34,224,122,.4);
background:rgba(34,224,122,.05);
box-shadow:inset 0 0 0 1px rgba(34,224,122,.2);
}
.board-select-grid .bopt .name{font-size:13.5px;font-weight:500;color:var(--text-0);letter-spacing:-0.005em}
.board-select-grid .bopt.selected .name{color:var(--accent)}
.board-select-grid .bopt .chip-small{
font-family:var(--mono);font-size:9.5px;color:var(--text-3);
letter-spacing:.05em;text-transform:uppercase;
}
.board-select-grid .bopt.bespoke{
border-style:dashed;
grid-column:span 2;
align-items:flex-start;
}
.board-select-grid .bopt.bespoke .name{color:var(--violet)}
.board-select-grid .bopt.bespoke:hover{border-color:rgba(180,139,255,.4);background:rgba(180,139,255,.04)}
.rev-row{
display:flex;gap:8px;flex-wrap:wrap;
}
.rev-row .rv{
padding:8px 16px;border-radius:8px;border:1px solid var(--line);
background:var(--bg-3);color:var(--text-1);cursor:pointer;
font-family:var(--mono);font-size:12px;font-weight:500;letter-spacing:.04em;
transition:.15s;
}
.rev-row .rv:hover{color:var(--text-0);border-color:var(--line-strong)}
.rev-row .rv.selected{
background:rgba(34,224,122,.08);color:var(--accent);
border-color:rgba(34,224,122,.35);
}
.serial-preview{
padding:18px 20px;
border:1px solid rgba(34,224,122,.22);
border-radius:10px;
background:
radial-gradient(400px 120px at 20% 0%, rgba(34,224,122,.06), transparent 70%),
rgba(34,224,122,.03);
display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:16px;
}
.serial-preview .psym{
width:36px;height:36px;border-radius:8px;
background:rgba(34,224,122,.1);border:1px solid rgba(34,224,122,.3);
display:grid;place-items:center;color:var(--accent);
}
.serial-preview .ptxt .k{
font-size:10.5px;letter-spacing:.14em;color:var(--text-3);
text-transform:uppercase;font-weight:600;
}
.serial-preview .ptxt .v{
font-family:var(--mono);font-size:18px;font-weight:600;
color:var(--text-0);margin-top:4px;letter-spacing:0;
}
.serial-preview .ptxt .v .muted{color:var(--text-3);font-weight:400}
.serial-preview .regen{
display:inline-flex;align-items:center;gap:6px;
padding:6px 10px;border-radius:6px;
background:var(--bg-3);border:1px solid var(--line);
color:var(--text-2);font-family:var(--mono);font-size:11px;cursor:pointer;
text-transform:uppercase;letter-spacing:.05em;
}
.serial-preview .regen:hover{color:var(--text-0);border-color:var(--line-strong)}
.serial-preview .regen svg{width:12px;height:12px}
/* ----- BESPOKE MODAL ----- */
.modal-scrim{
position:fixed;inset:0;background:rgba(0,0,0,.7);
backdrop-filter:blur(6px);
display:none;
align-items:center;justify-content:center;
z-index:80;
padding:40px 20px;
}
.modal-scrim.open{display:flex}
.modal{
width:min(720px,100%);
background:linear-gradient(180deg, var(--bg-1), var(--bg-2) 60%);
border:1px solid var(--line-strong);border-radius:14px;
box-shadow:0 40px 120px -20px rgba(0,0,0,.8);
max-height:86vh;display:flex;flex-direction:column;
overflow:hidden;
}
.modal-head{
padding:16px 22px;border-bottom:1px solid var(--line);
display:flex;align-items:center;justify-content:space-between;
}
.modal-head h3{margin:0;font-size:14px;font-weight:600;letter-spacing:-0.005em;display:flex;align-items:center;gap:10px}
.modal-head h3 .violet-dot{width:6px;height:6px;border-radius:50%;background:var(--violet);box-shadow:0 0 10px var(--violet)}
.modal-head .close{
width:28px;height:28px;border-radius:6px;
background:transparent;border:1px solid var(--line);
color:var(--text-3);cursor:pointer;display:grid;place-items:center;
}
.modal-head .close:hover{color:var(--text-0);border-color:var(--line-strong);background:var(--bg-3)}
.bespoke-list{overflow:auto;max-height:60vh}
.bespoke-item{
display:grid;grid-template-columns: 1fr auto auto;gap:16px;align-items:center;
padding:14px 22px;border-bottom:1px solid var(--line);cursor:pointer;
transition:.12s;
}
.bespoke-item:hover{background:rgba(255,255,255,.025)}
.bespoke-item .bname{font-size:13.5px;color:var(--text-0);font-weight:500}
.bespoke-item .bcode{font-family:var(--mono);font-size:11px;color:var(--text-3);margin-top:2px}
.bespoke-item .bclient{
font-family:var(--mono);font-size:11px;color:var(--text-2);
padding:3px 8px;border-radius:4px;background:var(--bg-3);border:1px solid var(--line);
}
.bespoke-item .bunits{font-family:var(--mono);font-size:10.5px;color:var(--text-3);letter-spacing:.05em;text-transform:uppercase}
/* Empty state for device-list */
.list-empty{
padding:60px 20px;text-align:center;
color:var(--text-3);font-family:var(--mono);font-size:12px;
display:flex;flex-direction:column;align-items:center;gap:12px;
}
.list-empty .bigdot{
width:48px;height:48px;border-radius:50%;
background:var(--bg-3);border:1px solid var(--line);
display:grid;place-items:center;color:var(--text-3);
}
</style>
</head>
<body class="layout-split">
<div class="shell">
<!-- TOP BAR -->
<div class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="#22e07a" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 3h8l4 4v14H6z"></path>
<path d="M14 3v4h4"></path>
<path d="M10 12l-2 3h3l-1 3 3-4h-2l1-2z" fill="#22e07a" stroke="none"></path>
</svg>
</div>
<div class="title">
<h1>Provisioning Wizard</h1>
<p>Flash firmware to a Bell Systems board via WebSerial</p>
</div>
</div>
<div class="top-actions" style="margin-top:2px">
<a class="btn btn-ghost" href="Mode Select.html">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"></path><path d="M12 19l-7-7 7-7"></path></svg>
Change mode
</a>
</div>
</div>
<!-- STEPPER -->
<div class="stepper" role="list">
<div class="step done" role="listitem">
<div class="num">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</div>
<div class="meta"><span class="label">Mode</span><span class="sub" id="step-mode-sub">existing</span></div>
<div class="step-divider"></div>
</div>
<div class="step active" role="listitem">
<div class="num">2</div>
<div class="meta"><span class="label">Device</span><span class="sub" id="step-device-sub">select</span></div>
<div class="step-divider"></div>
</div>
<div class="step" role="listitem">
<div class="num">3</div>
<div class="meta"><span class="label">Flash</span><span class="sub">pending</span></div>
<div class="step-divider"></div>
</div>
<div class="step" role="listitem">
<div class="num">4</div>
<div class="meta"><span class="label">Verify</span><span class="sub">pending</span></div>
<div class="step-divider"></div>
</div>
<div class="step" role="listitem">
<div class="num">5</div>
<div class="meta"><span class="label">Done</span><span class="sub">pending</span></div>
</div>
</div>
<!-- EXISTING DEVICE CARD -->
<section class="card" id="existingCard" aria-label="Pick existing device">
<div class="card-head">
<h2><span class="dot"></span> Select a device from inventory</h2>
<span class="mode-pill"><span class="swatch"></span>Flash existing</span>
</div>
<!-- Search -->
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"></circle>
<path d="M21 21l-4.3-4.3"></path>
</svg>
<input id="searchInput" placeholder="Search by serial · e.g. BSVSPR-26D19J or VSPR-26" autocomplete="off" spellcheck="false">
<div class="kbd-hint"><kbd></kbd><kbd>K</kbd></div>
</div>
<!-- Filter chips -->
<div class="filter-bar">
<span class="chip active" data-filter="all">All <span class="count" id="count-all">0</span></span>
<span class="chip" data-filter="manufactured">Manufactured <span class="count" id="count-manufactured">0</span></span>
<span class="chip" data-filter="flashed">Flashed <span class="count" id="count-flashed">0</span></span>
<span class="chip" data-filter="provisioned">Provisioned <span class="count" id="count-provisioned">0</span></span>
<div class="sort">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h13M3 12h9M3 18h5M17 18l4-4M21 18l-4-4"></path></svg>
Newest first
</div>
</div>
<!-- List -->
<div class="device-list" id="deviceList"></div>
<!-- Action bar -->
<div class="action-bar">
<div class="meta-inline">
<span class="k">SELECTED</span>
<span class="v" id="existingSelLabel" style="font-family:var(--mono)">— none —</span>
<span class="sep">/</span>
<span class="k">TOTAL ELIGIBLE</span><span class="v" id="totalEligible">0</span>
</div>
<div style="display:flex;gap:10px">
<a class="btn btn-ghost" href="Mode Select.html">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"></path><path d="M12 19l-7-7 7-7"></path></svg>
Back
</a>
<a class="btn btn-primary btn-lg" id="existingContinue" href="Flashing.html" style="opacity:.5;pointer-events:none">
Continue to flash
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"></path><path d="M12 5l7 7-7 7"></path></svg>
</a>
</div>
</div>
</section>
<!-- NEW DEVICE CARD -->
<section class="card hide" id="newCard" aria-label="Configure new device">
<div class="card-head">
<h2><span class="dot"></span> Configure new device</h2>
<span class="mode-pill new"><span class="swatch"></span>Deploy new</span>
</div>
<div class="new-wrap">
<!-- Board type -->
<div>
<div class="field-label">Board type <span class="req">*</span></div>
<div class="board-select-grid" id="boardGrid">
<button class="bopt" data-board="vesper"><span class="name">Vesper</span><span class="chip-small">ESP32-S3 · VSPR</span></button>
<button class="bopt" data-board="vesper-plus"><span class="name">Vesper Plus</span><span class="chip-small">ESP32-S3 · VSPP</span></button>
<button class="bopt" data-board="vesper-pro"><span class="name">Vesper Pro</span><span class="chip-small">ESP32-S3 · VSPO</span></button>
<button class="bopt" data-board="agnus-mini"><span class="name">Agnus Mini</span><span class="chip-small">ESP32-C6 · AGNM</span></button>
<button class="bopt" data-board="agnus"><span class="name">Agnus</span><span class="chip-small">ESP32-C6 · AGNU</span></button>
<button class="bopt" data-board="chronos"><span class="name">Chronos</span><span class="chip-small">nRF52 · CHRN</span></button>
<button class="bopt" data-board="chronos-pro"><span class="name">Chronos Pro</span><span class="chip-small">nRF52 · CHRP</span></button>
<button class="bopt bespoke" id="openBespoke">
<span class="name" style="display:flex;align-items:center;gap:8px">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12a8 8 0 1 1-16 0 8 8 0 0 1 16 0z"></path><path d="M12 8v4l3 2"></path></svg>
Select Bespoke…
</span>
<span class="chip-small">1-off client builds · 12 active</span>
</button>
</div>
</div>
<!-- Revision -->
<div>
<div class="field-label">Revision <span class="req">*</span></div>
<div class="rev-row" id="revRow">
<button class="rv" data-rev="1.0">Rev 1.0</button>
<button class="rv selected" data-rev="1.1">Rev 1.1</button>
<button class="rv" data-rev="2.0">Rev 2.0</button>
<button class="rv" data-rev="2.1">Rev 2.1</button>
</div>
</div>
<!-- Serial preview -->
<div class="serial-preview" id="serialPreview">
<div class="psym">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"></path></svg>
</div>
<div class="ptxt">
<div class="k">Next serial number</div>
<div class="v" id="nextSerial"><span class="muted">Pick a board type to generate…</span></div>
</div>
<button class="regen" id="regenBtn" style="opacity:.4;pointer-events:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>
Regenerate
</button>
</div>
</div>
<!-- Action bar -->
<div class="action-bar">
<div class="meta-inline">
<span class="k">BOARD</span><span class="v" id="newBoardLabel" style="font-family:var(--mono)">— none —</span>
<span class="sep">/</span>
<span class="k">REV</span><span class="v" id="newRevLabel" style="font-family:var(--mono)">1.1</span>
</div>
<div style="display:flex;gap:10px">
<a class="btn btn-ghost" href="Mode Select.html">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"></path><path d="M12 19l-7-7 7-7"></path></svg>
Back
</a>
<a class="btn btn-primary btn-lg" id="newContinue" href="Flashing.html" style="opacity:.5;pointer-events:none">
Mint &amp; continue
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"></path><path d="M12 5l7 7-7 7"></path></svg>
</a>
</div>
</div>
</section>
</div>
<!-- Bespoke modal -->
<div class="modal-scrim" id="bespokeModal">
<div class="modal">
<div class="modal-head">
<h3><span class="violet-dot"></span>Bespoke builds</h3>
<button class="close" id="closeBespoke" aria-label="Close">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
</div>
<div class="bespoke-list" id="bespokeList"></div>
</div>
</div>
<script>
/* ---------- DATA ---------- */
const BOARD_CODES = {
'vesper':'VSPR','vesper-plus':'VSPP','vesper-pro':'VSPO',
'agnus-mini':'AGNM','agnus':'AGNU','chronos':'CHRN','chronos-pro':'CHRP',
};
const BOARD_LABELS = {
'vesper':'Vesper','vesper-plus':'Vesper Plus','vesper-pro':'Vesper Pro',
'agnus-mini':'Agnus Mini','agnus':'Agnus','chronos':'Chronos','chronos-pro':'Chronos Pro',
};
// Existing devices (mock)
const DEVICES = [
{s:'BSVSPR-26D19J-STD10R-XSNBBP', board:'Vesper', code:'VSPR', rev:'1.0', status:'manufactured', date:'2026-03-18'},
{s:'BSAGNU-26D18K-STD11R-7KXQMA', board:'Agnus', code:'AGNU', rev:'1.1', status:'flashed', date:'2026-03-17'},
{s:'BSCHRN-26D17L-STD20R-P3ZWNV', board:'Chronos', code:'CHRN', rev:'2.0', status:'provisioned', date:'2026-03-16'},
{s:'BSVSPP-26D17L-STD11R-M9RLXB', board:'Vesper Plus', code:'VSPP', rev:'1.1', status:'manufactured', date:'2026-03-16'},
{s:'BSVSPO-26D15N-STD20R-QK27YH', board:'Vesper Pro', code:'VSPO', rev:'2.0', status:'flashed', date:'2026-03-14'},
{s:'BSAGNM-26D14P-STD10R-L8D4WR', board:'Agnus Mini', code:'AGNM', rev:'1.0', status:'manufactured', date:'2026-03-13'},
{s:'BSVSPR-26D14P-STD21R-HBFV6Z', board:'Vesper', code:'VSPR', rev:'2.1', status:'provisioned', date:'2026-03-13'},
{s:'BSCHRP-26D12R-STD20R-TNW1CK', board:'Chronos Pro', code:'CHRP', rev:'2.0', status:'flashed', date:'2026-03-11'},
{s:'BSVSPR-26D11S-STD11R-R5EMBX', board:'Vesper', code:'VSPR', rev:'1.1', status:'manufactured', date:'2026-03-10'},
{s:'BSAGNU-26D10T-STD11R-V2PGLY', board:'Agnus', code:'AGNU', rev:'1.1', status:'provisioned', date:'2026-03-09'},
{s:'BSVSPP-26D08V-STD20R-D7FH3S', board:'Vesper Plus', code:'VSPP', rev:'2.0', status:'flashed', date:'2026-03-07'},
{s:'BSCHRN-26D06X-STD10R-A1KYUM', board:'Chronos', code:'CHRN', rev:'1.0', status:'manufactured', date:'2026-03-05'},
{s:'BSVSPR-26D05Y-STD10R-J0QVXC', board:'Vesper', code:'VSPR', rev:'1.0', status:'provisioned', date:'2026-03-04'},
{s:'BSAGNM-26D04Z-STD11R-E6ZRTB', board:'Agnus Mini', code:'AGNM', rev:'1.1', status:'flashed', date:'2026-03-03'},
];
const BESPOKE = [
{name:'Orchid Mk II', code:'ORCH-MK2', client:'Kestrel Labs', units:'24 units'},
{name:'Lighthouse Mini', code:'LTHS-MIN', client:'Meridian Harbor Co.',units:'12 units'},
{name:'Drydock Sensor', code:'DRDK-S01', client:'Pelican Freight', units:'48 units'},
{name:'Hummingbird', code:'HMBR-V1', client:'Aviary Research', units:'8 units'},
{name:'Parchment Reader', code:'PRCH-R2', client:'Archive Society', units:'16 units'},
{name:'Tideline Logger', code:'TDLN-L1', client:'Coastal Institute', units:'32 units'},
];
/* ---------- INIT + ROUTING ---------- */
const params = new URLSearchParams(location.search);
const mode = params.get('mode') || localStorage.getItem('wizard.mode') || 'existing';
document.getElementById('step-mode-sub').textContent = mode === 'new' ? 'new' : 'existing';
if (mode === 'new'){
document.getElementById('existingCard').classList.add('hide');
document.getElementById('newCard').classList.remove('hide');
}
/* ---------- EXISTING DEVICE LIST ---------- */
const listEl = document.getElementById('deviceList');
let activeFilter = 'all';
let selectedSerial = null;
let query = '';
function renderList(){
const q = query.toLowerCase();
const filtered = DEVICES.filter(d => {
if (activeFilter !== 'all' && d.status !== activeFilter) return false;
if (q && !d.s.toLowerCase().includes(q) && !d.board.toLowerCase().includes(q)) return false;
return true;
});
// counts
document.getElementById('count-all').textContent = DEVICES.length;
['manufactured','flashed','provisioned'].forEach(s => {
document.getElementById('count-'+s).textContent = DEVICES.filter(d => d.status === s).length;
});
document.getElementById('totalEligible').textContent = filtered.length + ' / ' + DEVICES.length;
if (!filtered.length){
listEl.innerHTML = `
<div class="list-empty">
<div class="bigdot">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path></svg>
</div>
<div>No devices match the current filter.</div>
</div>`;
return;
}
listEl.innerHTML = filtered.map(d => `
<div class="device-row ${d.s === selectedSerial ? 'selected' : ''}" data-serial="${d.s}">
<div class="check">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</div>
<div class="serial-cell">
${d.s}
<div class="meta-sub">added ${d.date}</div>
</div>
<div class="board-cell-x">
${d.board}
<span class="rev">Rev ${d.rev}</span>
</div>
<div class="date">${d.date}</div>
<span class="status-badge ${d.status}"><span class="sdot"></span>${d.status}</span>
</div>
`).join('');
}
listEl.addEventListener('click', e => {
const row = e.target.closest('.device-row');
if (!row) return;
selectedSerial = row.dataset.serial;
document.getElementById('existingSelLabel').textContent = selectedSerial;
const btn = document.getElementById('existingContinue');
btn.style.opacity = '1'; btn.style.pointerEvents = 'auto';
btn.href = 'Flashing.html?serial=' + encodeURIComponent(selectedSerial);
document.getElementById('step-device-sub').textContent = selectedSerial.split('-')[0].replace('BS','').toLowerCase();
renderList();
});
document.querySelectorAll('.chip[data-filter]').forEach(c => {
c.addEventListener('click', () => {
document.querySelectorAll('.chip[data-filter]').forEach(x => x.classList.remove('active'));
c.classList.add('active');
activeFilter = c.dataset.filter;
renderList();
});
});
document.getElementById('searchInput').addEventListener('input', e => {
query = e.target.value;
renderList();
});
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k'){
e.preventDefault();
document.getElementById('searchInput').focus();
}
});
renderList();
/* ---------- NEW DEVICE FORM ---------- */
let selBoard = null;
let selRev = '1.1';
function randSeg(len){
const chars='0123456789ABCDEFGHJKLMNPQRSTUVWXYZ';
let s=''; for (let i=0;i<len;i++) s += chars[Math.floor(Math.random()*chars.length)];
return s;
}
function mintSerial(boardKey, rev){
if (!boardKey) return null;
const code = BOARD_CODES[boardKey];
if (!code) return null;
const revCode = 'STD' + rev.replace('.','') + 'R';
return `BS${code}-26D${randSeg(2)}${randSeg(1)}-${revCode}-${randSeg(6)}`;
}
function renderSerial(){
const el = document.getElementById('nextSerial');
const btn = document.getElementById('newContinue');
const regen = document.getElementById('regenBtn');
if (!selBoard){
el.innerHTML = '<span class="muted">Pick a board type to generate…</span>';
btn.style.opacity = '.5'; btn.style.pointerEvents = 'none';
regen.style.opacity = '.4'; regen.style.pointerEvents = 'none';
return;
}
const s = mintSerial(selBoard, selRev);
el.textContent = s;
btn.style.opacity = '1'; btn.style.pointerEvents = 'auto';
btn.href = 'Flashing.html?serial=' + encodeURIComponent(s) + '&new=1';
regen.style.opacity = '1'; regen.style.pointerEvents = 'auto';
}
document.getElementById('boardGrid').addEventListener('click', e => {
const b = e.target.closest('.bopt');
if (!b || b.id === 'openBespoke') return;
document.querySelectorAll('#boardGrid .bopt').forEach(x => x.classList.remove('selected'));
b.classList.add('selected');
selBoard = b.dataset.board;
document.getElementById('newBoardLabel').textContent = BOARD_LABELS[selBoard] || selBoard;
document.getElementById('step-device-sub').textContent = BOARD_CODES[selBoard].toLowerCase();
renderSerial();
});
document.getElementById('revRow').addEventListener('click', e => {
const r = e.target.closest('.rv');
if (!r) return;
document.querySelectorAll('#revRow .rv').forEach(x => x.classList.remove('selected'));
r.classList.add('selected');
selRev = r.dataset.rev;
document.getElementById('newRevLabel').textContent = selRev;
renderSerial();
});
document.getElementById('regenBtn').addEventListener('click', renderSerial);
/* ---------- BESPOKE MODAL ---------- */
const scrim = document.getElementById('bespokeModal');
const bespokeList = document.getElementById('bespokeList');
bespokeList.innerHTML = BESPOKE.map(b => `
<div class="bespoke-item" data-code="${b.code}" data-name="${b.name}">
<div>
<div class="bname">${b.name}</div>
<div class="bcode">${b.code}</div>
</div>
<span class="bclient">${b.client}</span>
<span class="bunits">${b.units}</span>
</div>
`).join('');
document.getElementById('openBespoke').addEventListener('click', () => scrim.classList.add('open'));
document.getElementById('closeBespoke').addEventListener('click', () => scrim.classList.remove('open'));
scrim.addEventListener('click', e => { if (e.target === scrim) scrim.classList.remove('open'); });
bespokeList.addEventListener('click', e => {
const it = e.target.closest('.bespoke-item');
if (!it) return;
// Add a "bespoke" option dynamically
const grid = document.getElementById('boardGrid');
grid.querySelectorAll('.bopt').forEach(x => x.classList.remove('selected'));
// Inject (or update) a selected-bespoke card
let el = grid.querySelector('.bopt.bespoke-selected');
if (!el){
el = document.createElement('button');
el.className = 'bopt bespoke-selected selected';
el.innerHTML = `<span class="name" style="color:var(--violet)">${it.dataset.name}</span><span class="chip-small">Bespoke · ${it.dataset.code}</span>`;
grid.appendChild(el);
} else {
el.classList.add('selected');
el.innerHTML = `<span class="name" style="color:var(--violet)">${it.dataset.name}</span><span class="chip-small">Bespoke · ${it.dataset.code}</span>`;
}
// Use the bespoke code as-is (first 4 chars) for serial
BOARD_CODES['bespoke'] = it.dataset.code.split('-')[0].slice(0,4).toUpperCase();
BOARD_LABELS['bespoke'] = it.dataset.name + ' (bespoke)';
el.dataset.board = 'bespoke';
selBoard = 'bespoke';
document.getElementById('newBoardLabel').textContent = it.dataset.name;
document.getElementById('step-device-sub').textContent = 'bespoke';
scrim.classList.remove('open');
renderSerial();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && scrim.classList.contains('open')) scrim.classList.remove('open');
});
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,362 @@
<!doctype html>
<meta charset="utf-8">
<title>Provisioning Wizard — Mode · Bellsystems Console</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&amp;family=JetBrains+Mono:wght@400;500;600;700&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="wizard.css">
<style>
/* Page-specific: Mode select */
.mode-wrap{
max-width:980px;margin:0 auto;
display:flex;flex-direction:column;gap:28px;
}
.mode-head{
text-align:center;margin-top:12px;
}
.mode-head .eyebrow{
font-family:var(--mono);font-size:10.5px;letter-spacing:.14em;
color:var(--text-3);text-transform:uppercase;
}
.mode-head h2{
margin:10px 0 6px;font-size:28px;font-weight:600;letter-spacing:-0.01em;
}
.mode-head p{
margin:0;color:var(--text-2);font-size:14px;
}
.mode-grid{
display:grid;grid-template-columns:1fr 1fr;gap:16px;
}
.mode-card{
position:relative;
background:linear-gradient(180deg, var(--bg-1), var(--bg-2) 60%);
border:1px solid var(--line);
border-radius:var(--radius);
padding:28px 26px 24px;
cursor:pointer;
transition:.18s;
overflow:hidden;
display:flex;flex-direction:column;gap:20px;
min-height:340px;
text-align:left;
}
.mode-card::before{
content:"";position:absolute;inset:0 0 auto 0;height:1px;
background:linear-gradient(90deg, transparent, rgba(255,255,255,.06), transparent);
}
.mode-card::after{
content:"";position:absolute;inset:auto 0 0 0;height:2px;
background:transparent;transition:.18s;
}
.mode-card:hover{
border-color:var(--line-strong);
transform:translateY(-2px);
box-shadow:0 20px 50px -20px rgba(0,0,0,.6);
}
.mode-card.selected{
border-color:rgba(34,224,122,.35);
background:linear-gradient(180deg, rgba(34,224,122,.04), var(--bg-2) 60%);
}
.mode-card.selected::after{background:var(--accent);box-shadow:0 0 18px rgba(34,224,122,.5)}
.mode-card .icon-wrap{
width:52px;height:52px;border-radius:12px;
display:grid;place-items:center;
background:var(--bg-3);border:1px solid var(--line-strong);
color:var(--text-1);
}
.mode-card.selected .icon-wrap{
background:rgba(34,224,122,.1);
border-color:rgba(34,224,122,.35);
color:var(--accent);
}
.mode-card .icon-wrap svg{width:26px;height:26px}
.mode-card h3{
margin:0;font-size:20px;font-weight:600;letter-spacing:-0.005em;
}
.mode-card .desc{
margin:0;color:var(--text-2);font-size:13.5px;line-height:1.55;
}
.mode-card .points{
display:flex;flex-direction:column;gap:8px;
padding-top:16px;margin-top:auto;
border-top:1px solid var(--line);
}
.mode-card .points .pt{
display:flex;align-items:center;gap:10px;
font-size:12.5px;color:var(--text-2);
font-family:var(--mono);
}
.mode-card .points .pt svg{width:13px;height:13px;color:var(--text-3);flex-shrink:0}
.mode-card.selected .points .pt svg{color:var(--accent)}
.mode-card .radio{
position:absolute;top:20px;right:20px;
width:22px;height:22px;border-radius:50%;
border:1.5px solid var(--line-strong);
background:var(--bg-3);
display:grid;place-items:center;
transition:.18s;
}
.mode-card .radio::after{
content:"";width:10px;height:10px;border-radius:50%;
background:transparent;transition:.18s;
}
.mode-card.selected .radio{border-color:var(--accent);background:rgba(34,224,122,.1)}
.mode-card.selected .radio::after{background:var(--accent);box-shadow:0 0 10px var(--accent)}
.mode-card .badge-count{
display:inline-flex;align-items:center;gap:6px;
font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;
padding:4px 8px;border-radius:999px;
background:var(--bg-3);border:1px solid var(--line);
color:var(--text-2);text-transform:uppercase;
width:fit-content;
}
.mode-card.selected .badge-count{
background:rgba(34,224,122,.08);color:var(--accent);
border-color:rgba(34,224,122,.25);
}
.mode-footer{
display:flex;align-items:center;justify-content:space-between;
padding:14px 4px 0;
}
.mode-footer .hint{
font-family:var(--mono);font-size:11px;color:var(--text-3);
letter-spacing:.04em;
}
.mode-footer .hint kbd{
font-family:var(--mono);font-size:10px;padding:2px 6px;border-radius:4px;
background:var(--bg-3);border:1px solid var(--line-strong);color:var(--text-1);
margin:0 2px;
}
</style>
</head>
<body class="layout-split">
<div class="shell">
<!-- TOP BAR -->
<div class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="#22e07a" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 3h8l4 4v14H6z"></path>
<path d="M14 3v4h4"></path>
<path d="M10 12l-2 3h3l-1 3 3-4h-2l1-2z" fill="#22e07a" stroke="none"></path>
</svg>
</div>
<div class="title">
<h1>Provisioning Wizard</h1>
<p>Flash firmware to a Bell Systems board via WebSerial</p>
</div>
</div>
<div class="top-actions" style="margin-top:2px">
<button class="btn btn-ghost">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"></path><path d="M12 19l-7-7 7-7"></path></svg>
Back to Inventory
</button>
</div>
</div>
<!-- STEPPER -->
<div class="stepper" role="list">
<div class="step active" role="listitem">
<div class="num">1</div>
<div class="meta"><span class="label">Mode</span><span class="sub" id="step-mode-sub">choose</span></div>
<div class="step-divider"></div>
</div>
<div class="step" role="listitem">
<div class="num">2</div>
<div class="meta"><span class="label">Device</span><span class="sub">pending</span></div>
<div class="step-divider"></div>
</div>
<div class="step" role="listitem">
<div class="num">3</div>
<div class="meta"><span class="label">Flash</span><span class="sub">pending</span></div>
<div class="step-divider"></div>
</div>
<div class="step" role="listitem">
<div class="num">4</div>
<div class="meta"><span class="label">Verify</span><span class="sub">pending</span></div>
<div class="step-divider"></div>
</div>
<div class="step" role="listitem">
<div class="num">5</div>
<div class="meta"><span class="label">Done</span><span class="sub">pending</span></div>
</div>
</div>
<!-- MAIN -->
<section class="card" aria-label="Select provisioning mode">
<div class="card-head">
<h2><span class="dot"></span> Select Mode</h2>
<div class="meta-inline" style="padding:0">
<span class="k">Step</span><span class="v">1 / 5</span>
</div>
</div>
<div style="padding:36px 28px">
<div class="mode-wrap">
<div class="mode-head">
<div class="eyebrow">Provisioning Wizard</div>
<h2>What are we doing today?</h2>
<p>Choose whether to flash a board that already exists in inventory, or generate a brand-new device record.</p>
</div>
<div class="mode-grid">
<!-- OPTION 1 -->
<div class="mode-card selected" data-mode="existing" tabindex="0" role="button" aria-pressed="true">
<span class="radio" aria-hidden="true"></span>
<div class="icon-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"></circle>
<path d="M21 21l-4.3-4.3"></path>
</svg>
</div>
<div>
<h3>Flash Existing Device</h3>
<p class="desc" style="margin-top:6px">Re-flash or finish provisioning a board that is already on record. Search by serial, or pick from boards not yet shipped.</p>
</div>
<span class="badge-count">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
<span id="existingCount">247 eligible</span>
</span>
<div class="points">
<div class="pt">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
Status: Manufactured, Flashed, or Provisioned
</div>
<div class="pt">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
Preserves existing serial &amp; inventory record
</div>
<div class="pt">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
Ideal for rework or returned units
</div>
</div>
</div>
<!-- OPTION 2 -->
<div class="mode-card" data-mode="new" tabindex="0" role="button" aria-pressed="false">
<span class="radio" aria-hidden="true"></span>
<div class="icon-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="3"></rect>
<path d="M12 8v8M8 12h8"></path>
</svg>
</div>
<div>
<h3>Deploy New Device</h3>
<p class="desc" style="margin-top:6px">Pick a hardware type &amp; revision. We'll mint a new serial, create the inventory record, and proceed to flash it.</p>
</div>
<span class="badge-count">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"></path></svg>
7 board types · + bespoke
</span>
<div class="points">
<div class="pt">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
Auto-generates next sequential serial
</div>
<div class="pt">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
Creates inventory record at status <span class="tok-em" style="color:var(--text-1)">Manufactured</span>
</div>
<div class="pt">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
Ideal for fresh production runs
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Action bar -->
<div class="action-bar">
<div class="meta-inline">
<span class="k">MODE</span><span class="v" id="chosenLabel">Flash Existing Device</span>
<span class="sep">/</span>
<span class="k">NEXT</span><span class="v">Device selection</span>
</div>
<div style="display:flex;gap:10px">
<button class="btn btn-ghost" disabled style="opacity:.45;cursor:not-allowed">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"></path><path d="M12 19l-7-7 7-7"></path></svg>
Back
</button>
<a class="btn btn-primary btn-lg" id="continueBtn" href="Device Select.html?mode=existing">
Continue
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"></path><path d="M12 5l7 7-7 7"></path></svg>
<span class="kbd"></span>
</a>
</div>
</div>
</section>
</div>
<script>
const cards = document.querySelectorAll('.mode-card');
const label = document.getElementById('chosenLabel');
const subLabel = document.getElementById('step-mode-sub');
const continueBtn = document.getElementById('continueBtn');
function select(mode){
cards.forEach(c => {
const is = c.dataset.mode === mode;
c.classList.toggle('selected', is);
c.setAttribute('aria-pressed', is ? 'true' : 'false');
});
if (mode === 'existing'){
label.textContent = 'Flash Existing Device';
subLabel.textContent = 'existing';
continueBtn.href = 'Device Select.html?mode=existing';
} else {
label.textContent = 'Deploy New Device';
subLabel.textContent = 'new';
continueBtn.href = 'Device Select.html?mode=new';
}
localStorage.setItem('wizard.mode', mode);
}
cards.forEach(c => {
c.addEventListener('click', () => select(c.dataset.mode));
c.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); select(c.dataset.mode); }
});
});
// Restore
const saved = localStorage.getItem('wizard.mode');
if (saved) select(saved);
// Enter to continue
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.target.matches('input,textarea')){
continueBtn.click();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,564 @@
<!doctype html>
<meta charset="utf-8">
<title>Provisioning Wizard — Verify · Bellsystems Console</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&amp;family=JetBrains+Mono:wght@400;500;600;700&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="wizard.css">
<style>
/* Page-specific: Verify */
.verify-main{
display:grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
gap:20px;
}
/* Left (serial logs) reuses console styles but a few local tweaks */
.serial-console-card{
display:flex;flex-direction:column;min-height:0;
background:linear-gradient(180deg, #080b0e, #05070a);
}
.serial-filter-bar{
display:flex;align-items:center;gap:6px;
padding:8px 14px;
border-bottom:1px solid var(--line);
background:rgba(0,0,0,.25);
flex-wrap:wrap;
}
.serial-filter-bar .lvl{
display:inline-flex;align-items:center;gap:6px;
padding:4px 9px;border-radius:6px;
font-family:var(--mono);font-size:10px;letter-spacing:.08em;
background:var(--bg-3);border:1px solid var(--line);
cursor:pointer;transition:.12s;text-transform:uppercase;
color:var(--text-3);
}
.serial-filter-bar .lvl:hover{color:var(--text-1);border-color:var(--line-strong)}
.serial-filter-bar .lvl.on{color:var(--text-0);background:var(--bg-2);border-color:var(--line-strong)}
.serial-filter-bar .lvl .dt{width:6px;height:6px;border-radius:50%;background:currentColor}
.serial-filter-bar .lvl.info .dt{color:#6aa9ff}
.serial-filter-bar .lvl.warn .dt{color:var(--warn)}
.serial-filter-bar .lvl.err .dt{color:var(--danger)}
.serial-filter-bar .lvl.dbg .dt{color:#8a97a4}
.serial-filter-bar .lvl.vrb .dt{color:#b48bff}
.serial-filter-bar .lvl.ok .dt{color:var(--accent)}
.serial-filter-bar .spacer{flex:1}
.serial-filter-bar .auto{
display:inline-flex;align-items:center;gap:8px;
color:var(--text-3);font-family:var(--mono);font-size:10.5px;
letter-spacing:.05em;text-transform:uppercase;
padding:4px 10px;border-radius:6px;
border:1px solid var(--line);background:var(--bg-3);cursor:pointer;
}
.serial-filter-bar .auto.on{color:var(--accent);border-color:rgba(34,224,122,.3);background:rgba(34,224,122,.06)}
/* Right panel */
.verify-card{
display:flex;flex-direction:column;
background:linear-gradient(180deg, var(--bg-1), var(--bg-2) 60%);
}
.verify-body{padding:20px 22px;display:flex;flex-direction:column;gap:20px}
.sig-panel{
padding:18px 20px;border:1px solid var(--line);border-radius:10px;
background:rgba(255,255,255,.012);
display:grid;grid-template-columns: 48px 1fr auto;align-items:center;gap:16px;
position:relative;overflow:hidden;
}
.sig-panel.online{
border-color:rgba(34,224,122,.32);
background:
radial-gradient(400px 120px at 20% 0%, rgba(34,224,122,.07), transparent 70%),
rgba(34,224,122,.03);
}
.sig-panel .halo{
width:48px;height:48px;border-radius:50%;
background:var(--bg-3);border:1px solid var(--line);
display:grid;place-items:center;color:var(--text-3);
position:relative;
}
.sig-panel.online .halo{
background:rgba(34,224,122,.08);border-color:rgba(34,224,122,.35);color:var(--accent);
}
.sig-panel.online .halo::before,
.sig-panel.online .halo::after{
content:"";position:absolute;inset:-4px;border-radius:50%;
border:1px solid rgba(34,224,122,.4);
animation:radar 2.2s infinite ease-out;
}
.sig-panel.online .halo::after{animation-delay:1.1s}
@keyframes radar{
0%{transform:scale(.6);opacity:.9}
100%{transform:scale(1.6);opacity:0}
}
.sig-panel .halo svg{width:22px;height:22px}
.sig-panel .meta .k{
font-size:10.5px;letter-spacing:.14em;color:var(--text-3);
text-transform:uppercase;font-weight:600;
}
.sig-panel .meta .v{
font-family:var(--mono);font-size:15px;color:var(--text-0);
margin-top:4px;font-weight:500;
}
.sig-panel.online .meta .v{color:var(--accent)}
.sig-panel .meta .sub{
font-family:var(--mono);font-size:11px;color:var(--text-3);margin-top:3px;
}
.sig-panel .status-pill{
display:inline-flex;align-items:center;gap:8px;
padding:6px 12px;border-radius:999px;
font-family:var(--mono);font-size:10.5px;font-weight:600;
letter-spacing:.1em;text-transform:uppercase;
background:var(--bg-3);color:var(--text-2);border:1px solid var(--line);
}
.sig-panel .status-pill .dt{width:6px;height:6px;border-radius:50%;background:var(--text-3);animation:pulse 1.5s infinite}
.sig-panel.online .status-pill{
background:rgba(34,224,122,.1);color:var(--accent);border-color:rgba(34,224,122,.3);
}
.sig-panel.online .status-pill .dt{background:var(--accent);box-shadow:0 0 10px var(--accent)}
/* Checklist */
.check-list{display:flex;flex-direction:column;gap:8px}
.chk{
display:grid;grid-template-columns: 28px 1fr auto;align-items:center;gap:12px;
padding:12px 14px;border:1px solid var(--line);border-radius:10px;
background:rgba(255,255,255,.012);
}
.chk .icon{
width:24px;height:24px;border-radius:50%;
border:1.5px solid var(--line-strong);background:var(--bg-3);
display:grid;place-items:center;color:var(--text-3);
}
.chk.pass{border-color:rgba(34,224,122,.22);background:rgba(34,224,122,.04)}
.chk.pass .icon{background:var(--accent);border-color:transparent;color:var(--accent-ink)}
.chk.pend .icon{border-style:dashed}
.chk.pend.active .icon{
border-color:var(--warn);
background:rgba(255,179,71,.1);
color:var(--warn);
animation:pulse 1.6s infinite;
}
.chk .label{font-size:13px;color:var(--text-0);font-weight:500}
.chk .sub{font-size:11px;color:var(--text-3);font-family:var(--mono);margin-top:2px;letter-spacing:.03em}
.chk.pend .label{color:var(--text-2)}
.chk .time{
font-family:var(--mono);font-size:10.5px;color:var(--text-3);
letter-spacing:.05em;
}
.chk.pass .time{color:var(--accent)}
.mqtt-details{
padding:12px 14px;border:1px solid var(--line);border-radius:10px;
background:var(--bg-2);
font-family:var(--mono);font-size:11.5px;line-height:1.7;
color:var(--text-2);
}
.mqtt-details .row{display:flex;justify-content:space-between;gap:16px}
.mqtt-details .row .k{color:var(--text-3);text-transform:uppercase;letter-spacing:.08em;font-size:10px}
.mqtt-details .row .v{color:var(--text-0);font-weight:500}
.mqtt-details .row .v.accent{color:var(--accent)}
.spinner{
width:14px;height:14px;border-radius:50%;
border:2px solid rgba(255,179,71,.2);
border-top-color:var(--warn);
animation:spin 0.8s linear infinite;
}
@keyframes spin{to{transform:rotate(360deg)}}
@media (max-width:1100px){
.verify-main{grid-template-columns:1fr}
}
</style>
</head>
<body class="layout-split">
<div class="shell">
<!-- TOP BAR -->
<div class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="#22e07a" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 3h8l4 4v14H6z"></path>
<path d="M14 3v4h4"></path>
<path d="M10 12l-2 3h3l-1 3 3-4h-2l1-2z" fill="#22e07a" stroke="none"></path>
</svg>
</div>
<div class="title">
<h1>Provisioning Wizard</h1>
<p>Verify the device is online and provisioned</p>
</div>
</div>
<div class="top-actions" style="margin-top:2px">
<a class="btn btn-ghost" href="Flashing.html">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"></path><path d="M12 19l-7-7 7-7"></path></svg>
Back to flash
</a>
</div>
</div>
<!-- STEPPER -->
<div class="stepper" role="list">
<div class="step done" role="listitem">
<div class="num"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></div>
<div class="meta"><span class="label">Mode</span><span class="sub">Provision</span></div>
<div class="step-divider"></div>
</div>
<div class="step done" role="listitem">
<div class="num"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></div>
<div class="meta"><span class="label">Device</span><span class="sub">BSVSPR-26D19J</span></div>
<div class="step-divider"></div>
</div>
<div class="step done" role="listitem">
<div class="num"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></div>
<div class="meta"><span class="label">Flash</span><span class="sub">complete</span></div>
<div class="step-divider"></div>
</div>
<div class="step active" role="listitem">
<div class="num">4</div>
<div class="meta"><span class="label">Verify</span><span class="sub" id="verifySub">listening…</span></div>
<div class="step-divider"></div>
</div>
<div class="step" role="listitem">
<div class="num">5</div>
<div class="meta"><span class="label">Done</span><span class="sub">pending</span></div>
</div>
</div>
<!-- MAIN (60 / 40) -->
<div class="verify-main">
<!-- LEFT: Live Serial Logs -->
<section class="card serial-console-card" aria-label="Live serial logs">
<div class="console-head">
<div class="tabs">
<button class="active">Serial</button>
<button>Timeline</button>
<button>Raw</button>
</div>
<div class="tools">
<button title="Pause/Resume" id="pauseBtn" aria-label="Pause">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>
</button>
<button title="Clear" id="clearBtn" aria-label="Clear">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14"></path></svg>
</button>
<button title="Download logs" aria-label="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
</button>
</div>
</div>
<!-- Filter bar -->
<div class="serial-filter-bar">
<span class="lvl info on" data-lvl="info"><span class="dt"></span>Info</span>
<span class="lvl ok on" data-lvl="ok"><span class="dt"></span>Ok</span>
<span class="lvl warn on" data-lvl="warn"><span class="dt"></span>Warn</span>
<span class="lvl err on" data-lvl="err"><span class="dt"></span>Error</span>
<span class="lvl dbg" data-lvl="dbg"><span class="dt"></span>Debug</span>
<span class="lvl vrb" data-lvl="vrb"><span class="dt"></span>Verbose</span>
<div class="spacer"></div>
<span class="auto on" id="autoscroll">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"></path><path d="M19 12l-7 7-7-7"></path></svg>
Auto-scroll
</span>
</div>
<div class="console-body" id="logBody"></div>
<div class="console-foot">
<span><strong>/dev/cu.usbserial-0142A91B</strong> · 115200 8N1</span>
<div class="stats">
<span>RX <strong id="rxCount">0</strong> lines</span>
<span>Uptime <strong id="uptime">00:00</strong></span>
<span><span id="statusText">streaming…</span></span>
</div>
</div>
</section>
<!-- RIGHT: Verification -->
<section class="card verify-card" aria-label="Device verification">
<div class="card-head">
<h2><span class="dot"></span> Device Verification</h2>
<span class="meta-inline" style="padding:0"><span class="k">STEP</span><span class="v">4 / 5</span></span>
</div>
<div class="verify-body">
<!-- Signal panel (MQTT presence) -->
<div class="sig-panel" id="sigPanel">
<div class="halo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12a15 15 0 0 1 20 0"></path>
<path d="M5 15.5a10 10 0 0 1 14 0"></path>
<path d="M8.5 18.5a5 5 0 0 1 7 0"></path>
<circle cx="12" cy="21" r="1" fill="currentColor"></circle>
</svg>
</div>
<div class="meta">
<div class="k">MQTT Presence</div>
<div class="v" id="sigLine">Waiting for device on mqtt.bellsystems.io…</div>
<div class="sub" id="sigSub">client_id = BSVSPR-26D19J-STD10R-XSNBBP</div>
</div>
<span class="status-pill" id="sigStatus">
<span class="dt"></span>
<span id="sigStatusText">Awaiting</span>
</span>
</div>
<!-- Checklist -->
<div class="check-list" id="checkList">
<div class="chk pend" data-k="mqtt">
<div class="icon">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"></circle></svg>
</div>
<div>
<div class="label">Device connects to broker</div>
<div class="sub">mqtt.bellsystems.io:8883 · TLS</div>
</div>
<span class="time" id="t-mqtt"></span>
</div>
<div class="chk pend" data-k="online">
<div class="icon">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"></circle></svg>
</div>
<div>
<div class="label">Publishes online message</div>
<div class="sub">$bell/&lt;serial&gt;/status</div>
</div>
<span class="time" id="t-online"></span>
</div>
<div class="chk pend" data-k="prov">
<div class="icon">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"></circle></svg>
</div>
<div>
<div class="label">Inventory marked Provisioned</div>
<div class="sub">api.bellsystems.io · PATCH /devices/&lt;id&gt;</div>
</div>
<span class="time" id="t-prov"></span>
</div>
</div>
<!-- MQTT details -->
<div class="mqtt-details">
<div class="row"><span class="k">Broker</span><span class="v">mqtt.bellsystems.io:8883</span></div>
<div class="row"><span class="k">Client ID</span><span class="v">BSVSPR-26D19J-…XSNBBP</span></div>
<div class="row"><span class="k">Last seen</span><span class="v" id="lastSeen"></span></div>
<div class="row"><span class="k">FW version</span><span class="v accent">v15.3.2</span></div>
<div class="row"><span class="k">RSSI</span><span class="v" id="rssi"></span></div>
</div>
</div>
<!-- Action bar -->
<div class="action-bar">
<div class="meta-inline">
<span class="k">STATE</span><span class="v" id="stateLabel">awaiting</span>
<span class="sep">/</span>
<span class="k">ELAPSED</span><span class="v" id="elapsed" style="font-variant-numeric:tabular-nums">0.0s</span>
</div>
<div style="display:flex;gap:10px">
<a class="btn btn-ghost" href="Flashing.html">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"></path><path d="M12 19l-7-7 7-7"></path></svg>
Back
</a>
<button class="btn btn-primary btn-lg" id="continueBtn" disabled style="opacity:.45;cursor:not-allowed">
Continue
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"></path><path d="M12 5l7 7-7 7"></path></svg>
</button>
</div>
</div>
</section>
</div>
</div>
<script>
/* -------- Log stream -------- */
const SCRIPT = [
['info','[boot]','ESP-IDF v5.1.2 · rst_reason=POWERON_RESET'],
['dbg','[heap]','free=289212 largest_free=131072 min_free=288456'],
['info','[main]','Starting Bell firmware v15.3.2 (build 4e29a1)'],
['info','[nvs]','Opened namespace "factory" · 12 keys'],
['dbg','[cfg]','Loaded model="vesper" rev="1.0" hw="ESP32-S3"'],
['info','[wifi]','Connecting to SSID "bell-provision"…'],
['dbg','[wifi]','Auth=WPA2 · channel=6 · bssid=a4:cf:12:xx:xx:xx'],
['info','[wifi]','Got IP 10.24.100.87 · gw=10.24.100.1 · rssi=-54'],
['ok','[wifi]','Associated · dhcp lease 24h'],
['info','[tls]','Loading device cert from partition "cert_store"'],
['dbg','[tls]','Cert CN=BSVSPR-26D19J-STD10R-XSNBBP · issuer=Bell-Root-R1'],
['info','[mqtt]','Connecting mqtt.bellsystems.io:8883…'],
['vrb','[tcp]','SYN sent · 35.209.44.12:8883'],
['vrb','[tls]','Handshake: ECDHE-ECDSA-AES128-GCM-SHA256'],
['ok','[mqtt]','CONNACK rc=0 · sessionPresent=false','__MARK_MQTT'],
['info','[mqtt]','Subscribed $bell/+/cmd (QoS 1)'],
['info','[pub]','PUBLISH $bell/<serial>/status {"state":"online","fw":"v15.3.2"}','__MARK_ONLINE'],
['ok','[api]','POST /devices/<serial>/heartbeat → 200 OK'],
['info','[api]','PATCH /devices/<serial> status=provisioned','__MARK_PROV'],
['ok','[api]','Inventory: status ← provisioned · roleToken issued'],
['dbg','[loop]','tick t=12.4s · rssi=-52 · heap=271340'],
['info','[state]','Device ready · awaiting commissioning'],
];
const body = document.getElementById('logBody');
const filters = new Set(['info','ok','warn','err']); // default on
let paused = false;
let autoScroll = true;
let rx = 0;
const startTime = Date.now();
function fmtTime(ms){
const t = (ms/1000);
const mm = String(Math.floor(t/60)).padStart(2,'0');
const ss = (t%60).toFixed(1).padStart(4,'0');
return `${mm}:${ss}`;
}
function colorize(msg){
return msg
.replace(/(\b0x[0-9a-f]+\b)/gi, '<span class="tok-hex">$1</span>')
.replace(/(\b\d+\.\d+\.\d+\.\d+\b)/g, '<span class="tok-num">$1</span>')
.replace(/(\b\d{2,}\b)/g, '<span class="tok-num">$1</span>')
.replace(/"([^"]+)"/g, '"<span class="tok-em">$1</span>"')
.replace(/\[([a-z]+)\]/g, '<span class="tok-mut">[</span><span class="tok-em">$1</span><span class="tok-mut">]</span>');
}
function addLine(lvl, tag, msg){
if (!filters.has(lvl)) return;
const line = document.createElement('div');
line.className = 'log-line';
line.dataset.lvl = lvl;
const now = new Date();
const ts = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}.${String(now.getMilliseconds()).padStart(3,'0')}`;
line.innerHTML = `
<span class="log-time">${ts}</span>
<span class="log-level ${lvl}">${lvl.toUpperCase()}</span>
<span class="log-msg"><span class="tok-mut">${tag}</span> ${colorize(msg)}</span>`;
body.appendChild(line);
rx++;
document.getElementById('rxCount').textContent = rx;
if (autoScroll) body.scrollTop = body.scrollHeight;
}
/* Filter toggles */
document.querySelectorAll('.serial-filter-bar .lvl').forEach(el => {
el.addEventListener('click', () => {
el.classList.toggle('on');
const k = el.dataset.lvl;
if (el.classList.contains('on')) filters.add(k); else filters.delete(k);
// Re-apply to existing lines
body.querySelectorAll('.log-line').forEach(line => {
line.style.display = filters.has(line.dataset.lvl) ? '' : 'none';
});
});
});
/* Autoscroll toggle */
document.getElementById('autoscroll').addEventListener('click', () => {
autoScroll = !autoScroll;
document.getElementById('autoscroll').classList.toggle('on', autoScroll);
});
body.addEventListener('scroll', () => {
// Turn off autoscroll if user scrolls up significantly
const atBottom = body.scrollHeight - body.clientHeight - body.scrollTop < 20;
if (!atBottom && autoScroll){
autoScroll = false;
document.getElementById('autoscroll').classList.remove('on');
}
});
/* Pause / clear */
document.getElementById('pauseBtn').addEventListener('click', () => {
paused = !paused;
document.getElementById('statusText').textContent = paused ? 'paused' : 'streaming…';
});
document.getElementById('clearBtn').addEventListener('click', () => {
body.innerHTML = ''; rx = 0; document.getElementById('rxCount').textContent = 0;
});
/* -------- Verification state machine -------- */
function markChk(k, ts){
const el = document.querySelector(`.chk[data-k="${k}"]`);
if (!el) return;
el.classList.remove('pend','active');
el.classList.add('pass');
el.querySelector('.icon').innerHTML = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
document.getElementById('t-'+k).textContent = ts;
}
function activateChk(k){
const el = document.querySelector(`.chk[data-k="${k}"]`);
if (!el) return;
el.classList.add('active');
el.querySelector('.icon').innerHTML = '<div class="spinner"></div>';
}
activateChk('mqtt');
document.getElementById('stateLabel').textContent = 'listening for broker connect';
/* -------- Play the script -------- */
let idx = 0;
function step(){
if (paused){ setTimeout(step, 300); return; }
if (idx >= SCRIPT.length){
document.getElementById('statusText').textContent = 'idle';
return;
}
const [lvl, tag, msg, mark] = SCRIPT[idx++];
addLine(lvl, tag, msg);
const elapsed = ((Date.now()-startTime)/1000).toFixed(1) + 's';
if (mark === '__MARK_MQTT'){
markChk('mqtt', elapsed);
activateChk('online');
document.getElementById('stateLabel').textContent = 'broker ok · awaiting publish';
// Signal panel flips online
const sig = document.getElementById('sigPanel');
sig.classList.add('online');
document.getElementById('sigLine').textContent = 'Device online on mqtt.bellsystems.io';
document.getElementById('sigStatusText').textContent = 'Online';
document.getElementById('verifySub').textContent = 'online';
document.getElementById('lastSeen').textContent = 'just now';
document.getElementById('rssi').textContent = '-52 dBm';
} else if (mark === '__MARK_ONLINE'){
markChk('online', elapsed);
activateChk('prov');
document.getElementById('stateLabel').textContent = 'updating inventory';
} else if (mark === '__MARK_PROV'){
markChk('prov', elapsed);
document.getElementById('stateLabel').textContent = 'provisioned';
document.getElementById('sigStatusText').textContent = 'Provisioned';
document.getElementById('verifySub').textContent = 'provisioned';
const btn = document.getElementById('continueBtn');
btn.disabled = false;
btn.style.opacity = '1'; btn.style.cursor = 'pointer';
btn.classList.add('ok');
}
setTimeout(step, 220 + Math.random()*320);
}
setTimeout(step, 400);
/* Elapsed & uptime tickers */
setInterval(() => {
const el = Date.now() - startTime;
document.getElementById('elapsed').textContent = (el/1000).toFixed(1) + 's';
document.getElementById('uptime').textContent = fmtTime(el);
}, 100);
/* Continue → Done (not implemented page; go back to Flashing for now) */
document.getElementById('continueBtn').addEventListener('click', () => {
if (document.getElementById('continueBtn').disabled) return;
alert('Verification complete → proceed to final confirmation (not implemented in this prototype).');
});
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

View File

@@ -0,0 +1,555 @@
:root{
--bg-0:#07090b;
--bg-1:#0b0f13;
--bg-2:#10161c;
--bg-3:#151c24;
--line:#1e2730;
--line-strong:#2a3642;
--text-0:#eef3f8;
--text-1:#c8d2dc;
--text-2:#8a97a4;
--text-3:#5a6775;
--accent:#22e07a; /* primary green */
--accent-ink:#062211;
--accent-dim:#0f3a23;
--accent-glow: 0 0 0 1px rgba(34,224,122,.35), 0 0 22px -6px rgba(34,224,122,.6);
--warn:#ffb347;
--danger:#ff5a6a;
--blue:#5ab0ff;
--violet:#b48bff;
--radius:12px;
--radius-sm:8px;
--mono:"JetBrains Mono", ui-monospace, Menlo, monospace;
--sans:"Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
font-family:var(--sans);
color:var(--text-0);
background:
radial-gradient(1200px 600px at 85% -10%, rgba(34,224,122,.06), transparent 60%),
radial-gradient(900px 500px at -10% 110%, rgba(90,176,255,.05), transparent 60%),
var(--bg-0);
min-height:100vh;
-webkit-font-smoothing:antialiased;
font-feature-settings:"ss01","cv11";
letter-spacing:-0.005em;
}
/* Subtle noise */
body::before{
content:"";
position:fixed;inset:0;
pointer-events:none;
opacity:.035;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
mix-blend-mode:overlay;
z-index:0;
}
.shell{position:relative;z-index:1;max-width:1440px;margin:0 auto;padding:28px 40px 48px}
/* -------- Top bar -------- */
.topbar{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:28px}
.brand{display:flex;align-items:center;gap:14px}
.logo{
width:40px;height:40px;border-radius:10px;
background:linear-gradient(135deg,#0c1117,#161e27);
border:1px solid var(--line-strong);
display:grid;place-items:center;
box-shadow: inset 0 0 0 1px rgba(255,255,255,.02);
position:relative;
}
.logo svg{width:22px;height:22px}
.title h1{font-size:20px;font-weight:600;margin:0;letter-spacing:-0.01em}
.title p{margin:2px 0 0;color:var(--text-2);font-size:13px}
.crumbs{display:flex;align-items:center;gap:10px;color:var(--text-2);font-size:12.5px;font-family:var(--mono)}
.crumbs a{color:var(--text-2);text-decoration:none}
.crumbs a:hover{color:var(--text-0)}
.crumbs .sep{color:var(--text-3)}
.top-actions{display:flex;gap:10px;align-items:center}
/* -------- Stepper -------- */
.stepper{
display:grid;grid-template-columns:repeat(5, 1fr);
gap:10px;
padding:14px;
border:1px solid var(--line);
border-radius:14px;
background:linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,0));
margin-bottom:24px;
position:relative;
}
.step{
display:flex;align-items:center;gap:12px;
padding:12px 14px;
border-radius:10px;
position:relative;
min-width:0;
}
.step .num{
width:26px;height:26px;border-radius:50%;
display:grid;place-items:center;
font-family:var(--mono);font-size:12px;font-weight:600;
background:var(--bg-3);color:var(--text-2);
border:1px solid var(--line-strong);
flex-shrink:0;
}
.step.done .num{background:rgba(34,224,122,.12);color:var(--accent);border-color:rgba(34,224,122,.4)}
.step.active .num{background:var(--accent);color:var(--accent-ink);border-color:transparent;box-shadow:var(--accent-glow)}
.step .label{font-size:12.5px;color:var(--text-2);text-transform:uppercase;letter-spacing:.08em;font-weight:500}
.step.active .label{color:var(--text-0)}
.step.done .label{color:var(--text-1)}
.step .sub{font-size:11px;color:var(--text-3);font-family:var(--mono);margin-top:1px}
.step .meta{display:flex;flex-direction:column;min-width:0}
.step-divider{
position:absolute;top:50%;right:-6px;width:12px;height:1px;
background:var(--line-strong);
}
.step:last-child .step-divider{display:none}
/* -------- Main grid -------- */
.main{
display:grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap:20px;
}
/* Layout: stacked */
body.layout-stacked .main{grid-template-columns:1fr}
body.layout-stacked .console-body{min-height:260px;max-height:360px}
/* Layout: drawer */
body.layout-drawer .main{grid-template-columns:1fr}
body.layout-drawer .console-card{
position:fixed;top:0;right:0;bottom:0;width:min(640px, 92vw);
z-index:40;border-radius:0;border-left:1px solid var(--line-strong);
transform:translateX(100%);transition:transform .25s ease;
box-shadow:-20px 0 60px -10px rgba(0,0,0,.7);
}
body.layout-drawer.drawer-open .console-card{transform:translateX(0)}
body.layout-drawer .console-body{min-height:0;max-height:none;flex:1}
.drawer-toggle{
display:none;position:fixed;bottom:20px;right:20px;z-index:35;
padding:12px 16px;border-radius:999px;
background:var(--accent);color:var(--accent-ink);font-weight:600;
border:0;cursor:pointer;font-size:13px;
box-shadow:0 10px 30px -8px rgba(34,224,122,.5);
align-items:center;gap:8px;font-family:var(--sans);
}
body.layout-drawer .drawer-toggle{display:inline-flex}
body.layout-drawer.drawer-open .drawer-toggle{display:none}
.card{
background:linear-gradient(180deg, var(--bg-1), var(--bg-2) 60%);
border:1px solid var(--line);
border-radius:var(--radius);
overflow:hidden;
position:relative;
}
.card::before{
content:"";position:absolute;inset:0 0 auto 0;height:1px;
background:linear-gradient(90deg, transparent, rgba(255,255,255,.05), transparent);
}
.card-head{
display:flex;align-items:center;justify-content:space-between;
padding:14px 18px;
border-bottom:1px solid var(--line);
background:rgba(255,255,255,.015);
}
.card-head h2{
margin:0;font-size:11px;font-weight:600;letter-spacing:.14em;
text-transform:uppercase;color:var(--text-2);
display:flex;align-items:center;gap:10px;
}
.card-head h2 .dot{width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent)}
/* -------- Device section -------- */
.device{
padding:20px 22px 18px;
display:flex;flex-direction:column;gap:20px;
border-bottom:1px solid var(--line);
}
.device-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
.serial-label{font-size:10.5px;letter-spacing:.14em;color:var(--text-3);text-transform:uppercase;font-weight:500;margin-bottom:6px}
.serial{
font-family:var(--mono);font-size:22px;font-weight:600;
letter-spacing:-0.01em;color:var(--text-0);
display:flex;align-items:center;gap:10px;
}
.serial .copy{
width:26px;height:26px;display:grid;place-items:center;
border-radius:6px;background:transparent;border:1px solid var(--line);
color:var(--text-3);cursor:pointer;transition:.15s;
}
.serial .copy:hover{color:var(--text-0);border-color:var(--line-strong);background:var(--bg-3)}
.manufactured{
display:inline-flex;align-items:center;gap:8px;
padding:5px 10px;border-radius:999px;
background:rgba(34,224,122,.08);
border:1px solid rgba(34,224,122,.22);
color:var(--accent);
font-family:var(--mono);font-size:10.5px;font-weight:600;
letter-spacing:.1em;text-transform:uppercase;
margin-top:8px;
}
.manufactured .pulse{
width:6px;height:6px;border-radius:50%;background:var(--accent);
box-shadow:0 0 8px var(--accent);
animation:pulse 2s infinite;
}
@keyframes pulse{
0%,100%{opacity:1;transform:scale(1)}
50%{opacity:.6;transform:scale(.85)}
}
.board-grid{
display:grid;grid-template-columns:repeat(3,minmax(0,1fr));
gap:10px;
}
.board-cell{
padding:12px 14px;
border:1px solid var(--line);
border-radius:10px;
background:rgba(255,255,255,.015);
}
.board-cell .k{font-size:10.5px;letter-spacing:.14em;color:var(--text-3);text-transform:uppercase;font-weight:500;margin-bottom:6px}
.board-cell .v{font-family:var(--mono);font-size:14px;color:var(--text-0);font-weight:500;display:flex;align-items:center;gap:8px}
.board-cell .v .sub{color:var(--text-3);font-weight:400;font-size:12.5px}
.board-cell .pill{
display:inline-block;padding:1px 7px;border-radius:4px;
background:rgba(180,139,255,.12);color:var(--violet);
font-size:10.5px;font-weight:600;letter-spacing:.05em;
border:1px solid rgba(180,139,255,.22);
text-transform:uppercase;
}
/* -------- Port status -------- */
.port-bar{
display:flex;align-items:center;gap:12px;
padding:10px 14px;border-radius:10px;
border:1px solid var(--line);
background:var(--bg-2);
}
.port-bar .status-dot{
width:8px;height:8px;border-radius:50%;
background:var(--text-3);
flex-shrink:0;
}
.port-bar.connected .status-dot{background:var(--accent);box-shadow:0 0 10px var(--accent)}
.port-bar.flashing .status-dot{background:var(--warn);box-shadow:0 0 10px var(--warn);animation:pulse 1s infinite}
.port-bar .txt{font-family:var(--mono);font-size:12.5px;color:var(--text-1);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.port-bar .txt strong{color:var(--text-0);font-weight:600}
.port-bar .baud{
font-family:var(--mono);font-size:11px;color:var(--text-3);
padding:3px 8px;border-radius:4px;background:var(--bg-3);
border:1px solid var(--line);
}
/* -------- Settings section -------- */
.settings{
padding:18px 22px 20px;
display:flex;flex-direction:column;gap:18px;
border-bottom:1px solid var(--line);
}
.section-label{
font-size:10.5px;letter-spacing:.14em;color:var(--text-3);
text-transform:uppercase;font-weight:600;
display:flex;align-items:center;gap:10px;
}
.section-label::after{content:"";flex:1;height:1px;background:var(--line)}
/* Segmented NVS control */
.segmented{
display:inline-grid;grid-auto-flow:column;
padding:3px;gap:3px;
background:var(--bg-3);border:1px solid var(--line);
border-radius:8px;
position:relative;
}
.segmented button{
border:0;background:transparent;
color:var(--text-2);font-family:var(--sans);font-size:12.5px;font-weight:500;
padding:7px 14px;border-radius:6px;cursor:pointer;
display:flex;align-items:center;gap:8px;
transition:.18s;
letter-spacing:.01em;
}
.segmented button .tag{
font-family:var(--mono);font-size:9.5px;
padding:1px 5px;border-radius:3px;
background:var(--bg-1);color:var(--text-3);
border:1px solid var(--line-strong);
}
.segmented button:hover{color:var(--text-0)}
.segmented button.active{
background:var(--bg-1);color:var(--text-0);
box-shadow: inset 0 0 0 1px var(--line-strong), 0 1px 0 rgba(0,0,0,.3);
}
.segmented button.active .tag{background:var(--accent);color:var(--accent-ink);border-color:transparent}
.segmented button.active.legacy .tag{background:var(--warn);color:#2a1800}
.row{display:flex;align-items:center;justify-content:space-between;gap:16px}
/* Toggle */
.toggle-row{
display:flex;align-items:center;justify-content:space-between;gap:16px;
padding:12px 14px;border-radius:10px;
border:1px solid var(--line);
background:rgba(255,255,255,.01);
}
.toggle-row.danger-row{
border-color:rgba(255,90,106,.2);
background:rgba(255,90,106,.04);
}
.toggle-text .t{font-size:13.5px;color:var(--text-0);font-weight:500}
.toggle-text .d{font-size:12px;color:var(--text-2);margin-top:2px}
.toggle-row.danger-row .toggle-text .t{color:#ffb9c0}
.toggle-row.danger-row .toggle-text .d{color:#d98d95}
.switch{position:relative;display:inline-block;width:38px;height:22px;flex-shrink:0}
.switch input{opacity:0;width:0;height:0}
.switch .slider{
position:absolute;inset:0;cursor:pointer;
background:var(--bg-3);border:1px solid var(--line-strong);
border-radius:999px;transition:.2s;
}
.switch .slider::before{
content:"";position:absolute;height:14px;width:14px;left:3px;top:50%;transform:translateY(-50%);
background:var(--text-2);border-radius:50%;transition:.2s;
}
.switch input:checked + .slider{background:var(--accent-dim);border-color:rgba(34,224,122,.4)}
.switch input:checked + .slider::before{transform:translate(16px, -50%);background:var(--accent)}
.toggle-row.danger-row .switch input:checked + .slider{background:rgba(255,90,106,.2);border-color:rgba(255,90,106,.4)}
.toggle-row.danger-row .switch input:checked + .slider::before{background:var(--danger)}
/* -------- Flash map -------- */
.flash-map{
padding:18px 22px 20px;
display:flex;flex-direction:column;gap:10px;
}
.partition{
display:grid;
grid-template-columns: 22px minmax(0,1fr) auto;
grid-template-rows: auto auto;
align-items:center;
column-gap:14px;
row-gap:8px;
padding:12px 14px;
border:1px solid var(--line);
border-radius:10px;
background:rgba(255,255,255,.012);
position:relative;
transition:.2s;
}
.partition .icon{grid-column:1;grid-row:1}
.partition .info{grid-column:2;grid-row:1;min-width:0}
.partition .pct{grid-column:3;grid-row:1;min-width:48px}
.partition .bar{grid-column:1 / -1;grid-row:2;width:100%}
.partition.active{
border-color:rgba(34,224,122,.25);
background:rgba(34,224,122,.03);
}
.partition.complete{
border-color:rgba(34,224,122,.18);
}
.partition .icon{
width:22px;height:22px;display:grid;place-items:center;
color:var(--text-3);
}
.partition.active .icon{color:var(--accent)}
.partition.complete .icon{color:var(--accent)}
.partition .info .name{font-size:13px;color:var(--text-0);font-weight:500}
.partition .info .addr{font-family:var(--mono);font-size:11px;color:var(--text-3);margin-top:2px}
.partition.active .info .addr{color:var(--accent)}
.bar{
height:6px;background:var(--bg-3);border-radius:3px;overflow:hidden;
position:relative;border:1px solid var(--line);
}
.bar-fill{
height:100%;background:var(--accent);border-radius:3px;
transition:width .3s ease;
position:relative;
box-shadow:0 0 8px rgba(34,224,122,.4);
}
.bar-fill::after{
content:"";position:absolute;inset:0;
background:linear-gradient(90deg, transparent, rgba(255,255,255,.25), transparent);
animation:shimmer 1.4s infinite;
}
.partition.complete .bar-fill::after{display:none}
@keyframes shimmer{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}
.partition .pct{
font-family:var(--mono);font-size:12px;color:var(--text-2);
text-align:right;font-weight:500;
font-variant-numeric:tabular-nums;
}
.partition.active .pct{color:var(--accent)}
.partition.complete .pct{color:var(--accent)}
/* -------- Footer / Action bar -------- */
.action-bar{
display:flex;align-items:center;justify-content:space-between;gap:14px;
padding:14px 22px;
background:rgba(0,0,0,.25);
border-top:1px solid var(--line);
}
.meta-inline{
font-family:var(--mono);font-size:11px;color:var(--text-3);
display:flex;align-items:center;gap:10px;
flex-wrap:wrap;
}
.meta-inline .sep{color:var(--line-strong)}
.meta-inline .k{color:var(--text-3)}
.meta-inline .v{color:var(--text-1)}
/* Buttons */
.btn{
display:inline-flex;align-items:center;justify-content:center;gap:9px;
padding:10px 16px;
font-family:var(--sans);font-size:13.5px;font-weight:600;
border-radius:8px;border:1px solid var(--line-strong);
background:var(--bg-3);color:var(--text-0);
cursor:pointer;transition:.15s;
letter-spacing:.005em;
}
.btn:hover{background:var(--bg-2);border-color:#3a4756}
.btn svg{width:15px;height:15px}
.btn-ghost{background:transparent;border-color:var(--line)}
.btn-ghost:hover{background:var(--bg-3)}
.btn-primary{
background:var(--accent);color:var(--accent-ink);
border-color:transparent;
box-shadow:0 1px 0 rgba(255,255,255,.18) inset, 0 8px 24px -10px rgba(34,224,122,.6);
}
.btn-primary:hover{background:#2df088;box-shadow:0 1px 0 rgba(255,255,255,.2) inset, 0 10px 28px -8px rgba(34,224,122,.75)}
.btn-danger{background:rgba(255,90,106,.1);color:#ff8690;border-color:rgba(255,90,106,.25)}
.btn-danger:hover{background:rgba(255,90,106,.15)}
.btn-lg{padding:13px 22px;font-size:14.5px}
.btn .kbd{font-family:var(--mono);font-size:10px;padding:1px 5px;border-radius:3px;background:rgba(0,0,0,.2);color:inherit;border:1px solid rgba(0,0,0,.3);opacity:.7}
.btn-primary .kbd{background:rgba(0,0,0,.15);border-color:rgba(0,0,0,.25)}
/* -------- Console / Log panel -------- */
.console-card{
display:flex;flex-direction:column;
min-height:0;
background:linear-gradient(180deg, #080b0e, #05070a);
}
.console-head{
display:flex;align-items:center;justify-content:space-between;
padding:14px 18px;
border-bottom:1px solid var(--line);
background:rgba(0,0,0,.35);
}
.console-head .tabs{display:flex;gap:2px;font-family:var(--mono);font-size:11px}
.console-head .tabs button{
background:transparent;border:0;color:var(--text-3);cursor:pointer;
padding:6px 10px;border-radius:5px;
font-family:inherit;font-size:inherit;letter-spacing:.05em;text-transform:uppercase;
}
.console-head .tabs button.active{color:var(--text-0);background:var(--bg-3)}
.console-head .tools{display:flex;gap:6px}
.console-head .tools button{
width:28px;height:28px;border-radius:6px;
display:grid;place-items:center;
background:transparent;border:1px solid var(--line);
color:var(--text-3);cursor:pointer;
}
.console-head .tools button:hover{color:var(--text-0);border-color:var(--line-strong);background:var(--bg-3)}
.console-head .tools button svg{width:13px;height:13px}
.console-body{
flex:1;
font-family:var(--mono);font-size:12px;line-height:1.65;
padding:14px 18px 12px;
overflow:auto;
color:#b8c4d0;
min-height:440px;
max-height:560px;
scrollbar-width:thin;scrollbar-color: var(--line-strong) transparent;
background:
repeating-linear-gradient(180deg, rgba(255,255,255,.012) 0 1px, transparent 1px 24px);
}
.console-body::-webkit-scrollbar{width:8px;height:8px}
.console-body::-webkit-scrollbar-thumb{background:var(--line-strong);border-radius:4px}
.log-line{display:grid;grid-template-columns: 100px 56px 1fr;gap:14px;padding:1px 0;white-space:pre-wrap;word-break:break-word}
.log-time{color:#4a5562;font-variant-numeric:tabular-nums;white-space:nowrap}
.log-level{font-weight:600;letter-spacing:.04em}
.log-level.info{color:#6aa9ff}
.log-level.ok{color:var(--accent)}
.log-level.warn{color:var(--warn)}
.log-level.err{color:var(--danger)}
.log-level.dbg{color:#8a97a4}
.log-msg .tok-hex{color:#b48bff}
.log-msg .tok-num{color:#5ab0ff}
.log-msg .tok-file{color:var(--accent)}
.log-msg .tok-em{color:#eef3f8;font-weight:600}
.log-msg .tok-mut{color:#6a7683}
.caret{display:inline-block;width:7px;height:13px;background:var(--accent);vertical-align:middle;animation:blink 1s steps(2) infinite;margin-left:4px}
@keyframes blink{50%{opacity:0}}
.console-empty{
display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;min-height:420px;
color:var(--text-3);font-family:var(--mono);font-size:12px;
gap:14px;
text-align:center;
}
.console-empty .bigdot{
width:54px;height:54px;border-radius:50%;
background:radial-gradient(circle at 30% 30%, #1a2430, #0c1319);
border:1px solid var(--line);
display:grid;place-items:center;
}
.console-empty .bigdot svg{width:22px;height:22px;color:var(--text-3)}
.console-foot{
display:flex;align-items:center;justify-content:space-between;
padding:6px 18px;border-top:1px solid var(--line);
font-family:var(--mono);font-size:10.5px;color:var(--text-3);
letter-spacing:.05em;text-transform:uppercase;
background:rgba(0,0,0,.25);
line-height:1.4;
}
.console-foot .stats{display:flex;gap:16px}
.console-foot strong{color:var(--text-1);font-weight:600}
/* -------- Tweaks panel -------- */
.tweaks{
position:fixed;bottom:20px;right:20px;z-index:30;
width:260px;
background:var(--bg-1);border:1px solid var(--line-strong);
border-radius:12px;
padding:14px;
display:none;
box-shadow:0 20px 60px -20px rgba(0,0,0,.8);
font-size:12.5px;
}
.tweaks.on{display:block}
.tweaks h3{margin:0 0 10px;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--text-2)}
.tweaks .tw-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 0}
.tweaks .tw-row label{color:var(--text-1)}
.tweaks select{
background:var(--bg-3);color:var(--text-0);
border:1px solid var(--line-strong);border-radius:6px;
padding:5px 8px;font-family:var(--mono);font-size:11px;
}
/* Small helpers */
.hide{display:none !important}
@media (max-width: 1100px){
.main{grid-template-columns:1fr}
.stepper{grid-template-columns:repeat(5, minmax(0,1fr));font-size:11px}
}

42
strategies/PROMPT.txt Normal file
View File

@@ -0,0 +1,42 @@
frontend\src\pages\bellcloud\devices\notes
frontend\src\pages\bellcloud\devices\DeviceForm.jsx
frontend\src\pages\bellcloud\mqtt
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
Read CLAUDE.md and DESIGN.md and use your /frontend-design for this task !
Build PAGE at PAGEURL
REFERENCE (logic only — do not copy styling):
- Archive: ARCHIVEPAGEURL
- Use this ONLY to understand: API endpoints, data fields, user actions
DESIGN REFERENCE:
- Style guide: frontend/src/pages/dev/StyleGuide.jsx
- Match components, spacing, and patterns exactly as shown there
- Use only components from frontend/src/components/ui/
- Use only CSS token variables — no hardcoded values
When done, replace the <ComingSoon /> for this route in frontend/src/router/index.jsx
STITCH REFERENCE:
- frontend/src/_archive/stitch-references/PageName.html
- Use this as visual/layout inspiration for this specific page
- Translate the layout and visual structure into React using our design system
- Do not copy Stitch's inline styles — use our tokens instead
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
Apply the same glass effect as the DataTable and Card components to [component/page name].
Replace any solid background: var(--color-bg-*) with rgba(28, 32, 38, 0.30) (or 0.40 for elevated/nested surfaces)
Add backdrop-filter: var(--blur-modal) and -webkit-backdrop-filter: var(--blur-modal)
Replace any solid hover/active/selected background colors with their semi-transparent equivalents (rgba(49, 53, 60, 0.50))
Keep borders and shadows unchanged

View File

@@ -0,0 +1,76 @@
TODO FEATURES AND FIXES
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
// CONSOLE:
- Add an option to view all melodies for a user, delete them (one by one or flush), and edit them / reorder them.
- Automate the adding of a device on the cloud after it has been sold. Not necessarily claim it, but add it on the cloud with Subscription settings in place.
- Device Subscription Start (subscrStart) value on Firestore should be timestamp not string.
- Automate the whole 'device settings' process with a step-menu modal.
- On the Firmware Release Upload modal, do not close when clicking outside. Only with CLOSE button or ESC key.
- Implement a system to allow only approved users, to register an account on our app or automate the user creation from the console, and in turn:
- Automate (properly) the device assignment to the users.
- When editing a location allow to show the map even withour coordinates, and add them when I first CLICK on the map.
- On the Quotations page, add "Add Legacy" option.
- Add Uptime Indicator per device
- Add "Retired" option for Melodies
- On the MAIL modal add option to "Seach for Customer" on the email field. When searching and selecting a customer, allow us to choose WHICH email, if they have multiple.
## Customer/FinanceTab:
- The overall Financial Status should be smaller, more compact and differentiated than the active order statuses.
- When an order is complete in terms of finance, make it really obvious (maybe make the whole payment container green)
- Do not allow negative numbers in Amount fields.
## Device Inventory Page:
- Add a button somewhere to "Send APP Link via mail"
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
// FIRMWARE:
- When Resetting Settings, via the S1 Key, if on AP Mode, switch back to STA Mode.
- On the /{deviceip}/settings page, add basic auth with pass: "bsvspr8998"
- Add AutoDST switch (figure out the system behind it)
- Add
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
// FLUTTER APP:
- Add a post to firestore each time the app LOGS IN, or Loads the Playback Menu
- Add a option/system to allow the Console to REFRESH the melodies list with the new Titles/Descriptions/Info
- Add option to DELETE melodies
- Add option in the Settings to Test Fire a bell