Compare commits

...

4 Commits

Author SHA1 Message Date
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
13 changed files with 3589 additions and 87 deletions

View File

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

View File

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

View File

@@ -12,6 +12,9 @@ services:
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro - ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
ports: ports:
- "8000:8000" - "8000:8000"
depends_on:
postgres:
condition: service_healthy
networks: networks:
- internal - internal
@@ -23,7 +26,6 @@ services:
- /app/node_modules - /app/node_modules
ports: ports:
- "5173:5174" - "5173:5174"
- "8001:5174"
networks: networks:
- internal - internal
@@ -40,6 +42,24 @@ services:
networks: networks:
- internal - 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: networks:
internal: internal:
driver: bridge driver: bridge

View File

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

View File

@@ -35,6 +35,7 @@ const ENTITY_LABELS = {
customer: 'Customer', customer: 'Customer',
order: 'Order', order: 'Order',
device: 'Device', device: 'Device',
device_batch: 'Device Batch',
melody: 'Melody', melody: 'Melody',
product: 'Product', product: 'Product',
staff: 'Staff', staff: 'Staff',
@@ -246,7 +247,7 @@ function LogRow({ entry, isExpanded, onToggle }) {
</td> </td>
{/* Action badge */} {/* Action badge */}
<td className="log-cell"> <td className="log-cell" style={{ whiteSpace: 'nowrap' }}>
<StatusBadge variant={variant}>{label}</StatusBadge> <StatusBadge variant={variant}>{label}</StatusBadge>
</td> </td>
@@ -492,9 +493,7 @@ const INITIAL_FILTERS = {
offset: 0, offset: 0,
} }
export default function LogViewerPage() { function LogViewerContent() {
const { user } = useAuth()
const [entries, setEntries] = useState([]) const [entries, setEntries] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@@ -592,7 +591,7 @@ export default function LogViewerPage() {
.log-table { .log-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: auto;
} }
.log-table thead th { .log-table thead th {
padding: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-4);
@@ -668,7 +667,7 @@ export default function LogViewerPage() {
<tr> <tr>
<th style={{ width: 148 }}>Timestamp</th> <th style={{ width: 148 }}>Timestamp</th>
<th style={{ width: 160 }}>Staff</th> <th style={{ width: 160 }}>Staff</th>
<th style={{ width: 110 }}>Action</th> <th style={{ whiteSpace: 'nowrap' }}>Action</th>
<th>Entity</th> <th>Entity</th>
<th style={{ width: 120 }}>ID</th> <th style={{ width: 120 }}>ID</th>
<th style={{ width: 40 }} /> <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: { server: {
host: '0.0.0.0', host: '0.0.0.0',
allowedHosts: ['.localhost', 'console.bellsystems.net'],
port: 5174, port: 5174,
hmr: { hmr: {
clientPort: 8001, clientPort: 8001,

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