Compare commits
4 Commits
063106a29c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1022b7e5f1 | |||
| 024ba88470 | |||
| 9a213e93f8 | |||
| d8b0b9ce28 |
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.postgres import get_pg_session
|
||||
from shared.orm import AuditLog
|
||||
from auth.dependencies import require_admin_or_above
|
||||
from auth.dependencies import require_sysadmin
|
||||
from auth.models import TokenPayload
|
||||
|
||||
router = APIRouter(prefix="/api/audit-log", tags=["audit-log"])
|
||||
@@ -26,7 +26,7 @@ async def list_audit_log(
|
||||
to_date: Optional[datetime] = Query(None),
|
||||
limit: int = Query(_DEFAULT_LIMIT, ge=1, le=_MAX_LIMIT),
|
||||
offset: int = Query(0, ge=0),
|
||||
_user: TokenPayload = Depends(require_admin_or_above),
|
||||
_user: TokenPayload = Depends(require_sysadmin),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
filters = []
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi.responses import Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
@@ -13,9 +14,10 @@ from manufacturing.models import (
|
||||
ManufacturingStats,
|
||||
)
|
||||
from manufacturing import service
|
||||
from manufacturing import audit
|
||||
from shared.audit import log_action
|
||||
from shared.exceptions import NotFoundError
|
||||
from shared.firebase import get_db as get_firestore
|
||||
from database.postgres import get_pg_session
|
||||
|
||||
|
||||
class LifecycleEntryPatch(BaseModel):
|
||||
@@ -43,26 +45,21 @@ def get_stats(
|
||||
return service.get_stats()
|
||||
|
||||
|
||||
@router.get("/audit-log")
|
||||
async def get_audit_log(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
):
|
||||
entries = await audit.get_recent(limit=limit)
|
||||
return {"entries": entries}
|
||||
|
||||
|
||||
@router.post("/batch", response_model=BatchResponse, status_code=201)
|
||||
async def create_batch(
|
||||
body: BatchCreate,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
result = service.create_batch(body)
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="batch_created",
|
||||
detail={
|
||||
"batch_id": result.batch_id,
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="CREATE",
|
||||
entity_type="device_batch",
|
||||
entity_id=result.batch_id,
|
||||
entity_label=f"Batch {result.batch_id} ({result.board_type}, qty {len(result.serial_numbers)})",
|
||||
meta={
|
||||
"board_type": result.board_type,
|
||||
"board_version": result.board_version,
|
||||
"quantity": len(result.serial_numbers),
|
||||
@@ -137,6 +134,7 @@ async def update_status(
|
||||
sn: str,
|
||||
body: DeviceStatusUpdate,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
# Guard: claimed requires at least one user in user_list
|
||||
# (allow if explicitly force_claimed=true, which the mfg UI sets after adding a user manually)
|
||||
@@ -167,11 +165,13 @@ async def update_status(
|
||||
)
|
||||
|
||||
result = service.update_device_status(sn, body, set_by=user.email)
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="status_updated",
|
||||
serial_number=sn,
|
||||
detail={"status": body.status.value, "note": body.note},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="STATUS_CHANGE",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"status": body.status.value, "note": body.note},
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -181,10 +181,11 @@ async def patch_lifecycle_entry(
|
||||
sn: str,
|
||||
body: LifecycleEntryPatch,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Edit the date and/or note of a lifecycle history entry by index."""
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
fs = get_firestore()
|
||||
docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
doc_ref = docs[0].reference
|
||||
@@ -198,7 +199,16 @@ async def patch_lifecycle_entry(
|
||||
history[body.index]["note"] = body.note
|
||||
doc_ref.update({"lifecycle_history": history})
|
||||
from manufacturing.service import _doc_to_inventory_item
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
result = _doc_to_inventory_item(doc_ref.get())
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="UPDATE",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"lifecycle_index": body.index, "date": body.date, "note": body.note},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=200)
|
||||
@@ -206,6 +216,7 @@ async def create_lifecycle_entry(
|
||||
sn: str,
|
||||
body: LifecycleEntryCreate,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Upsert a lifecycle history entry for the given status_id.
|
||||
|
||||
@@ -214,8 +225,8 @@ async def create_lifecycle_entry(
|
||||
a status is visited more than once (max one entry per status).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
fs = get_firestore()
|
||||
docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
doc_ref = docs[0].reference
|
||||
@@ -229,19 +240,28 @@ async def create_lifecycle_entry(
|
||||
"set_by": user.email,
|
||||
}
|
||||
|
||||
# Overwrite existing entry for this status if present, else append
|
||||
existing_idx = next(
|
||||
(i for i, e in enumerate(history) if e.get("status_id") == body.status_id),
|
||||
None,
|
||||
)
|
||||
if existing_idx is not None:
|
||||
is_update = existing_idx is not None
|
||||
if is_update:
|
||||
history[existing_idx] = new_entry
|
||||
else:
|
||||
history.append(new_entry)
|
||||
|
||||
doc_ref.update({"lifecycle_history": history})
|
||||
from manufacturing.service import _doc_to_inventory_item
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
result = _doc_to_inventory_item(doc_ref.get())
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="UPDATE" if is_update else "CREATE",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"lifecycle_status": body.status_id, "date": new_entry["date"], "note": body.note},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/devices/{sn}/lifecycle/{index}", response_model=DeviceInventoryItem)
|
||||
@@ -249,10 +269,11 @@ async def delete_lifecycle_entry(
|
||||
sn: str,
|
||||
index: int,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Delete a lifecycle history entry by index. Cannot delete the entry for the current status."""
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
fs = get_firestore()
|
||||
docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
doc_ref = docs[0].reference
|
||||
@@ -261,12 +282,22 @@ async def delete_lifecycle_entry(
|
||||
if index < 0 or index >= len(history):
|
||||
raise HTTPException(status_code=400, detail="Invalid lifecycle entry index")
|
||||
current_status = data.get("mfg_status", "")
|
||||
if history[index].get("status_id") == current_status:
|
||||
deleted_entry = history[index]
|
||||
if deleted_entry.get("status_id") == current_status:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the entry for the current status. Change the status first.")
|
||||
history.pop(index)
|
||||
doc_ref.update({"lifecycle_history": history})
|
||||
from manufacturing.service import _doc_to_inventory_item
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
result = _doc_to_inventory_item(doc_ref.get())
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="DELETE",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"lifecycle_status": deleted_entry.get("status_id"), "index": index},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/devices/{sn}/nvs.bin")
|
||||
@@ -276,12 +307,16 @@ async def download_nvs(
|
||||
hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"),
|
||||
nvs_schema: Optional[str] = Query(None, description="NVS schema to use: 'legacy' or 'new' (default)"),
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override, legacy=(nvs_schema == "legacy"))
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="device_flashed",
|
||||
serial_number=sn,
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="COMMAND",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"command": "nvs_flash", "hw_type_override": hw_type_override, "nvs_schema": nvs_schema or "new"},
|
||||
)
|
||||
return Response(
|
||||
content=binary,
|
||||
@@ -295,16 +330,19 @@ async def assign_device(
|
||||
sn: str,
|
||||
body: DeviceAssign,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
try:
|
||||
result = service.assign_device(sn, body)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="device_assigned",
|
||||
serial_number=sn,
|
||||
detail={"customer_id": body.customer_id},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="UPDATE",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"customer_id": body.customer_id},
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -314,6 +352,7 @@ async def delete_device(
|
||||
sn: str,
|
||||
force: bool = Query(False, description="Required to delete sold/claimed devices"),
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Delete a device. Sold/claimed devices require force=true."""
|
||||
try:
|
||||
@@ -322,11 +361,13 @@ async def delete_device(
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="device_deleted",
|
||||
serial_number=sn,
|
||||
detail={"force": force},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="DELETE",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"force": force},
|
||||
)
|
||||
|
||||
|
||||
@@ -334,6 +375,7 @@ async def delete_device(
|
||||
async def send_manufactured_email(
|
||||
sn: str,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Send the 'device manufactured' notification to the assigned customer's email."""
|
||||
db = get_firestore()
|
||||
@@ -361,11 +403,13 @@ async def send_manufactured_email(
|
||||
device_name=hw_family.replace("_", " ").title(),
|
||||
customer_name=customer_name,
|
||||
)
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="email_manufactured_sent",
|
||||
serial_number=sn,
|
||||
detail={"recipient": email},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="COMMAND",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"command": "email_manufactured", "recipient": email},
|
||||
)
|
||||
|
||||
|
||||
@@ -373,6 +417,7 @@ async def send_manufactured_email(
|
||||
async def send_assigned_email(
|
||||
sn: str,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Send the 'device assigned / app instructions' email to the assigned user(s)."""
|
||||
db = get_firestore()
|
||||
@@ -407,24 +452,30 @@ async def send_assigned_email(
|
||||
errors.append(str(exc))
|
||||
if errors:
|
||||
raise HTTPException(status_code=500, detail=f"Some emails failed: {'; '.join(errors)}")
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="email_assigned_sent",
|
||||
serial_number=sn,
|
||||
detail={"user_count": len(user_list)},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="COMMAND",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"command": "email_assigned", "user_count": len(user_list)},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/devices", status_code=200)
|
||||
async def delete_unprovisioned(
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Delete all devices with status 'manufactured' (never provisioned)."""
|
||||
deleted = service.delete_unprovisioned_devices()
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="bulk_delete_unprovisioned",
|
||||
detail={"count": len(deleted), "serial_numbers": deleted},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="DELETE",
|
||||
entity_type="device_batch",
|
||||
entity_id="bulk_unprovisioned",
|
||||
entity_label=f"Bulk delete unprovisioned ({len(deleted)} devices)",
|
||||
meta={"count": len(deleted), "serial_numbers": deleted},
|
||||
)
|
||||
return {"deleted": deleted, "count": len(deleted)}
|
||||
|
||||
@@ -466,6 +517,7 @@ async def delete_flash_asset(
|
||||
hw_type: str,
|
||||
asset: str,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Delete a single flash asset file (bootloader.bin or partitions.bin)."""
|
||||
if asset not in VALID_FLASH_ASSETS:
|
||||
@@ -474,10 +526,13 @@ async def delete_flash_asset(
|
||||
service.delete_flash_asset(hw_type, asset)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="flash_asset_deleted",
|
||||
detail={"hw_type": hw_type, "asset": asset},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="DELETE",
|
||||
entity_type="firmware",
|
||||
entity_id=f"{hw_type}/{asset}",
|
||||
entity_label=f"{hw_type} / {asset}",
|
||||
meta={"hw_type": hw_type, "asset": asset},
|
||||
)
|
||||
|
||||
|
||||
@@ -489,7 +544,8 @@ class FlashAssetNoteBody(BaseModel):
|
||||
async def set_flash_asset_note(
|
||||
hw_type: str,
|
||||
body: FlashAssetNoteBody,
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Save (or overwrite) the note for a hw_type's flash asset set.
|
||||
|
||||
@@ -497,6 +553,14 @@ async def set_flash_asset_note(
|
||||
Pass an empty string to clear the note.
|
||||
"""
|
||||
service.set_flash_asset_note(hw_type, body.note)
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="UPDATE",
|
||||
entity_type="firmware",
|
||||
entity_id=hw_type,
|
||||
entity_label=hw_type,
|
||||
meta={"note": body.note},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/flash-assets/{hw_type}/{asset}", status_code=204)
|
||||
@@ -504,7 +568,8 @@ async def upload_flash_asset(
|
||||
hw_type: str,
|
||||
asset: str,
|
||||
file: UploadFile = File(...),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Upload a bootloader.bin or partitions.bin for a given hw_type.
|
||||
|
||||
@@ -512,13 +577,20 @@ async def upload_flash_asset(
|
||||
and .pio/build/{env}/partitions.bin). Upload them once per hw_type after
|
||||
each PlatformIO build that changes the partition layout.
|
||||
"""
|
||||
# hw_type can be a standard board type OR a bespoke UID (any non-empty slug)
|
||||
if not hw_type or len(hw_type) > 128:
|
||||
raise HTTPException(status_code=400, detail="Invalid hw_type/bespoke UID.")
|
||||
if asset not in VALID_FLASH_ASSETS:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}")
|
||||
data = await file.read()
|
||||
service.save_flash_asset(hw_type, asset, data)
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="CREATE",
|
||||
entity_type="firmware",
|
||||
entity_id=f"{hw_type}/{asset}",
|
||||
entity_label=f"{hw_type} / {asset}",
|
||||
meta={"hw_type": hw_type, "asset": asset, "size_bytes": len(data)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/devices/{sn}/bootloader.bin")
|
||||
|
||||
@@ -12,6 +12,9 @@ services:
|
||||
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
|
||||
@@ -23,7 +26,6 @@ services:
|
||||
- /app/node_modules
|
||||
ports:
|
||||
- "5173:5174"
|
||||
- "8001:5174"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
@@ -40,6 +42,24 @@ services:
|
||||
networks:
|
||||
- internal
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: bellsystems-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
|
||||
@@ -1107,7 +1107,9 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
||||
border: '1px solid var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
minHeight: 320,
|
||||
height: 320,
|
||||
alignSelf: 'start',
|
||||
position: 'sticky', top: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
|
||||
@@ -35,6 +35,7 @@ const ENTITY_LABELS = {
|
||||
customer: 'Customer',
|
||||
order: 'Order',
|
||||
device: 'Device',
|
||||
device_batch: 'Device Batch',
|
||||
melody: 'Melody',
|
||||
product: 'Product',
|
||||
staff: 'Staff',
|
||||
@@ -246,7 +247,7 @@ function LogRow({ entry, isExpanded, onToggle }) {
|
||||
</td>
|
||||
|
||||
{/* Action badge */}
|
||||
<td className="log-cell">
|
||||
<td className="log-cell" style={{ whiteSpace: 'nowrap' }}>
|
||||
<StatusBadge variant={variant}>{label}</StatusBadge>
|
||||
</td>
|
||||
|
||||
@@ -492,9 +493,7 @@ const INITIAL_FILTERS = {
|
||||
offset: 0,
|
||||
}
|
||||
|
||||
export default function LogViewerPage() {
|
||||
const { user } = useAuth()
|
||||
|
||||
function LogViewerContent() {
|
||||
const [entries, setEntries] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
@@ -592,7 +591,7 @@ export default function LogViewerPage() {
|
||||
.log-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
table-layout: auto;
|
||||
}
|
||||
.log-table thead th {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
@@ -668,7 +667,7 @@ export default function LogViewerPage() {
|
||||
<tr>
|
||||
<th style={{ width: 148 }}>Timestamp</th>
|
||||
<th style={{ width: 160 }}>Staff</th>
|
||||
<th style={{ width: 110 }}>Action</th>
|
||||
<th style={{ whiteSpace: 'nowrap' }}>Action</th>
|
||||
<th>Entity</th>
|
||||
<th style={{ width: 120 }}>ID</th>
|
||||
<th style={{ width: 40 }} />
|
||||
@@ -752,3 +751,20 @@ export default function LogViewerPage() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LogViewerPage() {
|
||||
const { hasRole } = useAuth()
|
||||
|
||||
if (!hasRole('sysadmin')) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader title="Log Viewer" subtitle="Staff actions and system events across the console" />
|
||||
<div style={{ padding: 'var(--space-12)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
|
||||
Access restricted to system administrators.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <LogViewerContent />
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['.localhost', 'console.bellsystems.net'],
|
||||
port: 5174,
|
||||
hmr: {
|
||||
clientPort: 8001,
|
||||
|
||||
@@ -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)
|
||||
@@ -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&family=JetBrains+Mono:wght@400;500;600;700&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 & 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
@@ -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&family=JetBrains+Mono:wght@400;500;600;700&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 & 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 & 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>
|
||||
@@ -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&family=JetBrains+Mono:wght@400;500;600;700&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/<serial>/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/<id></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 |
@@ -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}
|
||||
}
|
||||
Reference in New Issue
Block a user